How to Use GoReleaser to Automate Go Releases

Automate Go binary builds and GitHub releases by adding a goreleaser.yml workflow that triggers on version tags.

The release ritual

You spent the weekend building a CLI tool that parses CSV files and outputs JSON. It works great on your machine. Now your friend asks for a copy. You realize you're on macOS, they're on Windows. You spin up a VM, cross-compile, zip the binary, upload it to a GitHub release, and hope the checksum matches. Two weeks later, you fix a bug. You repeat the whole ritual. You forget to bump the version string in main.go. The release notes are empty. You're spending more time packaging than coding.

GoReleaser as a release factory

GoReleaser is a release automation tool. It reads a configuration file, builds your Go code for every target platform, generates checksums, signs artifacts, and uploads everything to GitHub or GitLab. Think of it as a factory line for your binary. You push a tag, the line starts, and by the time you pour a coffee, the release is live with assets for Linux, macOS, and Windows, plus a CHANGELOG and a sha256 sum file. You don't write the build script. You declare what you want, and GoReleaser figures out the steps.

The tool respects Go conventions. It expects a go.mod file. It uses go build under the hood. It doesn't fight the toolchain. GoReleaser handles the matrix of operating systems and architectures so you don't have to maintain a fragile shell script that breaks when a new Go version drops.

GoReleaser handles the matrix. You handle the tag.

Minimal configuration

Here's the simplest config: tell GoReleaser where your main file is and let it handle the rest.

# .goreleaser.yaml
# Declares the build configuration for GoReleaser
builds:
  - main: ./cmd/mytool
    # Points to the package containing the main function
    binary: mytool
    # Sets the output binary name; GoReleaser appends OS/arch suffixes automatically

Save this file as .goreleaser.yaml in your repository root. Run goreleaser release --snapshot --skip=validate to test locally. The --snapshot flag creates a release without uploading. The --skip=validate flag bypasses tag checks for local testing. GoReleaser builds the binary for your current OS and architecture and drops it in the dist folder.

How the build runs

When you run goreleaser release, the tool parses the YAML file. It looks at the current git tag to determine the version. If you're on v1.2.3, it sets ldflags to embed that version into the binary. It then iterates over the build matrix. For a default config, that means linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64. It invokes go build with the correct GOOS and GOARCH for each combination.

Once binaries are ready, GoReleaser generates a SHA256SUMS file. It creates a GitHub release draft and uploads the artifacts. The process is idempotent. If you run it again with the same tag, it updates the existing release rather than creating a duplicate. This makes it safe to retry failed builds.

Don't fight the module system. Keep go.mod clean.

Embedding version info

Hardcoding the version in your source code is a trap. You update the code, forget to update the release, and users get the wrong version string. GoReleaser solves this with linker flags. You declare a variable in your code and let the linker inject the value at build time.

Here's how you embed version info: define a package-level variable and leave it empty.

// cmd/mytool/main.go
package main

import "fmt"

// Version is set by the linker during the release build
var Version string

func main() {
	// Prints the version; defaults to empty string if not set
	fmt.Println("Tool version:", Version)
}

The YAML config tells GoReleaser how to inject variables. You use ldflags to pass -X flags to the linker.

builds:
  - main: ./cmd/mytool
    binary: mytool
    ldflags:
      - -s -w
      # Strips debug info and reduces binary size
      - -X main.Version={{.Version}}
      # Injects the git tag into the Version variable
      - -X main.Commit={{.Commit}}
      # Injects the short commit hash

GoReleaser provides template variables like {{.Version}} and {{.Commit}}. The tool resolves these from git metadata. If you build from a tag v1.0.0, {{.Version}} becomes v1.0.0. If you build from a branch, it falls back to the commit hash. This keeps your binary in sync with your repository state.

The release is only as good as the tag.

Realistic CI workflow

In production, you trigger GoReleaser from CI, not locally. Here's a GitHub Actions workflow that runs on tags.

# .github/workflows/release.yml
# Triggers the release pipeline when a git tag is pushed
name: Release

on:
  push:
    tags:
      - 'v*'
      # Matches tags starting with 'v', like v1.0.0

permissions:
  contents: write
  # Grants the workflow permission to create releases and upload assets

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          # Fetches full history so GoReleaser can generate changelogs and git info

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.21'
          # Pins the Go version to ensure reproducible builds

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          distribution: goreleaser
          version: latest
          args: release --clean
          # --clean removes the dist folder before starting to avoid stale artifacts
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Authenticates with GitHub to create the release and upload files

      - name: Trigger Go module reindex
        run: |
          curl -sSf "https://proxy.golang.org/github.com/${GITHUB_REPOSITORY,,}/@v/${GITHUB_REF_NAME}.info"
          # Pings the Go module proxy to index the new version immediately

The workflow checks out the code with full history. GoReleaser needs git history to generate changelogs and extract commit metadata. It sets up Go and runs the release action. The GITHUB_TOKEN is provided automatically by GitHub Actions, but you must grant contents: write permission. Without that permission, the action crashes trying to create the release. The final step pings the Go module proxy so pkg.go.dev indexes your version immediately.

GoReleaser doesn't format your code. Run gofmt in your CI before the release step. The community expects clean code. Most editors run gofmt on save, so formatting issues should never reach the release branch.

Pipelines and artifacts

GoReleaser supports pipelines. A pipeline is a modular extension. You can enable nfpms to generate .deb and .rpm packages. You can enable brew to publish a Homebrew tap. You can enable snap to publish to the Snap Store. These pipelines run after the builds. They consume the binaries and produce distribution packages. You enable them by adding a section to the YAML. This turns GoReleaser into a full distribution suite. You don't need separate tools for package managers.

nfpms:
  - id: packages
    package_name: mytool
    # Sets the package name for Debian and RPM
    vendor: MyOrg
    # Identifies the vendor in the package metadata
    homepage: https://example.com
    # Links to the project homepage
    maintainer: Dev <dev@example.com>
    # Specifies the package maintainer
    description: A CLI tool for parsing CSV
    # Provides a short description for the package manager
    formats:
      - deb
      # Generates a Debian package
      - rpm
      # Generates an RPM package

Pipelines extend the release. Enable what you need, ignore the rest.

Pitfalls and errors

Common issues start with version embedding. If your code expects a version variable but GoReleaser doesn't set it, the binary reports an empty string. You need to configure ldflags in the YAML to pass -X main.Version={{.Version}}. If you forget the ldflags section, the build succeeds but the version info is missing.

Another pitfall is the binary name. If you name your binary go, it clashes with the system go command on Linux. GoReleaser warns you, but the release still happens. Pick a unique name.

Secrets are another trap. The workflow fails with HTTP 401 Unauthorized if GITHUB_TOKEN isn't available. In GitHub Actions, the token is automatic, but you must grant contents: write permission. Without that permission, the action crashes trying to create the release.

If your go.mod is missing or malformed, GoReleaser aborts with a module loading error. The tool relies on the module system to resolve dependencies. If you run goreleaser release without a tag, the tool warns no tag found and exits. It refuses to create a release without a version identifier. You can override this with --skip=validate, but that defeats the purpose.

GoReleaser warns about missing checksums if you skip the checksum pipeline. Always include checksums. Users need to verify their downloads.

Decision matrix

Use GoReleaser when you distribute binaries to end users and need multi-platform builds, checksums, and release assets.

Use a simple go build script when you only deploy to a single environment like a Docker container or a private server where the client downloads the source.

Use Nix or Guix when you need reproducible system-level packaging with dependency tracking beyond Go modules.

Use a custom Makefile when you have complex build steps like code generation or asset bundling that GoReleaser's hooks cannot express cleanly.

Where to go next