# How to cross-compile CLI release archives with goreleaser

> Turn one tag into per-OS archives and a checksums file with goreleaser, a build script you own, or dist for Rust, and stamp the version so no binary says `dev`.

Source: https://voxgig.com/howto/cross-compile-release-archives-with-goreleaser

- Audience: api-producer
- Level: beginner
- Languages: go, rust
- Verified: 2026-10-18
- Published: 2026-10-18

## 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)](https://axodotdev.github.io/cargo-dist/book/) | 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](https://goreleaser.com/customization/builds/builders/go/) | 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](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations) | 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`](https://goreleaser.com/customization/builds/builders/rust/), 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`](https://pkg.go.dev/cmd/link). The variable is declared with a default that is wrong on
purpose, so an unstamped build announces itself.

```go title="main.go"
// 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](https://goreleaser.com/resources/cookbooks/using-main.version/) documents, so a variable
with that exact name is filled with no configuration at all.

```bash
go build -ldflags "-X main.version=1.4.0" -o dist/meters . && ./dist/meters --version
```

```text output
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](https://go.dev/doc/go1.24) describe, and
[`debug.ReadBuildInfo`](https://pkg.go.dev/runtime/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

```yaml title=".goreleaser.yaml"
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`](https://goreleaser.com/customization/builds/builders/go/) 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`](https://goreleaser.com/customization/package/archives/)
keeps the default name template, `{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}`, and
switches Windows to zip, where the default everywhere is `tar.gz`.
[`checksum`](https://goreleaser.com/customization/package/checksum/) writes one sha256 line per
archive into a file whose name is also the default. The
[`snapshot`](https://goreleaser.com/customization/publish/snapshots/) template names a build made
with no tag.

Validate, then build without a tag:

```bash
goreleaser check
```

```bash
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](https://goreleaser.com/resources/errors/dirty/) 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:

```go title="tools/release/main.go"
		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:

```go title="tools/release/main.go"
// 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)
}
```

```bash
go run ./tools/release -version 1.4.0
```

```text output
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:

```bash
tar tzf dist/meters_1.4.0_linux_amd64.tar.gz && unzip -Z1 dist/meters_1.4.0_windows_amd64.zip
```

```text output
meters
meters.exe
```

The workflow form runs the same `go build` once per matrix entry and uploads each archive as an
artifact:

```yaml title="release-matrix.yml"
    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`](https://cli.github.com/manual/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`](https://doc.rust-lang.org/cargo/reference/environment-variables.html)
variable Cargo sets for the compiler, so there is no `dev` default to forget.

```rust title="rust/src/main.rs"
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");
    }
}
```

```bash
cargo run -q --manifest-path rust/Cargo.toml -- --version
```

```text output
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:

```toml title="rust/dist-workspace.toml"
[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](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)
shows. Checksums come as one `.sha256` file per archive rather than one file for all, which the
[configuration reference](https://axodotdev.github.io/cargo-dist/book/reference/config.html)
describes under `checksum`. Cross-compiling Rust needs each target's standard library from
[`rustup target add`](https://rust-lang.github.io/rustup/cross-compilation.html) 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](https://axodotdev.github.io/cargo-dist/book/ci/index.html)
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:

```bash
cd dist && sha256sum -c meters_1.4.0_checksums.txt
```

```text output
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:

```bash
go test -v ./tools/release | grep -E '^(=== RUN|PASS$|FAIL$)'
```

```text output
=== 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.

```bash
go run . --version
```

```text output
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:

```bash
sha256sum -c dist/meters_1.4.0_checksums.txt
```

```text output
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](https://pkg.go.dev/cmd/cgo) 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

- [Test interactive prompts and terminal behavior in a CLI](/howto/test-interactive-prompts-and-tty-behaviour)

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

- [Generate a changelog from conventional commits](/howto/changelog-from-conventional-commits)

## 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.

[^1]: goreleaser's [template reference](https://goreleaser.com/customization/general/templates/)
gives `.Version` as the tag with its `v` prefix stripped. The output of
`git 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 itself `0.0.0-SNAPSHOT` plus the short commit. A missing tag
is treated as version zero, and the commit does the identifying.

[^2]: `go tool dist list` prints the pairs the toolchain can build, 48 of them in the version this
page ran, from `aix/ppc64` to `windows/arm64`. The valid combinations are documented under the
environment section of [installing Go from source](https://go.dev/doc/install/source#environment).
goreleaser's defaults cross three operating systems with three architectures and build eight,
because `darwin/386` is not on the list. The sample builds five. The other forty-three wait for a
user to ask.

[^3]: 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](https://www.gnu.org/software/coreutils/manual/html_node/cksum-output-modes.html).
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.