How-to › Ship a CLI or REPL

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

Audience
API producer
Level
beginner
Topic
Test, package and distribute a CLI
Languages
Go and Rust
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
dist (cargo-dist)A Rust CLI, with archives, installers, and the release workflow generated from one TOML fileA tool that rewrites your release workflow on every dist init, and a tag that must equal the version in Cargo.tomlYour CLI is Go, or a release workflow you wrote already does what you want
goreleaserA Go CLI, when Homebrew, Scoop, or Docker will follow and you want them from the same fileOne more binary in CI, a YAML dialect with its own template fields, and a release that stops when an earlier step dirtied the treeYour matrix is one target and the release is a single gh release create
hand-written GitHub Actions matrix with go buildNothing to install, and every naming and checksum decision visible in your workflowEvery convention is yours to write and keep: the .exe suffix, the zip, the checksum line, the version stampYou 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.

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.

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

  1. goreleaser’s template reference 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. ↩︎ Back to text

  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. 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. ↩︎ Back to text

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.