Overview
Go's static binaries and built-in cross-compilation make releasing unusually mechanical: one CI job can emit a whole OS/arch matrix with no cross-toolchains (as long as cgo stays off). The craft is everything around the compile — making builds reproducible and verifiable, stamping versions, packing assets, automating archives/changelogs/packages with GoReleaser, choosing a container pattern, and attaching the supply-chain artifacts (SBOM, signatures, provenance) that modern consumers expect.
Key points
- Reproducible builds are achievable:
-trimpathstrips build-machine paths, the toolchain is deterministic, and since Go 1.18 binaries carry embedded build info (VCS revision, dirty flag, module versions) readable withgo version -m ./binary. - Cross-compilation is env vars:
GOOS=linux GOARCH=arm64 go build— a release matrix is a nested loop, not a toolchain-installation project. cgo breaks this (CGO_ENABLED=0is the release-build default for a reason). - Version stamping, two ways: classic
-ldflags "-X main.version=v1.2.3"injection at link time, vs reading the stamped VCS data viaruntime/debug.ReadBuildInfo()— prefer the latter for revision/date, keep ldflags for the human-facing tag CI knows and the build info doesn't. //go:embedcompiles assets (templates, migrations, static web files) into the binary — preserving single-file deployment where other stacks grow an assets directory.- GoReleaser is the de-facto release automation: from one
.goreleaser.yamlit builds the matrix, packs archives, generates changelogs, publishes GitHub releases, Homebrew taps and Scoop manifests, nfpm deb/rpm packages, multi-arch Docker manifests, and hooks signing (cosign) and SBOMs (syft). - Container patterns: multi-stage Dockerfile ending
FROM scratchor distroless;koskips Dockerfiles entirely for Kubernetes workloads, building images straight from the importpath and stamping SBOMs. FROM scratchpitfalls: no CA certificates (TLS calls fail), no tzdata (timelocation lookups fail), no/etc/passwdfor a non-root user, no shell for debugging — copy in what you need or usegcr.io/distroless/static, which fixes the first three.- Supply-chain posture: SLSA provenance from CI (e.g. the generic SLSA3 GitHub generator or GoReleaser's attestation support), plus a govulncheck gate — it reports only vulnerabilities in code paths you actually call, so it is quiet enough to make blocking.
Details
Reproducibility checklist
- Build with
-trimpathandCGO_ENABLED=0; pin the toolchain (thetoolchaindirective or a versioned container image). - Don't inject timestamps; derive dates from the VCS commit if needed.
- Verify: build twice on different machines and compare hashes; inspect what shipped
with
go version -m— it lists module versions, build flags, and VCS state, which is also invaluable for incident response ("which binary has the vulnerable dependency?").
Release pipeline shape (CI)
- Tag push
v*triggers the release workflow. - Gate:
go test ./..., lint,govulncheck ./.... - GoReleaser builds the matrix, packs, signs (cosign keyless against the CI OIDC identity), attaches SBOMs (syft), publishes archives + packages + images.
- Provenance attestation uploaded alongside the artifacts (SLSA).
Examples
# .goreleaser.yaml — minimal but production-shaped
builds:
- main: ./cmd/widget
env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
flags: [-trimpath]
ldflags:
- -s -w -X main.version={{ .Version }}
archives:
- formats: [tar.gz]
checksum:
name_template: checksums.txt
sboms:
- artifacts: archive # syft-generated SBOM per archive
changelog:
use: github
# Multi-stage: build in the toolchain image, ship almost nothing
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached layer: deps change rarely
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/widget ./cmd/widget
FROM gcr.io/distroless/static:nonroot # ca-certs + tzdata + nonroot included
COPY --from=build /out/widget /widget
ENTRYPOINT ["/widget"]
Related
- Modules & the Go build ecosystem — the parent summary note.
- Go modules — the dependency side: what go.sum and the checksum database already guarantee before your build starts.
- TinyGo — the alternative compiler when the target is microcontrollers or small WASM, with its own build constraints.
- Security testing — where govulncheck and friends sit in the wider verification strategy.
- Secrets management — signing keys and registry credentials in CI deserve the same discipline as production secrets.