The shipping problem
You just finished a CLI tool that actually works. You tested it on your laptop. Now a colleague asks for the macOS binary. A friend on Linux wants to try it. Suddenly you are managing three different build environments, wrestling with architecture flags, and manually uploading zip files to GitHub. The mental overhead of shipping code quickly drowns out the joy of writing it.
How GoReleaser works
GoReleaser solves this by acting as a cross-compilation factory. You push a version tag to your repository, and the tool handles the rest. It fetches your source, compiles it for every target operating system and architecture you specify, packages the binaries into archives, generates checksums, drafts a changelog, and uploads everything to a GitHub release. You get a consistent, reproducible release pipeline without maintaining separate build scripts for each platform.
The tool reads its instructions from a YAML configuration file. It does not guess. If you do not tell it which binaries to build or which platforms to target, it will stop and ask. This explicit design prevents silent failures and keeps your release process predictable. GoReleaser follows the Go community convention of explicit configuration over magic. You declare everything in YAML. When a release fails, the error points directly to the configuration line that caused it.
The GitHub Actions workflow
Here is the GitHub Actions workflow that triggers the release. Save it at .github/workflows/goreleaser.yml.
name: Goreleaser
# Triggers only when a git tag is pushed to the repository
on:
push:
tags:
- "*"
# Grants the workflow permission to create releases and upload assets
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
# Fetches full history so git describe and changelog generation work
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
# Uses the version specified in go.mod automatically
go-version: "^1"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: latest
# --clean removes the dist/ directory before building to avoid stale artifacts
args: release --clean
env:
# The token is automatically injected by GitHub Actions
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Go module reindex (pkg.go.dev)
run: |
# Pings the module proxy so your new version appears in search results immediately
curl -sSf "https://proxy.golang.org/github.com/${GITHUB_REPOSITORY,,}/@v/${GITHUB_REF_NAME}.info"
Push a tag like v1.0.0 to your main branch and the workflow runs automatically. The --clean flag ensures GoReleaser starts from a blank slate every time. Stale binaries from previous runs never sneak into a new release.
What happens under the hood
When the tag hits the remote, GitHub Actions spins up a fresh Ubuntu runner. The checkout step pulls your repository with full commit history. That history matters because GoReleaser uses it to generate the release changelog. Without fetch-depth: 0, the runner only sees the latest commit, and your changelog will be empty.
Next, the Go toolchain installs. The ^1 directive tells the action to read your go.mod file and match the exact version your project requires. This prevents version drift between your local machine and the CI environment.
The GoReleaser action then downloads the binary, reads your .goreleaser.yaml configuration, and begins the build matrix. It compiles your code for each target OS and architecture combination. The compiled binaries land in a dist/ directory. The tool then zips them, calculates SHA256 checksums, and uploads everything to the GitHub release page associated with your tag.
Cross-compilation relies on environment variables. GoReleaser sets GOOS and GOARCH before invoking go build. The Go compiler respects these variables and generates machine code for the target platform without needing a native compiler for that OS. You can build Windows binaries on Linux, macOS binaries on Ubuntu, and ARM binaries on x86 machines. The compiler handles the instruction set translation.
Finally, the curl command pings the Go module proxy. The proxy caches module versions aggressively to protect origin servers from traffic spikes. Without that ping, your new release might take hours or days to appear in go get searches. The request forces an immediate reindex. The proxy responds with a 200 OK and updates its internal index. Your version becomes available to downstream projects within minutes.
Configuration that actually ships
The workflow does nothing without a configuration file. Create .goreleaser.yaml at your project root. Here is a production-ready baseline that covers binaries, archives, checksums, and changelogs.
# Tells GoReleaser which Go version to use during compilation
version: 2
builds:
- binary: mycli
# Targets all standard OS/arch combinations unless overridden
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
# Strips debug symbols to reduce binary size by 50% or more
flags:
- -trimpath
# Embeds version info and removes DWARF/debug symbols
- -ldflags=-s -w -X main.version={{.Version}}
archives:
- format: tar.gz
# Wraps the binary in a clean directory structure
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
# Groups commits by type using conventional commit prefixes
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
The builds section defines what gets compiled. The -trimpath flag removes local filesystem paths from the binary, which improves build reproducibility and prevents leaking your machine directory structure. The -ldflags=-s -w flags strip the symbol table and DWARF debug info. Production binaries do not need debug symbols. Removing them shrinks the file size and speeds up loading. The -X main.version={{.Version}} flag injects the git tag into your Go code at compile time. You can read it at runtime with fmt.Println(version).
The archives section controls packaging. Linux and macOS users expect .tar.gz files. Windows users expect .zip. The format_overrides block handles that split automatically. The checksum section generates a text file with SHA256 hashes for every archive. Users verify the file integrity before running unknown binaries. The changelog section reads your commit history and formats it into the release notes.
Debugging and common failure modes
Tag formatting breaks releases more often than configuration errors. GoReleaser expects semantic versioning with a leading v. Push v1.0.0, not 1.0.0. If you push a tag without the prefix, the tool will still run, but the generated filenames and module proxy ping will use the wrong version string. The module proxy will reject the ping with a 404 Not Found because it expects the v prefix for major version one modules.
Missing repository permissions cause silent upload failures. The workflow needs contents: write to create the release and attach assets. If you use a fork or restrict token scopes, the build succeeds, the binaries sit in dist/, and the workflow finishes without uploading anything. Check the action logs for HTTP 403 Forbidden when pushing assets.
Shallow clones break changelog generation. The default GitHub Actions checkout fetches only one commit. GoReleaser relies on git log to find commits between tags. Without full history, the changelog section stays blank. Always set fetch-depth: 0 in the checkout step.
Module proxy delays frustrate users. The Go module proxy caches versions for up to 24 hours. The curl ping forces an immediate update, but network timeouts occasionally drop the request. If the ping fails, the workflow still succeeds. Users might wait a day for the version to appear in search results. That delay is normal and resolves automatically.
Before pushing a real tag, test your configuration locally. Run goreleaser check to validate the YAML syntax. Run goreleaser release --snapshot --clean to build everything locally without uploading to GitHub. The --snapshot flag generates versioned binaries with a -SNAPSHOT suffix. You can inspect the dist/ directory to verify file names, archive contents, and checksums. Fix configuration errors locally before they fail in CI.
Releases are not an afterthought. They are the bridge between your code and the people who run it. Automate the boring parts so you can focus on the features.
When to reach for GoReleaser
Use GoReleaser when you ship a CLI tool or standalone binary to multiple operating systems. Use GoReleaser when you want automated checksums, changelogs, and GitHub release uploads without writing custom scripts. Use a manual go build pipeline when you only deploy to a single Linux server or container registry. Use go install for internal developer tools that do not need versioned releases or distribution archives. Pick a simple Makefile when your build step is just compiling one binary for your own machine.