diff --git a/Dockerfile b/Dockerfile index 8779e06..8580a63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,25 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN make fmt-check -RUN make lint + +# Cache-buster for the gate layers, and only for them. Docker +# invalidates COPY only when the copied content changes, so on an +# unchanged tree the gates below would be served from cache and the +# build would exit 0 having run nothing. script/cibuild and +# script/docker pass a fresh CHECK_EPOCH on every invocation. +# +# Two properties this depends on. ARG is per-stage, so the build stage +# below declares it again; one declaration here would leave that +# stage's gate cacheable. And each gate RUN must reference the value, +# because BuildKit hashes the expanded command: a declared but +# unreferenced ARG invalidates nothing. +# +# It sits below the dependency layers deliberately. Everything above it +# (the pinned base image, go mod download) keeps its cache; only the +# gates go cold. +ARG CHECK_EPOCH +RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check +RUN echo "gate lint, epoch ${CHECK_EPOCH}" && make lint # Build stage # golang:1.25-alpine, 2026-07-23 @@ -40,7 +57,12 @@ USER builder # Fail the build unless the branch is green. Runs as non-root so the # permission-denied test paths are exercised legitimately (root would # bypass the chmod(0) the tests rely on). -RUN make check +# +# Second per-stage declaration of the gate cache-buster; see the lint +# stage above for why one is not enough. It is placed after USER so the +# drop to the unprivileged user still happens before the checks run. +ARG CHECK_EPOCH +RUN echo "gate check, epoch ${CHECK_EPOCH}" && make check RUN make build diff --git a/TODO.md b/TODO.md index c23b4f4..795ad2a 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,47 @@ # Completed Steps +- 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 + were bare `docker build` invocations with no cache control, and the + `Dockerfile` copies the tree before running its gates, so on an + unchanged tree Docker served those layers from cache and the build + exited 0 having executed nothing. That is not hypothetical here — + every merge this repo has done is a non-fast-forward merge of an + undiverged branch, so each merge commit's tree is byte-identical to + the branch head's and each merge CI run was almost certainly a full + cache hit; and PR #31's reviewer found `make docker` returning + success as a 17-layer cache hit, catching it only by being + suspicious. The fix is `ARG CHECK_EPOCH` with the scripts passing + `--build-arg CHECK_EPOCH="$(date +%s)"`. Two details make or break + it. `ARG` is scoped per stage and this `Dockerfile` has three gates + across two — `make fmt-check` and `make lint` in the lint stage, + `make check` in the build stage — so a single declaration would have + left one stage silently cacheable; it is declared in both. And + BuildKit hashes the expanded command, not the declaration, so a + declared-but-unreferenced `ARG` invalidates nothing: each gate `RUN` + echoes the epoch, which also puts the value in the build log as + evidence the layer really ran. Placement is below the dependency + layers on purpose — a build that goes cold every time would be a + different bug, not a fix. Verified by running each script twice back + to back on an unchanged tree under `BUILDKIT_PROGRESS=plain`: all + three gates executed on all four runs, each with a fresh epoch in + the log (`script/cibuild` 78.8s then 61.1s; `script/docker` 61.1s + then 53.4s), and twelve steps were still served `CACHED` in the + steady state — both `go mod download`s, `apk add`, `adduser`, the + `chown`, every `go.mod`/`go.sum` and source copy, the linter copy + out of the lint stage, and the binary copy into the runtime stage. + The lint stage still gates the build stage: with a deliberate + `unused` finding planted in the tree, the build failed at + `make lint` in 36.1s and the build-stage `make check` never started. + The build stage also still drops to the unprivileged `builder` user + before `make check`, which the suite depends on rather than merely + prefers: forcing the same image to run the tests as root fails + `TestScanHardlinkRunFailsTogether`, because root reads straight + through the `chmod(0)` the test uses to prove hard links are read + once. This is the local fix only; propagating it to the canonical + templates is `prompts` #26 - check the installed golangci-lint version in `script/bootstrap` instead of only its presence (2026-08-09, branch `bootstrap-version-check`, closes #24): `missing golangci-lint` meant diff --git a/script/cibuild b/script/cibuild index 7d63316..e71140a 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,14 +1,23 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs make check (via -# script/check), so a successful build implies all checks pass. The -# Gitea workflow runs this on push. +# script/cibuild: run the CI build. The Dockerfile runs make fmt-check, +# make lint and make check (via the script/ entrypoints) as build steps, +# so a successful build implies all checks pass. The Gitea workflow runs +# this on push. +# +# That implication holds only because of CHECK_EPOCH. A COPY layer is +# invalidated by changed content, and a merge commit's tree is +# byte-identical to the branch head it merges, so without a fresh value +# here Docker serves the gate layers from cache and the build reports a +# green it never earned. Passing the current epoch invalidates the gate +# layers on every run while leaving the pinned base images and +# go mod download cached; see the Dockerfile for the placement. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build . + docker build --build-arg CHECK_EPOCH="$(date +%s)" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..b5de765 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,14 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# Identical in all repos; the tag comes from script/projectname. +# The tag comes from script/projectname. +# +# CHECK_EPOCH is passed for the same reason script/cibuild passes it: +# without it Docker serves the Dockerfile's gate layers from cache on an +# unchanged tree and this exits 0 having run neither the lint stage nor +# the builder stage's make check. This is the gate a developer or +# reviewer runs by hand, so a cached pass here is the most misleading +# result the repo can produce. Dependency layers sit above the ARG and +# stay cached. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +16,10 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + docker build \ + --build-arg CHECK_EPOCH="$(date +%s)" \ + -t "$("$SCRIPT_DIR/projectname")" \ + . } main "$@"