Packaging and releasing CLIs

Reproducible Go builds: build twice, compare the hashes

· 11 min read

On this page (9)
  1. Who needs this
  2. What the test looks like
  3. The two mistakes we trigger on purpose
  4. Other things that make builds differ
  5. Reading the differences when they happen
  6. Mistakes we see
  7. Where each fix goes
  8. What a pass gives you
  9. FAQ

You publish a Go binary, and someone asks how they can know it was built from the source in your repository. If you cannot rebuild it and get the same bytes, the honest answer is "you trust me". A reproducible build turns that into something checkable: two clean builds of one commit produce identical files, so anyone can build the tag and compare hashes with your release.

Go is closer to this than most toolchains. Close is not the same as there, and the gap is a handful of specific flags and settings that this post walks through, with a test you can run on your own project in about ten minutes.

The two-directory test

Four steps: fresh clone per build, empty environment and fresh cache, second build started seconds later, then compare every output file.1Fresh cloneeachTwodirectories,committedcontent only2Emptyenvironmentenv -i, owncache and tempdir3Build twiceSecond onestarts a coupleof secondslater4Compare allfilesBinaries,archives,checksums file

Who needs this

Reproducibility sounds like something for distribution maintainers, but it pays off in plainer situations too.

A CLI that other people install. You ship mycli as GitHub release archives and an install script. Users who care, and every company security review, eventually ask whether the archive matches the tag. With reproducible builds the answer is a command, not a promise. See verifying downloads with checksums and signatures for what the user does with the hash once they have it.

An internal service you deploy to a single VPS. Nobody outside the company reads your source, but you still want to know that the binary running in production is the one CI built from commit abc123, and not something rebuilt on a laptop with a dirty tree. If a rebuild of that commit gives the same hash as the file in /opt/app/releases/, you can say so with certainty. This also makes rollbacks boring: the old release directory can be recreated from the tag if it was ever deleted.

A project with a CI cache problem. Builds that differ for no reason are a symptom. If the same commit built on two runners yields different bytes, something in your pipeline leaks the environment (a path, a clock, a toolchain version). Finding it with a deliberate test is cheaper than finding it during an incident, when you are trying to work out whether a binary is the one you think it is.

A tool that goes through a package manager. Homebrew formulas and Scoop manifests pin the SHA-256 of your archive. If you ever rebuild a release (a re-run job, a moved tag), a reproducible build gives the same hash and nobody's pinned checksum breaks. A non-reproducible one silently changes the hash and every downstream pin fails.

What the test looks like

The test is simple to state. Take one commit. Build it twice, in two different directories, each with a fresh clone, an empty environment, a fresh Go cache and its own temporary directory, and start the second build a couple of seconds after the first so that anything leaking the clock shows up. Then compare every output file.

We run exactly this on the example project in our release kit. Its results for the example: all 13 files were identical, which is 6 binaries, 6 archives and the checksums file. We also check that the checker can fail, because a test that always passes tells you nothing. More on that below.

You do not need any kit to run the idea. Here is the manual version for a plain go build.

# from your repository, with everything committed
rm -rf /tmp/repro && mkdir -p /tmp/repro
for n in a b; do
  git clone -q --no-hardlinks . /tmp/repro/src-$n
  mkdir -p /tmp/repro/$n/home /tmp/repro/$n/gocache /tmp/repro/$n/gopath /tmp/repro/$n/tmp
  ( cd /tmp/repro/src-$n &&
    env -i PATH="$(dirname "$(command -v go)"):/usr/bin:/bin" \
      HOME=/tmp/repro/$n/home GOCACHE=/tmp/repro/$n/gocache \
      GOPATH=/tmp/repro/$n/gopath TMPDIR=/tmp/repro/$n/tmp \
      GOTOOLCHAIN=local CGO_ENABLED=0 \
      go build -trimpath -ldflags="-s -w" -o /tmp/repro/$n/mycli ./cmd/mycli )
  sleep 2
done
sha256sum /tmp/repro/a/mycli /tmp/repro/b/mycli

Two things in there are deliberate. git clone rather than copying your working tree means only committed content is built, so a stray uncommitted file cannot make the result look better or worse than it is. And env -i starts from an empty environment, so a variable set in your shell profile (GOFLAGS, GOAMD64, CGO_ENABLED, a proxy) cannot influence the bytes without you noticing.

If the two hashes match, you are done. If they do not, the next sections are the usual causes.

The two mistakes we trigger on purpose

A check that always reports success is decoration. So we also break the build in two ways and confirm that the checker catches each one.

Putting the build time in the binary

A common pattern is to inject a "build date" with the linker's -X flag:

go build -ldflags "-X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ./cmd/mycli

Every build now contains a different string, so every build hashes differently. In GoReleaser the same trap hides in the defaults: its documented default ldflags include -X main.date={{.Date}}, and {{.Date}} is the build time. When we put the build time into the linker flags of the example project, every one of the 13 files differed, because the date lands in the binary and the binary lands in the archive and the archive's hash lands in the checksums file.

One changed byte travels downstream

A build date inside the binary changes the binary, which changes the archive that contains it, which changes the checksums file that lists the archive hash.Binarybuild date lands hereArchivecontains the binaryChecksums filelists the archivehashchangeschanges

The fix is not to drop the date. It is to use a date that belongs to the commit instead of the clock:

ldflags:
  - -s -w
  - -X example.com/mycli/internal/version.Date={{.CommitDate}}

{{.CommitDate}} is the committer date of the commit being built, so it is the same on every machine and every day. Users still see a date in mycli --version, and it is a date that means something: when that code was committed.

Which date goes in the binary

Comparison of the build clock, which changes on every build, with the commit date, which stays the same on every machine and every day.{{.Date}} (build clock)Different on every buildEvery file hash changesRelease cannot be rebuilt and matched{{.CommitDate}}Same on every machine and dayHashes match across clean builds--version still shows a meaningful date

Leaving out -trimpath

Without -trimpath, Go records the absolute path of every source file in the binary, which shows up in stack traces and in debug data. Build in /tmp/repro/src-a and in /tmp/repro/src-b, and the two binaries differ by exactly those strings.

You can see it for yourself:

go build -o with-paths ./cmd/mycli
go build -trimpath -o without-paths ./cmd/mycli
strings with-paths | grep -c "$PWD"
strings without-paths | grep -c "$PWD"

The first count is above zero. The second should be zero. With the flag set, our example binary contains no source directory path at all, and the two-directory builds match. It also removes your username and home directory layout from a file you are about to publish, which is a small privacy gain on its own.

Both deliberate mistakes were caught by the checker: it reported "not reproducible" and exited non-zero.

Other things that make builds differ

Beyond those two, these are what we see most.

cgo

With CGO_ENABLED=1 the result depends on the C compiler, its version, the system headers and the libc on the build machine. That is a lot of environment to pin. If you do not need cgo, set CGO_ENABLED=0. Static binaries are also easier to ship, because they run on any Linux of the right architecture without a matching libc. If you do need cgo (for example the common SQLite driver that wraps the C library), reproducibility needs a pinned container image for the build, and you should say so in your release notes. There is also the pure-Go option: a SQLite driver written in Go lets you keep CGO_ENABLED=0.

The Go version

Different Go releases produce different binaries, because the compiler and linker change. That is expected and not a bug. The practical consequence is that "reproducible" always means "with this Go version". Pin it. In go.mod, the go line is a minimum, and a toolchain line can name a specific toolchain. In the build environment above we set GOTOOLCHAIN=local so that the test uses exactly the Go on the machine rather than silently downloading another. In CI, use go-version-file: go.mod or an exact version, never "latest".

Different architecture settings

GOAMD64, GOARM64, GOARM and GOFLAGS change code generation. Defaults are stable, but an exported variable on one runner is not. This is another reason the test starts from env -i.

Embedded VCS information

Since Go 1.18, go build inside a git repository stamps the commit, commit time and a "modified" flag into the binary (see go version -m ./mycli, look for the vcs.* lines). This is harmless in a clean clone of the same commit, and it is why we build from a fresh clone: a dirty tree changes vcs.modified and with it the bytes.

Archive metadata

Binaries can match while archives differ. A tarball records file modification times, and a zip does too. If your release step archives files with their on-disk times, every build gives new archives and therefore new checksums. In GoReleaser this is two settings: mod_timestamp on the build sets the binary's file time, and info.mtime on each extra file in archives sets the others. Both should be {{ .CommitDate }} or {{ .CommitTimestamp }}. The GoReleaser configuration walkthrough shows the full block.

Dependencies

Commit go.sum. If you vendor, commit vendor/. If you rely on the module proxy, a retracted or force-moved version is a risk, though the checksum database makes silent changes unlikely. A dependency that embeds generated data with a timestamp will defeat you, and you will find it only by testing.

Rule of thumb: a path in the diff means -trimpath, a timestamp means the clock, identical strings but different hashes means check the build settings.

Reading the differences when they happen

When the hashes differ, do not guess. Look at the bytes.

cmp -l /tmp/repro/a/mycli /tmp/repro/b/mycli | head
strings /tmp/repro/a/mycli > /tmp/repro/a.txt
strings /tmp/repro/b/mycli > /tmp/repro/b.txt
diff /tmp/repro/a.txt /tmp/repro/b.txt | head -20

A path in the diff means -trimpath is missing or a dependency was built outside the module cache. A timestamp means something injected the clock. If the strings are identical and the hashes still differ, compare go version -m output for the two files and look at the build settings. For archives, diffoscope (available as a Debian package) unpacks them and shows the difference per file, including modification times.

Mistakes we see

Testing in the same directory twice. Two builds from the same checkout, same cache, same minute, prove very little. Path leaks, clock leaks and cache effects are invisible. Use two clones in different locations with a fresh cache each.

Comparing only the binary. The thing users download is the archive, and the thing they verify against is the checksums file. A binary that matches while the archive differs is a failed release as far as anyone checking is concerned. Compare all three.

Building a dirty tree. "It matched on my machine" with uncommitted changes means nothing for the tag. Test the commit, not the folder.

Claiming more than the test proved. Two matching builds on one machine, with one Go version, prove that the output does not depend on the directory, the clock or the cache. They do not prove the result is identical on macOS, or with another Go release, or that a third party ever rebuilt it. Say that in your docs. It is still the property that makes an outside check possible.

Fixing the symptom with fixed timestamps in the wrong place. Setting a constant Date of 1970-01-01 makes the hash match and your --version output useless. The commit date does both jobs.

Where each fix goes

SymptomCauseFix
Every file differsClock injected via -XUse {{.CommitDate}}, not {{.Date}}
Binaries differ between directoriesSource path embeddedAdd -trimpath
Binaries match, archives differFile mtimes in the archiveSet mod_timestamp and info.mtime to the commit time
Differs between two machinesGo version or toolchain downloadPin the version, GOTOOLCHAIN=local in the test
Differs only with cgo onC compiler and libc in the outputCGO_ENABLED=0, or a pinned build image
Differs only in a dirty treevcs.modified stampedBuild from a clean clone of the tag

What a pass gives you

Identical bytes across two clean builds mean the result does not depend on the directory, the clock or the environment it was built in. That is the property that lets someone else check a release binary against the source: they build the tag with your Go version and compare the SHA-256 to the one in your checksums file. Pair it with a signed checksums file, as described in verifying downloads with checksums and signatures, and a user can establish both who published a file and that it matches the source.

Put the check in CI on release tags. It adds the time of two builds, and it catches the day someone adds {{.Date}} to the ldflags during a late-night refactor. For the installer side of a release, what a curl-pipe-sh installer should refuse to do covers the code that consumes these hashes.

FAQ

Are Go builds reproducible by default?

Mostly. Go does not embed a build time and its toolchain is deterministic, so the same source, Go version and flags give the same output in many cases. The common breaks are the absolute source path (fixed by -trimpath), a clock injected through -ldflags -X, cgo, and a different Go version.

Does -trimpath change how my program behaves?

It changes the file paths that appear in stack traces and in runtime.Caller, which then show the module path and package instead of an absolute directory. Programs that read their own source file paths at runtime are the only ones affected, and that is rare. Most developers find the shorter traces easier to read.

Do I need to reproduce builds on macOS and Windows too?

Only if you want to promise it. Each target is a separate output, and a Linux host cross-compiling to Darwin with CGO_ENABLED=0 is reproducible in the same way as the Linux binary. Building on a macOS host brings a different environment into play, so pick one build host for releases (CI on Linux is the usual choice) and verify there.

How is this different from signing a release?

A signature says who published the file. A reproducible build says what the file was built from. They answer different questions, and a user who cares about the supply chain wants both: the signature to know the checksums file came from you, and the rebuild to know it matches the source.

How often should I run the check?

On every release tag in CI, and locally whenever you touch build flags, go.mod, or the archive configuration. It is cheap compared with explaining a mismatch after release.

The Go CLI Release Kit includes a repro-check.sh script that does the two-directory build with GoReleaser and compares binaries, archives and the checksums file, along with the negative controls described above. If you would rather write your own from the snippets here, you have everything you need. The kit is at /products/go-release-kit/.

goreleasesecurity