From 3a183aa64bd2165642091ed1721570711264f94f Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 14:43:04 +0000 Subject: [PATCH] Run script/bootstrap in the Docker build stage (closes #42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical REPO_POLICIES.md:97 requires Dockerfiles to install development prerequisites by running script/bootstrap rather than duplicating installs inline. The build stage did the opposite: an inline `apk add --no-cache make` and its own `go mod download`, so it maintained a second, independent notion of the toolchain — the local-versus-CI divergence #24 exists to close, reintroduced one layer down. The stage now copies script/ plus go.mod/go.sum and runs script/bootstrap, which ends in `go mod download`. COPY --from=lint /usr/bin/golangci-lint is kept and moved above the bootstrap layer. It is the only edge making this stage depend on the lint stage, so removing it as redundant would silently stop the build gating on lint. Copying it first also puts it on PATH before bootstrap runs, so bootstrap's version check compares the lint stage's linter against the pin on every build: the two stages are now provably one toolchain rather than two that happen to agree, and bootstrap does not pay for a from-source build of its own linter. $GOPATH/bin joins PATH so that if the copied binary ever stops matching the pin, bootstrap's reinstall lands somewhere PATH resolves instead of failing its own verification. All of it sits above ARG CHECK_EPOCH, and the chown and USER builder still precede make check. --- Dockerfile | 36 ++++++++++++++++++++++++++++++------ TODO.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8580a63..554c32d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,24 +29,48 @@ RUN echo "gate lint, epoch ${CHECK_EPOCH}" && make lint # golang:1.25-alpine, 2026-07-23 FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder -RUN apk add --no-cache make - # We never build or run as root. Create an unprivileged user and point # HOME and the Go caches at its home so go build/test and golangci-lint -# can write their caches when we drop to it below. +# can write their caches when we drop to it below. $GOPATH/bin is on +# PATH because that is where script/bootstrap's `go install` lands: if +# the linter copied in below ever stops matching bootstrap's pin, +# bootstrap reinstalls it and then verifies the pin against what PATH +# resolves, which can only succeed if that directory is searched. RUN adduser -D -u 1000 builder ENV HOME=/home/builder ENV GOPATH=/home/builder/go ENV GOCACHE=/home/builder/.cache/go-build +ENV PATH=/home/builder/go/bin:$PATH WORKDIR /src -# Reuse the linter binary from the lint stage; the copy also forces -# BuildKit to complete linting before this stage proceeds. +# Reuse the linter binary from the lint stage. This copy is load-bearing +# twice over and must not be deleted as redundant now that bootstrap +# below can install a linter of its own: +# +# - It is the only thing making this stage depend on the lint stage, +# so it is what forces BuildKit to finish fmt-check and lint before +# compilation and tests start. Remove it and the fail-fast design +# dies silently: the build stops gating on lint and still exits 0. +# - It is what keeps the two stages on one toolchain. script/bootstrap +# version-checks whatever PATH resolves against its pin, so copying +# the lint stage's binary in first means every build now compares +# the lint stage's linter to that pin and fails loudly if they ever +# drift apart. Bootstrap installing its own linter here instead +# would restore exactly the two-independent-toolchains problem the +# copy prevents (and cost a from-source build of the linter). COPY --from=lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint +# Install development prerequisites the same way a developer does, +# rather than duplicating the installs inline. script/ and the +# dependency manifests are copied first, and nothing else is, so this +# layer stays cached until the scripts or the dependencies change — +# bootstrap ends in `go mod download`, which is why there is no separate +# invocation of it here. +COPY script/ script/ COPY go.mod go.sum ./ -RUN go mod download +RUN script/bootstrap + COPY . . # Hand the sources and caches to the unprivileged user, then drop root diff --git a/TODO.md b/TODO.md index 795ad2a..497a71f 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,47 @@ # Completed Steps +- install the Docker build stage's prerequisites by running + `script/bootstrap` instead of `apk add --no-cache make` inline + (2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical + `REPO_POLICIES.md:97` requires it, and the inline install left the + build stage maintaining its own notion of the toolchain — exactly + the divergence #24 exists to close, one layer down. The stage now + copies `script/` plus `go.mod`/`go.sum` and runs `script/bootstrap`, + which ends in `go mod download`, so the separate invocation of that + is gone. `COPY --from=lint /usr/bin/golangci-lint` stays, and moves + above the bootstrap layer. It is the only edge making this stage + depend on the lint stage, so deleting it as redundant would end + fail-fast linting silently; putting it first also means bootstrap's + version check now compares the lint stage's linter against the pin + on every build, which is what makes the two stages provably one + toolchain instead of two that happen to agree. Letting bootstrap + install its own linter here would have reintroduced the second + toolchain and paid for a from-source build of it. `$GOPATH/bin` + joins `PATH` so that if the copied binary ever stops matching the + pin, bootstrap's reinstall lands somewhere `PATH` resolves rather + than failing its own verification. Everything added sits above + `ARG CHECK_EPOCH`, and the `chown` and `USER builder` still precede + `make check`. Verified: bootstrap runs clean under Alpine's `sh` and + its `apk` branch, installing `git` and `make` and finding the copied + linter already at the pin; a second build served the bootstrap and + dependency layers `CACHED` while both gates ran with a fresh epoch; + a planted `unused` finding failed the build at the lint gate in + 48.9s with the build stage's `make check` never starting; and the + suite run in the image as `--user 0:0` fails + `TestScanHardlinkRunFailsTogether`, so the drop to the unprivileged + user is still load-bearing. That last check needs the Go test cache + disabled — the first attempt reported `ok ... (cached)` as root, + reusing the result the build-time run had left in the shared cache, + which would have read as a pass. Build wall time, on a shared host + running many concurrent builds and so noisy: 2m13s on an unchanged + tree, 2m17s and 4m29s for two builds after a source change, 5m14s + cold. Only the cold one breaches the policy ceiling, and not because + of this change — `chown -R builder:builder /src /home/builder` walks + the module cache and re-runs on every source change, and it alone + varied between 77s and 210s across those four builds, which is also + the whole spread in the totals. The same cold measurement against + `main` is 5m03s with a 209s `chown`. Filed as #43 - bust the Docker layer cache for the gate steps, so `script/cibuild` and `script/docker` cannot report a green they did not earn (2026-08-09, branch `cibuild-cache-bust`, closes #32): both scripts