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