From bcdb1ad4f8979401661974ede5c930ab4ef2e45d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 06:29:44 +0200 Subject: [PATCH 1/2] Stamp no commit into a Go bundle, so one build source is one digest (hq issue 363, ADR 0267 rule 6) A module built from its repository's root is compiled with the clone's .git in view, and Go stamped the commit into the binary: a merge that changed only a README made a new digest of an unchanged program. --- internal/builder/reproducible_test.go | 108 ++++++++++++++++++++++++++ internal/builder/toolchain.go | 7 +- 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 internal/builder/reproducible_test.go diff --git a/internal/builder/reproducible_test.go b/internal/builder/reproducible_test.go new file mode 100644 index 00000000..e88851f4 --- /dev/null +++ b/internal/builder/reproducible_test.go @@ -0,0 +1,108 @@ +package builder + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// **A Go bundle does not vary with the commit it was built at** (novox/hq ADR 0267 rule 6, issue 363). A +// module built from its repository's root — the controller — is compiled with the clone's `.git` in what +// the toolchain sees, and Go stamps the commit, its time and whether the tree was modified into every +// binary built there unless told not to. Two builds of one program at commits that differ only in a +// README then made two binaries, so an unchanged build source was a new digest. +// +// The toolchain's own compile line is run here, as the build seat runs it inside the toolchain image: in +// two clones at two paths, of two commits that differ outside the program. One digest is wanted. +func TestAGoBundleBuiltTwiceFromOneBuildSourceIsOneDigest(t *testing.T) { + for _, tool := range []string{"go", "git"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s is not on this machine: %v", tool, err) + } + } + chain, err := ToolchainFor("go") + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + repo := filepath.Join(root, "origin") + files := map[string]string{ + "go.mod": "module example.com/ctl\n\ngo 1.22\n", + "cmd/ctl/main.go": "package main\n\nfunc main() { println(\"ctl\") }\n", + "README.md": "one\n", + } + for name, body := range files { + if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + git := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@example.invalid", + "-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main"}, args...)...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) + } + git(repo, "init", "-q") + git(repo, "add", ".") + git(repo, "commit", "-q", "-m", "one") + first := git(repo, "rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + git(repo, "commit", "-q", "-am", "only the README") + second := git(repo, "rev-parse", "HEAD") + + build := func(commit, at string) string { + t.Helper() + clone := filepath.Join(root, at) + git(root, "clone", "-q", repo, clone) + git(clone, "checkout", "-q", commit) + out := filepath.Join(clone, Out("server"), "ctl") + line := append([]string(nil), chain.Compile...) + line = append(line, "-ldflags", strings.Join(chain.LinkerFlags, " "), chain.OutputFlag, out, "./cmd/ctl") + cmd := exec.Command(line[0], line[1:]...) + cmd.Dir = clone + cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", strings.Join(line, " "), err, b) + } + body, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(body, []byte(commit)) { + t.Errorf("the binary built at %s carries the commit it was built at", commit[:12]) + } + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) + } + a, b := build(first, "a"), build(second, "elsewhere/b") + if a != b { + t.Fatalf("one build source, two digests: %s at %s, %s at %s (compiled with %q)", + a[:12], first[:12], b[:12], second[:12], strings.Join(chain.Compile, " ")) + } +} + +// The toolchain says so itself, so a reader of the compile line sees it without running a build. +func TestTheGoToolchainStampsNoVersionControl(t *testing.T) { + chain, err := ToolchainFor("go") + if err != nil { + t.Fatal(err) + } + if joined := strings.Join(chain.Compile, " "); !strings.Contains(joined, "-buildvcs=false") { + t.Fatalf("the go toolchain stamps the commit into what it compiles: %q", joined) + } +} diff --git a/internal/builder/toolchain.go b/internal/builder/toolchain.go index db2c4c1c..f5a304da 100644 --- a/internal/builder/toolchain.go +++ b/internal/builder/toolchain.go @@ -167,8 +167,13 @@ var toolchains = []Toolchain{ // binary that needs a libc it did not bring is a delivery that works until a machine // differs. Trimmed of its own paths for the same reason a version comes from where it sits // rather than from the linker: two builds of one commit produce the same bytes. + // + // **And of one build source at two commits** (novox/hq ADR 0267 rule 6, issue 363): a module built + // from its repository's root is compiled with the clone's `.git` in view, and Go stamps the commit, + // its time and whether the tree was modified into the binary unless told not to — so a merge that + // changed only a README made a new digest of an unchanged program. Compile: []string{ - "env", "CGO_ENABLED=0", "GOFLAGS=-trimpath", + "env", "CGO_ENABLED=0", "GOFLAGS=-trimpath -buildvcs=false", "go", "build", }, // Stripped of symbols and debug info: what a machine holds is a file it runs, not one it From ef3e5ae9b0dc9abc68ddb9ecbe72ef163d2a59f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 06:47:18 +0200 Subject: [PATCH 2/2] Stamp no commit in this repository's own image recipes either (hq issue 363, review of #292) The genesis image copies the whole clone with .git, so its controller carried the commit it was built at, as the bundles did. --- Dockerfile | 4 ++- cmd/mesh-builder/Dockerfile | 2 +- examples/objectstore-provisioner/Dockerfile | 2 +- examples/postgres-provisioner/Dockerfile | 2 +- examples/redis-provisioner/Dockerfile | 2 +- examples/route-proxy/Dockerfile | 2 +- internal/builder/reproducible_test.go | 37 +++++++++++++++++++++ 7 files changed, 45 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0f2d07b..2d6fb855 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,11 @@ WORKDIR /src COPY go.mod go.sum ./ COPY vendor/ vendor/ +# `COPY . .` brings `.git` along, so Go is told not to stamp the commit: the version is said by -X, and a +# rebuild of an unchanged program is the same bytes (novox/hq ADR 0267 rule 6, issue 363). COPY . . ARG VERSION=development -RUN CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPROXY=off go build -trimpath \ +RUN CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPROXY=off go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ -o /mesh-controller ./cmd/mesh-controller diff --git a/cmd/mesh-builder/Dockerfile b/cmd/mesh-builder/Dockerfile index 3951536d..b639b869 100644 --- a/cmd/mesh-builder/Dockerfile +++ b/cmd/mesh-builder/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder FROM ${ALPINE_BASE} # git to clone what it is asked to build, and the docker client to build and push it. The daemon diff --git a/examples/objectstore-provisioner/Dockerfile b/examples/objectstore-provisioner/Dockerfile index 136a5f1e..d8b2a797 100644 --- a/examples/objectstore-provisioner/Dockerfile +++ b/examples/objectstore-provisioner/Dockerfile @@ -18,7 +18,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \ +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \ -o /mesh-provision-objectstore ./examples/objectstore-provisioner # Pinned like everything else the mesh runs (novox/hq ADR 0006): a tag moves and a digest does not. diff --git a/examples/postgres-provisioner/Dockerfile b/examples/postgres-provisioner/Dockerfile index 585cf08b..1da90266 100644 --- a/examples/postgres-provisioner/Dockerfile +++ b/examples/postgres-provisioner/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \ +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \ -o /mesh-provision-postgres ./examples/postgres-provisioner FROM scratch diff --git a/examples/redis-provisioner/Dockerfile b/examples/redis-provisioner/Dockerfile index ade39a7d..edb26134 100644 --- a/examples/redis-provisioner/Dockerfile +++ b/examples/redis-provisioner/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \ +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \ -o /mesh-provision-redis ./examples/redis-provisioner FROM scratch diff --git a/examples/route-proxy/Dockerfile b/examples/route-proxy/Dockerfile index 1c036271..03958031 100644 --- a/examples/route-proxy/Dockerfile +++ b/examples/route-proxy/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy FROM scratch COPY --from=build /mesh-route-proxy /mesh-route-proxy diff --git a/internal/builder/reproducible_test.go b/internal/builder/reproducible_test.go index e88851f4..345ce055 100644 --- a/internal/builder/reproducible_test.go +++ b/internal/builder/reproducible_test.go @@ -106,3 +106,40 @@ func TestTheGoToolchainStampsNoVersionControl(t *testing.T) { t.Fatalf("the go toolchain stamps the commit into what it compiles: %q", joined) } } + +// **And so does every recipe in this repository that compiles Go** (review of issue 363's fix): the +// controller's genesis image copies the whole clone, `.git` with it, and the reference images beside it +// are built the same way by the Makefile. +func TestEveryGoRecipeInThisRepositoryStampsNoVersionControl(t *testing.T) { + root := filepath.Join("..", "..") + var recipes []string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && (d.Name() == "vendor" || d.Name() == "testdata" || d.Name() == ".git") { + return filepath.SkipDir + } + if !d.IsDir() && d.Name() == "Dockerfile" { + recipes = append(recipes, path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(recipes) == 0 { + t.Fatal("no recipe found in this repository") + } + for _, path := range recipes { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(string(body), "\n") { + if strings.Contains(line, "go build") && !strings.Contains(line, "-buildvcs=false") { + t.Errorf("%s compiles Go and stamps the commit into it: %s", path, strings.TrimSpace(line)) + } + } + } +}