Your CLI builds on your laptop, which is the one machine your users do not have. A user on an
Apple silicon Mac asks for a binary, a Windows user asks for a zip, and the Homebrew formula you
planned needs a URL and a sha256 for each. You tag v1.4.0, build it by hand, and upload a binary
that reports meters dev.
What you get
You will end up with one tag producing five archives, a checksums file, and a binary that reports its version, from goreleaser or from a build script you own. This is for you if people install your CLI from a GitHub Release.
Short answer
Write a .goreleaser.yaml whose builds entry sets CGO_ENABLED=0, your GOOS and GOARCH lists,
and -X main.version={{ .Version }} in ldflags. Add an archives entry, tar.gz with zip on
Windows, and a checksum section. goreleaser release --snapshot --clean builds every archive
locally with no tag; without --snapshot, on a tag, it attaches them and the checksums file to the
GitHub Release. The version comes from the tag through {{ .Version }}; a binary built any other
way says dev.
You will need
Go 1.24 or later and goreleaser 2 for the Go rows. Rust 1.97 or later and dist for the Rust row.
Verified
2026-10-18 against Go 1.24.7, goreleaser 2.18.3, Rust 1.97.0, and GNU coreutils 9.4 for
sha256sum. The sample is a two-file Go CLI that a Go program of its own cross-compiles for five
targets from one Linux machine, so the captured runs need no goreleaser installed. goreleaser was
run once against the same configuration to compare its output, and dist was read from its book
rather than run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| dist (cargo-dist) | A Rust CLI, with archives, installers, and the release workflow generated from one TOML file | A tool that rewrites your release workflow on every dist init, and a tag that must equal the version in Cargo.toml | Your CLI is Go, or a release workflow you wrote already does what you want |
| goreleaser | A Go CLI, when Homebrew, Scoop, or Docker will follow and you want them from the same file | One more binary in CI, a YAML dialect with its own template fields, and a release that stops when an earlier step dirtied the tree | Your matrix is one target and the release is a single gh release create |
| hand-written GitHub Actions matrix with go build | Nothing to install, and every naming and checksum decision visible in your workflow | Every convention is yours to write and keep: the .exe suffix, the zip, the checksum line, the version stamp | You plan a second channel, where goreleaser’s defaults become the names that channel expects |
goreleaser and dist make the same bet from opposite ends of a toolchain. A release is a tag, and
the artifacts follow conventions you did not write, so a package manager added later finds the
names it expects. The hand-written matrix costs the opposite: no tool to install, so each
convention is yours to write, from the .exe suffix to the two spaces in a checksum line.
goreleaser also builds Rust through
cargo zigbuild, at the price of a
Zig toolchain beside Cargo.
Stamp the version at link time, not in source
A binary learns its version from the linker, through a string variable that the build sets with
-X. The variable is declared with a default that is wrong on
purpose, so an unstamped build announces itself.
// version is set at link time by goreleaser or by tools/release:
//
// go build -ldflags "-X main.version=1.4.0"
//
// Left alone, every build of this file reports dev, tagged or not. That is
// the value an update check compares against the latest release and misreads.
var version = "dev"
goreleaser’s default ldflags set main.version, main.commit, and main.date from its
template fields, as its
cookbook documents, so a variable
with that exact name is filled with no configuration at all.
go build -ldflags "-X main.version=1.4.0" -o dist/meters . && ./dist/meters --version
meters 1.4.0
-X main.version=1.4.0 is the whole mechanism, and the release tools below only supply the value.
The toolchain has a second source of its own. go build stamps the main module’s version from the
VCS tag, with +dirty appended when the tree has uncommitted changes, as the
release notes describe, and
debug.ReadBuildInfo reads it back. The sample
passes -buildvcs=false in its
cross-builds so that the archives come out the same from a checkout with no .git directory.
Describe the matrix once for goreleaser
version: 2
project_name: meters
builds:
- env:
- CGO_ENABLED=0
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}"
Four decisions live in that file.
builds sets CGO_ENABLED=0 so the
Linux binary links no libc and runs in a container with no C library, and -trimpath so no
binary carries a path from the build machine. archives
keeps the default name template, {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}, and
switches Windows to zip, where the default everywhere is tar.gz.
checksum writes one sha256 line per
archive into a file whose name is also the default. The
snapshot template names a build made
with no tag.
Validate, then build without a tag:
goreleaser check
goreleaser release --snapshot --clean
--snapshot skips validation and publishing and sets .Version from the snapshot template, so a
checkout with no tag builds as 0.0.0-SNAPSHOT followed by the short commit.1 Run against this
sample, it wrote the same five archives and the same checksums file as the hand-written tool below,
with that version in every name, and the Windows zip held meters.exe. On a tag, the same command
without --snapshot creates the GitHub Release and attaches every file in dist/. It refuses to
run at all from a dirty tree, which its
error page explains.
Build the same archives by hand to see what the tool decides
The hand-written row is a Go program rather than a shell script, so the same code runs on a laptop
and in the matrix job. Each target is one go build with the environment set:
build := exec.Command("go", "build", "-trimpath", "-buildvcs=false",
"-ldflags", "-s -w -X main.version="+version, "-o", bin, ".")
build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+t.os, "GOARCH="+t.arch)
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
return fmt.Errorf("%s/%s: %w", t.os, t.arch, err)
}
Two names are decisions goreleaser makes without saying so, and the tool has to make out loud:
// binaryName adds .exe on Windows. go build adds the suffix on its own only
// when no -o is given, and a matrix always gives one.
func binaryName(t target) string {
if t.os == "windows" {
return project + ".exe"
}
return project
}
// archiveName follows goreleaser's default template,
// {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}, as a zip on Windows
// and a tar.gz elsewhere, so a channel added later sees the same names from
// either tool.
func archiveName(version string, t target) string {
ext := ".tar.gz"
if t.os == "windows" {
ext = ".zip"
}
return fmt.Sprintf("%s_%s_%s_%s%s", project, version, t.os, t.arch, ext)
}
go run ./tools/release -version 1.4.0
meters_1.4.0_darwin_amd64.tar.gz
meters_1.4.0_darwin_arm64.tar.gz
meters_1.4.0_linux_amd64.tar.gz
meters_1.4.0_linux_arm64.tar.gz
meters_1.4.0_windows_amd64.zip
meters_1.4.0_checksums.txt (5 entries)
Five cross-compiles ran on one Linux machine with no runner matrix. Go’s toolchain carries every
target,2 so the matrix in release-matrix.yml is one way to run this and not a requirement.
Each archive holds one file, with the suffix only where Windows needs it:
tar tzf dist/meters_1.4.0_linux_amd64.tar.gz && unzip -Z1 dist/meters_1.4.0_windows_amd64.zip
meters
meters.exe
The workflow form runs the same go build once per matrix entry and uploads each archive as an
artifact:
strategy:
matrix:
include:
- { goos: darwin, goarch: amd64, ext: tar.gz }
- { goos: darwin, goarch: arm64, ext: tar.gz }
- { goos: linux, goarch: amd64, ext: tar.gz }
- { goos: linux, goarch: arm64, ext: tar.gz }
- { goos: windows, goarch: amd64, ext: zip }
env:
CGO_ENABLED: "0"
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build one target
run: |
version="${GITHUB_REF_NAME#v}"
name="meters_${version}_${GOOS}_${GOARCH}"
bin=meters; [ "$GOOS" = windows ] && bin=meters.exe
go build -trimpath -ldflags "-s -w -X main.version=${version}" -o "$bin" .
if [ "${{ matrix.ext }}" = zip ]; then zip -q "$name.zip" "$bin"; else tar czf "$name.tar.gz" "$bin"; fi
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.goos }}-${{ matrix.goarch }}
path: meters_*
A second job in the same file downloads the five artifacts, writes the checksums file with
sha256sum, and attaches everything with
gh release create. That job is the part
goreleaser’s checksum and release sections replace.
Let the crate manifest own the version
A Rust binary reads its version at compile time from Cargo.toml, through the
CARGO_PKG_VERSION
variable Cargo sets for the compiler, so there is no dev default to forget.
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("--version") {
println!("meters {}", env!("CARGO_PKG_VERSION"));
} else {
println!("meters: a CLI over a meter inventory, see --version");
}
}
cargo run -q --manifest-path rust/Cargo.toml -- --version
meters 1.4.0
dist takes the same number from the same file. Its configuration lives in dist-workspace.toml,
written by dist init, and a release is a tag whose version matches the crate’s:
[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.32.0"
ci = "github"
installers = []
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
checksum = "sha256"
dist plan prints the archives a tag would produce and dist build makes the one for the current
machine, as the quickstart
shows. Checksums come as one .sha256 file per archive rather than one file for all, which the
configuration reference
describes under checksum. Cross-compiling Rust needs each target’s standard library from
rustup target add and usually a
linker too, that page notes. The workflow dist generates builds native binaries on a runner per
operating system, as its CI guide
says, not five from one Linux box.
Check it worked
Verify the archives the way a user will, from inside dist/, where the names in the checksums file
resolve:
cd dist && sha256sum -c meters_1.4.0_checksums.txt
meters_1.4.0_darwin_amd64.tar.gz: OK
meters_1.4.0_darwin_arm64.tar.gz: OK
meters_1.4.0_linux_amd64.tar.gz: OK
meters_1.4.0_linux_arm64.tar.gz: OK
meters_1.4.0_windows_amd64.zip: OK
Every line ends in OK.3 The unit tests pin the two naming decisions and the digest:
go test -v ./tools/release | grep -E '^(=== RUN|PASS$|FAIL$)'
=== RUN TestWindowsGetsTheExeSuffixAndAZip
=== RUN TestEveryOtherTargetIsATarballNamedLikeGoreleaser
=== RUN TestChecksumIsTheDigestSha256sumComputes
PASS
When it goes wrong
Every binary says dev. The build ran without -X main.version, which is what a plain go build
or a go install from the repository does, so an update check compares dev against the release
list and misreads the answer. Stamp the version in every build path you ship from, and keep the
default loud.
go run . --version
meters dev
The checksums do not verify. sha256sum -c resolves each name relative to the current directory,
so a check run from the repository root reports every archive as unreadable:
sha256sum -c dist/meters_1.4.0_checksums.txt
meters_1.4.0_darwin_amd64.tar.gz: FAILED open or read
meters_1.4.0_darwin_arm64.tar.gz: FAILED open or read
meters_1.4.0_linux_amd64.tar.gz: FAILED open or read
meters_1.4.0_linux_arm64.tar.gz: FAILED open or read
meters_1.4.0_windows_amd64.zip: FAILED open or read
Run it from dist/. A user who downloads two files into one directory expects the bare names,
which is why goreleaser writes them that way.
The Linux binary wants a libc the image does not have. cgo is on by default for a native build
and off when cross-compiling, as the cgo documentation says. So on
an ubuntu-latest runner the linux/amd64 binary alone can link against glibc when a dependency
uses cgo, and it then fails to start in a FROM scratch container. CGO_ENABLED=0 in the build
environment makes every target behave like a cross-compile.
When not to do this
Do not hand-write the matrix if a second channel is coming. Homebrew casks, Scoop manifests, and npm launchers each want a download URL and a sha256 per platform, and goreleaser’s sections for them read its own archive names and checksums file. A script that drifts from those names costs a rewrite the week you add the first channel.
Do not run goreleaser from a dirty tree, and do not reach for --skip=validate to make it. A
generated file or a lockfile written by an earlier CI step is what dirtied the tree. A binary
stamped with the tag’s version from sources the tag does not describe is a disagreement no update
check can see. Fix the step that writes the file.
Do not point -X at a variable with an initializer the linker cannot replace. The flag works on a
string declared uninitialized or set to a constant, as cmd/link documents, so a version built
with fmt.Sprint stays dev forever.
Do not treat --snapshot as a release. It skips validation, so a dirty, untagged tree builds
without complaint, and its archives carry a version no tag will ever have.
Related how-tos
Last verified
Verified 2026-10-18 against Go 1.24.7, goreleaser 2.18.3, Rust 1.97.0, and GNU coreutils 9.4.
Every output block is what the command preceding it printed. goreleaser was run once with
--snapshot against this configuration, in a checkout with no tag. Its archive names, checksums
file, and Windows binary name were compared with the hand-written tool’s. That run is described
rather than captured, because the gate that re-runs these pages does not install goreleaser. release-matrix.yml was not run on a runner, and dist was read from its book and not
executed.
Footnotes
-
goreleaser’s template reference gives
.Versionas the tag with itsvprefix stripped. The output ofgit describe --dirty --always --tags,{Tag}-$N-{CommitSHA}, is a different field,.Summary. Run against a checkout with no tag at all, the sample’s snapshot called itself0.0.0-SNAPSHOTplus the short commit. A missing tag is treated as version zero, and the commit does the identifying. ↩︎ Back to text -
go tool dist listprints the pairs the toolchain can build, 48 of them in the version this page ran, fromaix/ppc64towindows/arm64. The valid combinations are documented under the environment section of installing Go from source. goreleaser’s defaults cross three operating systems with three architectures and build eight, becausedarwin/386is not on the list. The sample builds five. The other forty-three wait for a user to ask. ↩︎ Back to text -
The two spaces are a space and a flag. The coreutils manual describes the untagged output format as the checksum, a space, a flag for binary or text input mode, and the filename, in its description of output modes. Binary mode is shown as
*and text mode as a space. Where the distinction means nothing, text mode is the default, so the second character is a space standing for a decision the system did not have to make. ↩︎ Back to text