A GoReleaser v2 config for a small Go CLI, line by line
· 11 min read
On this page (12)
Most GoReleaser tutorials show a ten-line file that builds a binary and stop there. A real release needs more: the version printed by --version has to be right, archives need predictable names so an installer can find them, checksums have to be published, the changelog should be readable, and the same configuration should work on a laptop, in GitHub Actions and in GitLab CI. This post builds that file for a small single-binary CLI and explains each block, using GoReleaser v2 key names throughout.
From tag to release page
Who needs this
A solo maintainer with a command-line tool. You have been running go build by hand and uploading files through the web UI. The tool has a few hundred users now, some on Apple Silicon, some on Windows. A configuration file turns a release into git tag and git push.
A team with an internal CLI. Colleagues on Linux and macOS need the same tool, and someone must be able to say which commit a given binary came from. Version, commit and commit date baked into the binary answer that in one command.
A project that already has an install script or a Homebrew tap. Those consume your archive names and your checksums file. If the names change between releases, everything downstream breaks. Pinning the naming in configuration is the point.
Someone moving from GoReleaser v1 to v2. Several keys were renamed. A file that worked last year may now print deprecation warnings or fail. The sections below use the v2 spelling, and a table near the end lists the renames we hit most often.
The whole file
Here is the complete configuration. We will go through it block by block. It is the shape our release kit ships, tested with GoReleaser OSS v2.20.0.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: mycli
builds:
- id: mycli
main: ./cmd/mycli
binary: mycli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X example.com/mycli/internal/version.Version={{.Version}}
- -X example.com/mycli/internal/version.Commit={{.FullCommit}}
- -X example.com/mycli/internal/version.Date={{.CommitDate}}
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
mod_timestamp: "{{ .CommitTimestamp }}"
archives:
- id: default
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
wrap_in_directory: false
files:
- src: LICENSE
info:
mtime: "{{ .CommitDate }}"
- src: README.md
info:
mtime: "{{ .CommitDate }}"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
use: git
filters:
exclude:
- "^docs?(\\(.+\\))?:"
- "^test(s)?(\\(.+\\))?:"
- "^chore(\\(.+\\))?:"
- "^ci(\\(.+\\))?:"
- "^Merge (pull request|branch|remote-tracking)"
release:
prerelease: auto
The first line is a comment that points editors at GoReleaser's JSON schema, which gives you completion and catches typos as you type. The key line is version: 2. It tells GoReleaser to parse the file with the v2 rules. Without it you get v1 behaviour and warnings about keys it considers deprecated.
Builds
builds is a list, and one entry is enough for a single binary. main is the path of the package containing func main, binary is the name of the file inside the archive, and id lets other blocks refer to this build later.
CGO_ENABLED=0 makes the binary static. On Linux that means it does not need a particular libc, so one file runs on Debian, Alpine and a stripped-down container. It also removes the C toolchain from the list of things that can change the output, which matters for reproducible builds.
-trimpath goes under flags. It strips the build machine's directory from the binary. Skip it and two builds in different directories differ, and your username may end up in a file you publish.
goos and goarch take lists, and GoReleaser builds the matrix: three systems times two architectures is six binaries. If a combination makes no sense for your tool, add an ignore list with goos and goarch pairs rather than trimming the lists. Everything above works with go build cross-compilation alone, because there is no cgo.
mod_timestamp sets the modification time of the built binary file. Giving it {{ .CommitTimestamp }} means the file's time is the commit's time, not the time of the build. That matters because the file is then written into an archive, and an archive that records a different time each run has a different hash each run.
The ldflags and the version
The linker flag -X importpath.name=value sets a string variable at link time. For that to work, the variable must be a package-level string that is not initialised from a function:
package version
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
The import path in the flag is the full module path plus the package, not the directory. If your go.mod says module github.com/you/mycli, the flag is -X github.com/you/mycli/internal/version.Version={{.Version}}. A wrong path does not fail the build. The variable keeps its default, and mycli --version prints dev. Run the binary from a snapshot build once, before the first real tag.
-s -w removes the symbol table and DWARF debug information, which makes the file noticeably smaller and costs you debugger symbols. For a small CLI, that is the usual trade.
The three templates deserve attention:
{{.Version}}is the tag with the leadingvremoved, or the snapshot version.{{.FullCommit}}is the full commit hash.{{.Commit}}is also available; use the full form when a support request needs an exact commit.{{.CommitDate}}is the commit's date in UTC. GoReleaser's own default ldflags use{{.Date}}, which is the time of the build, and that single difference is what makes two builds of the same commit hash differently. Prefer the commit date.
Which date goes in ldflags
The fallback inside the package helps people who do not use GoReleaser. If Version is still dev, read debug.ReadBuildInfo() and use the module version recorded by go install example.com/mycli/cmd/[email protected]. That way both install routes print something true.
Archives
In v2 the format key is formats, and it is a list. The old singular format still works for now with a deprecation notice, but new configurations should not use it. The default archive is tar.gz; Windows users expect zip, so format_overrides switches it for goos: windows.
name_template produces mycli_1.2.3_linux_amd64.tar.gz. Treat this as an interface. Your install script, your Homebrew formula and your Scoop manifest build this name from parts, so write it down and change it only on purpose. The installer post shows the script side. Note that {{ .Arch }} for amd64 is amd64, as the Go name, not x86_64.
wrap_in_directory: false puts the files at the archive root. For a single binary, extracting to a folder named after the archive is an annoyance. With the flag off, an installer can extract one named file straight out.
files lists extras. LICENSE and README.md go into every archive, which most open-source licences ask for in a binary distribution. The src and info.mtime form lets you set the modification time of each file, again so that archives do not change between builds. If you leave files out, GoReleaser still includes files matching its default patterns (licence and readme names), so state them if you want the archive contents to be exact.
Checksums
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
GoReleaser writes one file with a line per archive in the same format as sha256sum, so a user can check with sha256sum -c. The default name is already {{ .ProjectName }}_{{ .Version }}_checksums.txt; we spell it out because the installer relies on it, and an explicit setting survives a change of default. sha256 is also the default algorithm. Spelling out defaults you depend on is cheap insurance.
The checksums file is what you sign if you want to protect more than integrity. The verification post covers that, including a GPG signs block that targets this file.
Rule of thumb: treat
name_templateas a public interface. Everything downstream builds archive names from it.
Snapshots
snapshot:
version_template: "{{ incpatch .Version }}-next"
goreleaser release --snapshot --clean builds everything locally and publishes nothing. In v2 the key is version_template; v1 called it name_template. After a tag v1.2.3, the snapshot version is 1.2.4-next, so a snapshot binary cannot be mistaken for a real release. The run leaves everything in dist/, including the six archives and the checksums file, and you can run dist/mycli_linux_amd64_v1/mycli --version and sha256sum -c on it. This is the dry run you do before every tag.
GoReleaser skips the changelog in snapshot mode. To see the changelog without publishing, use a tagged commit and skip the publishing steps: goreleaser release --clean --skip=publish,announce,validate. In v2, --skip replaced the family of --skip-publish style flags.
Changelog
GoReleaser can build release notes from commit messages. use: git reads the local history, which is the right choice for GitLab and for any setup where you do not want the notes to depend on API calls; use: github asks the API and can show authors. sort: asc orders by commit.
filters.exclude takes regular expressions. The ones above drop docs:, test:, chore: and ci: commits, with or without a scope in parentheses, and merge commits. That only works if your commit subjects follow a convention such as Conventional Commits, so pick one and stick to it.
groups goes a step further and sorts what is left into sections, with an order per group:
groups:
- title: "Breaking changes"
regexp: '^.*?(feat|fix|perf|refactor)(\([[:word:]\-\/]+\))?!:.+$'
order: 0
- title: "Features"
regexp: '^.*?feat(\([[:word:]\-\/]+\))?:.+$'
order: 1
- title: "Bug fixes"
regexp: '^.*?fix(\([[:word:]\-\/]+\))?:.+$'
order: 2
- title: "Other changes"
order: 999
The first matching group wins, which is why breaking changes (a ! before the colon) come first. A group without a regexp collects whatever is left. Quote regexes in YAML with single quotes so backslashes survive.
Changelog quality is a function of commit discipline. The configuration cannot rescue a history full of "fix stuff".
Releasing to GitHub or GitLab
release:
prerelease: auto
prerelease: auto marks tags like v1.0.0-rc.1 as pre-releases automatically. draft: true creates the release as a draft you review and publish by hand, which suits a team that wants a pair of eyes on the notes. header and footer accept templates for text before and after the changelog.
GoReleaser works out the owner and repository from the git remote named origin. You only add a github: or gitlab: sub-block with owner and name when the remote does not tell the truth, for example in a mirror.
Which service it publishes to depends on the token in the environment. With GITHUB_TOKEN set it creates a GitHub release. With GITLAB_TOKEN it creates a GitLab release. For a self-hosted GitLab, add gitlab_urls with api and download set to your instance's addresses, and for the CI job token route there is use_job_token: true under the same block.
GitHub Actions
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- run: make test
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Two details cause most first-release failures. fetch-depth: 0 is required, because a shallow clone has no tags and no history, and the changelog needs both. And permissions: contents: write lets the workflow's token create the release. The version: "~> v2" constraint takes the newest v2 and keeps a future v3 from changing behaviour under you.
GitLab CI
release:
stage: release
image:
name: goreleaser/goreleaser:v2.20.0
entrypoint: [""]
variables:
GIT_DEPTH: "0"
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
script:
- goreleaser release --clean
The image's entrypoint must be cleared, or GitLab's shell wrapper will not run your script. GIT_DEPTH: "0" is the GitLab equivalent of fetch-depth: 0. The job needs a GITLAB_TOKEN CI/CD variable, for example a project access token with the api scope, masked and protected so it exists only on protected tags.
Checking before you tag
Run these three commands locally before pushing a tag:
goreleaser check
goreleaser release --snapshot --clean
(cd dist && sha256sum -c *_checksums.txt && ls)
Before you push a tag
goreleaser check validates the file and reports deprecated keys. The snapshot proves it builds for all six targets. The sha256sum -c line proves the checksums file lists what you published. Then run the binary and look at --version: the version, the full commit and the commit date should all be real values, not dev.
Then push the tag from a commit on your default branch, and open the release page to see six archives and one checksums file.
v1 to v2 names we run into
| v1 | v2 |
|---|---|
(no version key) | version: 2 at the top |
archives[].format | archives[].formats (list) |
format_overrides[].format | format_overrides[].formats (list) |
snapshot.name_template | snapshot.version_template |
--skip-publish, --skip-validate | --skip=publish,validate |
--rm-dist | --clean |
goreleaser check names any deprecated key it finds, which is the quickest way to migrate.
Mistakes we see
The ldflags import path points at the wrong package. It silently does nothing. Test with a snapshot build.
{{.Date}} in ldflags. The build time makes every rebuild different. Use {{.CommitDate}}.
Changing name_template casually. Installers, formulas and manifests depend on it.
Shallow clones in CI. No tags, no history, wrong version and an empty changelog.
Tagging from a feature branch. The release builds whatever the tag points at, so make sure it is on the branch you mean.
Treating --snapshot as proof the changelog works. Snapshot mode skips it. Test the notes on a tagged build with publishing skipped.
Floating tool versions. Pin the GoReleaser version in CI (~> v2 or an exact tag) and the Go version through go.mod, so that a release in March matches one in September.
FAQ
Do I need GoReleaser Pro for this?
No. Everything above is in the open-source edition: builds, archives, checksums, changelog, GitHub and GitLab releases and signing. Pro adds things like split and merge builds, and more packaging options, which a one-binary CLI usually does not need.
Can I build with CGO and still use GoReleaser?
Yes, but cross-compiling cgo needs a C cross-compiler for each target, which is why many projects either use a pure-Go dependency or build per platform on separate runners. If you can set CGO_ENABLED=0, do.
How do I include the version in the binary without GoReleaser?
Use the same -ldflags "-X path.Version=..." with go build, and keep the debug.ReadBuildInfo() fallback in code, so go install module@version also reports a version. The Makefile can pass $(git describe --tags --always).
Why are my archives named with the version but no "v"?
{{ .Version }} strips the leading v from the tag. If you want it, use {{ .Tag }} in the template, then make sure every consumer of the name expects it.
How do I publish to Homebrew or Scoop?
GoReleaser has brews and scoops blocks that push to a tap or bucket repository, and they need a token with access to that repository. A simpler route for a small project is to generate the formula or manifest from the checksums file with a script, and commit it yourself.
The Go CLI Release Kit contains this configuration with the lines you must edit marked, the Makefile targets, both CI templates, an installer that matches the archive names, and scripts that generate the Homebrew and Scoop files from the checksums. It lives at /products/go-release-kit/.