Skip to content

Builds are reproducible with -trimpath

Level: 301 · anyone who has to say which binary came from which commit

One line: The same source, the same Go and the same flags give the same bytes — built twice, built after a touch, built in another directory — provided -trimpath keeps the build directory out of the binary; without it, two checkouts of one commit differ and each names its directory; and inside a git checkout the go command stamps the commit in, so vcs.modified=true marks a binary built from uncommitted changes.

The script makes one module in two temporary directories, as two people who checked out the same commit would, and compares binaries with cmp. It prints whether they are identical, never a hash, a size or a path.

Verified output of builds_are_reproducible_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build -trimpath -o one . && go build -trimpath -o two . && cmp one two
identical: yes
$ touch main.go && go build -trimpath -o three . && cmp one three
identical: yes
$ (cd "$second" && go build -trimpath -o four .) && cmp one "$second/four"
identical: yes
$ go build -o plain . && (cd "$second" && go build -o plain .) && cmp plain "$second/plain"
identical: no
$ grep -qF "$first" plain; grep -qF "$first" one
plain names its build directory: yes
one names its build directory: no
$ GOFLAGS=-trimpath go build -o five . && cmp one five
identical: yes
$ go version -m one | grep trimpath
    build   -trimpath=true
$ go build -trimpath -ldflags "-X main.version=1.4.2" -o six . && cmp one six
identical: no
$ ./one; ./six
invoice printer dev
invoice printer 1.4.2

Reading the output

Twice, after a touch, from elsewhere: identical. Nothing in a Go binary depends on when it was built or on the modification time of a file: the go command works from file contents (go help build ↗), and the compiler and linker are deterministic. With -trimpath, the second directory's build matched the first byte for byte.

Without -trimpath, the directory is in the binary. The two plain builds differ, and grep finds the first directory's path in plain and not in one: file names for stack traces and debugging information are recorded as absolute paths unless -trimpath replaces them with the module path — example/main.go — or the import path (Go 1.13 release notes ↗, which introduced the flag). The paths in the panic traces of this library's drivers read example/counter.go for the same reason; The race detector notes it.

GOFLAGS=-trimpath is the same flag from the environment, and the binary records it: go version -m one shows build -trimpath=true, which a release check can grep for. GOFLAGS reaches every go command in the job, go test and go run included.

A stamped version changes the bytes, by design. six differs from one because -X main.version=1.4.2 is an input (-ldflags -X sets a variable at link time). Reproducible means the same inputs give the same output; a release script that derives the stamp from the commit keeps it an input that two builders agree on, and one that stamps the date does not.

go env -w, kept away from your own configuration

go env -w NAME=value writes into the go command's configuration file, and every later go command reads it. The file's location is GOENV; the script points it into its own temporary directory, so the user's file is never touched — a page that ran go env -w for real would edit the configuration of whoever ran it:

Verified output of builds_are_reproducible_goenv_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go env GOFLAGS

$ go env -w GOFLAGS=-trimpath
$ cat "$GOENV"
GOFLAGS=-trimpath
$ go env GOFLAGS
-trimpath
$ go build -o configured . && cmp trimmed configured
identical: yes
$ go env -u GOFLAGS && go env GOFLAGS

$ go env -w NOTAVAR=1
go: unknown go command variable NOTAVAR
exit status 1

One line in a file, and the flag is on for every build. After go env -w GOFLAGS=-trimpath, a go build with no flags produced the same bytes as the explicit -trimpath build. go env -u removes the line; a name the go command does not know is refused (go help environment ↗). An environment variable wins over the file, and the file wins over the default, so a CI job that sets GOFLAGS is not undone by a developer's file.

Inside git, the commit is stamped in

Verified output of builds_are_reproducible_vcs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ git init && git add . && git commit -m "invoice printer"
$ go build -trimpath -o stamped . && cmp "$plain/outside" stamped
identical: no
$ go version -m stamped | grep vcs
    build   vcs=git
    build   vcs.revision=...
    build   vcs.time=...
    build   vcs.modified=false
$ go build -trimpath -buildvcs=false -o unstamped . && cmp "$plain/outside" unstamped
identical: yes
$ echo "// a change that is not committed" >>main.go && go build -trimpath -o dirty . && go version -m dirty | grep vcs.modified
    build   vcs.modified=true

The same source in a repository builds different bytes, because the go command records vcs=git, the revision, the commit time and whether the tree was clean (Go 1.18 release notes ↗); the script prints the keys and hides the two values that change with every run. -buildvcs=false turns the stamp off and the bytes match the build outside git again. A tree with an uncommitted change is recorded as vcs.modified=true, the line to refuse a release on. The stamp is only added when the main package's module is in the repository; a single file built as go build main.go gets none. (Not machine-checked here.)

Reproducing the toolchain itself. From Go 1.21, the Go distribution is built reproducibly: the same source gives the same toolchain on any host, so anyone can rebuild a release and compare — Russ Cox, Perfectly Reproducible, Verified Go Toolchains ↗. The go and toolchain lines in go.mod and GOTOOLCHAIN name which Go a build uses (Go Toolchains ↗); a different Go gives different bytes, and is meant to.

builds_are_reproducible_sh.sh, builds_are_reproducible_goenv_sh.sh and builds_are_reproducible_vcs_sh.sh

builds_are_reproducible_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The same source built twice gives the same bytes -- after the source is
# touched, and from a different directory, as long as -trimpath keeps the
# build directory out of the binary. Without it, two checkouts of one commit
# build two different binaries, and each names the directory it came from.
# Stamping a version with -X changes the bytes, because the version is an
# input. Nothing here prints a path, a hash or a size.
set -u
export GOTOOLCHAIN=local

first=$(mktemp -d)
second=$(mktemp -d)
trap 'rm -rf "$first" "$second"' EXIT

say() { printf '$ %s\n' "$*"; }
same() { if cmp -s "$1" "$2"; then echo "identical: yes"; else echo "identical: no"; fi; }

# One module in two directories: two people who checked out the same commit.
for dir in "$first" "$second"; do
    (cd "$dir" && go mod init example >/dev/null 2>&1) || exit 1
    cat >"$dir/main.go" <<'GO'
package main

import "fmt"

var version = "dev"

func main() {
    fmt.Println("invoice printer", version)
}
GO
done
cd "$first" || exit 1

say 'go build -trimpath -o one . && go build -trimpath -o two . && cmp one two'
go build -trimpath -o one . && go build -trimpath -o two . && same one two

say 'touch main.go && go build -trimpath -o three . && cmp one three'
touch main.go && go build -trimpath -o three . && same one three

say '(cd "$second" && go build -trimpath -o four .) && cmp one "$second/four"'
(cd "$second" && go build -trimpath -o four .) && same one "$second/four"

say 'go build -o plain . && (cd "$second" && go build -o plain .) && cmp plain "$second/plain"'
go build -o plain . && (cd "$second" && go build -o plain .) && same plain "$second/plain"

say 'grep -qF "$first" plain; grep -qF "$first" one'
if grep -qF "$first" plain; then echo "plain names its build directory: yes"; else echo "plain names its build directory: no"; fi
if grep -qF "$first" one; then echo "one names its build directory: yes"; else echo "one names its build directory: no"; fi

say 'GOFLAGS=-trimpath go build -o five . && cmp one five'
GOFLAGS=-trimpath go build -o five . && same one five

say 'go version -m one | grep trimpath'
go version -m one | grep trimpath

say 'go build -trimpath -ldflags "-X main.version=1.4.2" -o six . && cmp one six'
go build -trimpath -ldflags "-X main.version=1.4.2" -o six . && same one six
say './one; ./six'
./one
./six

builds_are_reproducible_goenv_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# go env -w writes a setting into the go command's own configuration file,
# and every later build reads it. GOENV points that file into this script's
# temporary directory, so the user's real configuration is never touched.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOENV="$dir/go.env"

say() { printf '$ %s\n' "$*"; }
same() { if cmp -s "$1" "$2"; then echo "identical: yes"; else echo "identical: no"; fi; }

go mod init example >/dev/null 2>&1 || exit 1
cat >main.go <<'GO'
package main

import "fmt"

func main() {
    fmt.Println("invoice printer")
}
GO
go build -trimpath -o trimmed . || exit 1

say 'go env GOFLAGS'
go env GOFLAGS
say 'go env -w GOFLAGS=-trimpath'
go env -w GOFLAGS=-trimpath
say 'cat "$GOENV"'
cat "$GOENV"
say 'go env GOFLAGS'
go env GOFLAGS
say 'go build -o configured . && cmp trimmed configured'
go build -o configured . && same trimmed configured

say 'go env -u GOFLAGS && go env GOFLAGS'
go env -u GOFLAGS && go env GOFLAGS

say 'go env -w NOTAVAR=1'
go env -w NOTAVAR=1 2>&1
echo "exit status $?"

builds_are_reproducible_vcs_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Inside a git checkout, go build stamps the commit into the binary: the same
# source built in a repository and outside one gives different bytes, and a
# tree with uncommitted changes is recorded as vcs.modified=true. With
# -buildvcs=false the bytes match the build outside git again. The revision
# and the commit time are never printed here, only the keys.
set -u
export GOTOOLCHAIN=local

plain=$(mktemp -d)
repo=$(mktemp -d)
trap 'rm -rf "$plain" "$repo"' EXIT

say() { printf '$ %s\n' "$*"; }
same() { if cmp -s "$1" "$2"; then echo "identical: yes"; else echo "identical: no"; fi; }

for dir in "$plain" "$repo"; do
    (cd "$dir" && go mod init example >/dev/null 2>&1) || exit 1
    cat >"$dir/main.go" <<'GO'
package main

import "fmt"

func main() {
    fmt.Println("invoice printer")
}
GO
done
(cd "$plain" && go build -trimpath -o outside .) || exit 1

cd "$repo" || exit 1
say 'git init && git add . && git commit -m "invoice printer"'
git -c init.defaultBranch=main init -q . && git add . && git -c user.name=ci -c user.email=ci@example.com commit -q -m "invoice printer"

say 'go build -trimpath -o stamped . && cmp "$plain/outside" stamped'
go build -trimpath -o stamped . && same "$plain/outside" stamped

say 'go version -m stamped | grep vcs'
go version -m stamped | awk '$1 == "build" && $2 ~ /^vcs/ { split($2, kv, "="); if (kv[1] == "vcs" || kv[1] == "vcs.modified") print "\t" $1 "\t" $2; else print "\t" $1 "\t" kv[1] "=..." }'

say 'go build -trimpath -buildvcs=false -o unstamped . && cmp "$plain/outside" unstamped'
go build -trimpath -buildvcs=false -o unstamped . && same "$plain/outside" unstamped

say 'echo "// a change that is not committed" >>main.go && go build -trimpath -o dirty . && go version -m dirty | grep vcs.modified'
echo "// a change that is not committed" >>main.go && go build -trimpath -o dirty . && go version -m dirty | grep vcs.modified

What to do

  • Build anything that leaves your machine with -trimpath — as GOFLAGS=-trimpath in the CI environment rather than go env -w on a developer's machine.
  • Check the binary, not the log: go version -m must show -trimpath=true and vcs.modified=false before a release is signed.
  • Prove it once in CI: build the release on two runners, or twice in two directories, and cmp the results; the kata below does it from Go.
  • Pin the toolchain with the go and toolchain lines and GOTOOLCHAIN, and treat every -ldflags -X value as an input that both builders must agree on.

Practice

Prove reproducibility from Go rather than from the shell: write a small module into two temporary directories, build it twice with -trimpath in the first, once with -trimpath in the second, and once without in each, and compare the bytes with bytes.Equal. Print three lines and no path, hash or size.

Solution

builds_are_reproducible_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: prove reproducibility from Go rather than from the shell. The program
// writes a small module into two temporary directories, builds it twice with
// -trimpath in the first, once with -trimpath in the second, and once without
// in each, and compares the bytes. It needs the go command on PATH and prints
// no path, hash or size.
//
//  go build -trimpath builds_are_reproducible_kata_go.go && ./builds_are_reproducible_kata_go
package main

import (
    "bytes"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
)

const source = `package main

import "fmt"

func main() {
    fmt.Println("invoice printer")
}
`

func main() {
    first := module()
    defer os.RemoveAll(first)
    second := module()
    defer os.RemoveAll(second)

    one := build(first, "one", "-trimpath")
    two := build(first, "two", "-trimpath")
    fmt.Println("same directory, -trimpath twice, identical:", bytes.Equal(one, two))

    three := build(second, "three", "-trimpath")
    fmt.Println("other directory, -trimpath, identical:     ", bytes.Equal(one, three))

    plainOne := build(first, "plain")
    plainTwo := build(second, "plain")
    fmt.Println("both directories, no -trimpath, identical: ", bytes.Equal(plainOne, plainTwo))
}

// module writes go.mod and main.go into a fresh temporary directory.
func module() string {
    dir, err := os.MkdirTemp("", "reproducible")
    if err != nil {
        panic(err)
    }
    must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example\n\ngo 1.25\n"), 0o644))
    must(os.WriteFile(filepath.Join(dir, "main.go"), []byte(source), 0o644))
    return dir
}

// build runs go build in dir with the given flags and returns the binary's bytes.
func build(dir, name string, flags ...string) []byte {
    args := append([]string{"build", "-o", name}, flags...)
    cmd := exec.Command("go", append(args, ".")...)
    cmd.Dir = dir
    cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
    if out, err := cmd.CombinedOutput(); err != nil {
        panic(fmt.Sprintf("go build: %v\n%s", err, out))
    }
    data, err := os.ReadFile(filepath.Join(dir, name))
    must(err)
    return data
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

Verified output of builds_are_reproducible_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

same directory, -trimpath twice, identical: true
other directory, -trimpath, identical:      true
both directories, no -trimpath, identical:  false

In other languages

Sources