Run script/bootstrap in the Docker build stage (closes #42)
All checks were successful
check / check (push) Successful in 1m16s

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.
This commit is contained in:
2026-08-09 14:43:04 +00:00
parent 47fd4e8def
commit 3a183aa64b
2 changed files with 71 additions and 6 deletions

View File

@@ -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

41
TODO.md
View File

@@ -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