From b3497407f2695705d23cc97ac584da0fa366ad81 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 4 Oct 2026 16:45:06 +0000 Subject: [PATCH] Fail a bare docker build instead of serving cached gates (closes #39) Each Dockerfile stage that runs gates now checks, right after its ARG CHECK_EPOCH, that the value is not empty, and stops with a message naming script/cibuild and script/docker. A plain `docker build .` can no longer report a green from cached gate layers. script/cibuild and script/docker now append the process id to the epoch, the form script/lint already uses, so two runs started in the same second still get different values. README says both. TODO.md corrects the steady-state CACHED count recorded for issue 32 from twelve to thirteen. Model: opus-5-5 --- Dockerfile | 27 +++++++++++++++++++++------ README.md | 9 ++++++--- TODO.md | 29 +++++++++++++++++------------ script/cibuild | 7 +++++-- script/docker | 4 ++-- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index b911abe..1f2dbd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,9 @@ COPY . . # 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. +# script/docker pass a fresh CHECK_EPOCH on every invocation. A build +# that passes none, such as a bare `docker build .`, fails at the check +# right after the ARG instead of quietly serving the gates from cache. # # Two properties this depends on. ARG is per-stage, so the markdown and # build stages below declare it again; one declaration here would leave @@ -22,6 +24,10 @@ COPY . . # (the pinned base image, go mod download) keeps its cache; only the # gates go cold. ARG CHECK_EPOCH +RUN if [ -z "${CHECK_EPOCH}" ]; then \ + echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ + exit 1; \ + fi # The linter is invoked directly here, not through `make lint`. That # target now runs `docker build -f Dockerfile.lint`, and a docker build @@ -71,9 +77,13 @@ WORKDIR /src # Markdown stage: the Markdown half of `make fmt-check`, as a gate. FROM prettier AS markdown COPY . . -# Second per-stage declaration of the gate cache-buster; see the lint -# stage above. +# Second per-stage declaration of the gate cache-buster and its check; +# see the lint stage above. ARG CHECK_EPOCH +RUN if [ -z "${CHECK_EPOCH}" ]; then \ + echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ + exit 1; \ + fi RUN echo "gate prettier, epoch ${CHECK_EPOCH}" && \ prettier --check '**/*.md' --tab-width 4 --prose-wrap always @@ -153,10 +163,15 @@ USER builder # prerequisites. `make`, not the script directly, because the Makefile's # `export CGO_ENABLED = 0` applies only to what it invokes. # -# Third 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. +# Third per-stage declaration of the gate cache-buster and its check; +# 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 if [ -z "${CHECK_EPOCH}" ]; then \ + echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \ + exit 1; \ + fi RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test # The version stamped into the binary: the VERSION build argument when diff --git a/README.md b/README.md index 8e6c030..b4078a2 100644 --- a/README.md +++ b/README.md @@ -821,9 +821,12 @@ from binary-versus-pin to pin-versus-pin, which is what gate layers from cache and the build exits 0 having executed no tests and no lint — a green it never earned, and one this repository has produced twice. `CHECK_EPOCH` invalidates the gate layers on every run while leaving the pinned -base images and the dependency layers cached. `script/lint`'s value carries the -process id as well as the epoch, because two lint runs land inside the same -second easily and a bare epoch would cache the second one. +base images and the dependency layers cached. Each script's value carries the +process id as well as the epoch, because two runs land inside the same second +easily and a bare epoch would cache the second one. Each `Dockerfile` stage with +gates fails when the value is empty, so a bare `docker build .` stops with +`CHECK_EPOCH is unset; build via script/cibuild or script/docker` instead of +serving the gates from cache. ## Build diff --git a/TODO.md b/TODO.md index 5e65e74..a962fe4 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,10 @@ # Completed Steps +- a bare `docker build .` fails with a message naming `script/cibuild` and + `script/docker` instead of serving the gates from cache (2026-10-04, + https://git.eeqj.de/sneak/sfdupes/issues/39) + - `make fmt` and `make fmt-check` run prettier over all Markdown, in Docker, and CI checks it; all Markdown reformatted (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/19) @@ -298,18 +302,19 @@ 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 + 61.1s; `script/docker` 61.1s then 53.4s), and thirteen steps were still served + `CACHED` in the steady state — the lint stage's `WORKDIR /src`, 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 any linter already on `PATH` satisfied the diff --git a/script/cibuild b/script/cibuild index 7283a88..f9a12b5 100755 --- a/script/cibuild +++ b/script/cibuild @@ -21,14 +21,17 @@ # 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. +# go mod download cached; see the Dockerfile for the placement. The +# process id goes in with the epoch so that two runs started in the +# same second still get different values, the same form script/lint +# uses. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build --build-arg CHECK_EPOCH="$(date +%s)" . + docker build --build-arg CHECK_EPOCH="$(date +%s)-$$" . } main "$@" diff --git a/script/docker b/script/docker index 3c7f7bc..3245f26 100755 --- a/script/docker +++ b/script/docker @@ -3,7 +3,7 @@ # 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 +# without a fresh value Docker serves the gate layers from cache on an # unchanged tree and this exits 0 having run none of the lint stage's # gates, the markdown stage's prettier gate or the builder stage's test # gate. This is the set of gates a developer or reviewer runs by hand, @@ -17,7 +17,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" docker build \ - --build-arg CHECK_EPOCH="$(date +%s)" \ + --build-arg CHECK_EPOCH="$(date +%s)-$$" \ -t "$("$SCRIPT_DIR/projectname")" \ . } -- 2.54.0