Reproducible builds in Go: hashing the same output twice

Build two copies of the same Go program and compare the hashes. If they differ, the binary you shipped is not the binary you can prove you shipped — and for anything security-relevant, that gap is a supply-chain hole. A user cannot verify that the artifact they downloaded matches your source if the build itself is a moving target. Reproducible builds close that gap: given the same source, the same toolchain, and the same inputs, the output is byte-for-byte identical.

What makes Go builds non-deterministic

Go is closer to reproducible-by-default than most ecosystems, but several inputs quietly change the output:

  • The build ID. By default go build embeds a build ID derived from the action graph. Pass -ldflags="-buildid=" to make it deterministic.
  • Paths. Without -trimpath, the binary records the absolute paths of the source files, which differ between machines.
  • VCS metadata. Since Go 1.18, builds embed version-control information. -buildvcs=false, or a clean checkout, removes it.
  • Version stamping. If you inject a version with -ldflags "-X main.version=...", the value must come from a stable source — a tag, not the build time.
  • The environment. GOFLAGS, GOPATH, CGO_ENABLED, and the module cache all influence the result. Pin them explicitly.

A reproducible build command

A minimal deterministic invocation looks like this:

CGO_ENABLED=0 go build -trimpath -buildvcs=false \
  -ldflags "-buildid= -X main.version=$(git describe --tags)"

Then verify: build twice, hash both binaries, and compare. sha256sum on each build should produce identical output. If it does not, diff the binaries with diffoscope — it shows which bytes changed and why, which makes the fix mechanical instead of mysterious.

Locking the toolchain

Source and flags are only part of it. The Go toolchain itself and every module in the build must be fixed:

  • Toolchain. The go directive, and the newer toolchain directive, in go.mod pin the compiler version. A build with Go 1.21 will not match a build with Go 1.23.
  • Modules. go.sum and vendoring pin exact module contents. The module proxy serves content-addressed, immutable artifacts, so the same go.mod and go.sum resolve to the same code everywhere.
  • The module cache. GOFLAGS=-mod=mod, or vendored dependencies, stop resolution from picking up whatever is already cached locally.

Why it matters for security tools

Reproducibility is what makes signing meaningful. A signed release is only as good as the artifact it covers; if the build is deterministic, anyone can rebuild from source, hash the result, and confirm that the published binary matches. Without it, verification reduces to trusting the signer’s pipeline end to end. This is not an abstract ideal: several widely used Go projects publish rebuild instructions and CI jobs that verify byte-identical output, and initiatives such as reproducible-builds.org track which projects achieve the same property. Go’s toolchain makes the bar unusually reachable — the flags above are essentially the whole recipe.

trustless, a credential broker CLI for AI agents, is released as a single static binary with cosign keyless- signed artifacts and an SBOM, and its version is injected at build time via ldflags from a deterministic source — a tag, not a timestamp — which is exactly what reproducible builds require. The installation guide documents how to verify those signed artifacts before using them.

A binary you can rebuild and compare is a binary you can audit. When was the last time you hashed a release artifact against your own build of the same source?