1 Commits

Author SHA1 Message Date
clawbot
51c394552e Bust the Docker check-layer cache with a per-invocation CHECK_EPOCH (closes #26)
All checks were successful
check / check (push) Successful in 7s
script/cibuild was a plain `docker build .`, and the Dockerfile does
`COPY . .` followed by `RUN make check`. Docker invalidates a COPY layer
only when the copied content changes, so on an unchanged tree the check
layer was served from cache, the suite never ran, and the build still
exited 0. Measured here: run 1 took 18.5s and ran the suite; run 2 on a
byte-identical tree took 0.286s with `RUN make check` CACHED.

script/cibuild and script/docker now assign a per-invocation nonce on its
own line and pass it as --build-arg CHECK_EPOCH. The Dockerfile declares
ARG CHECK_EPOCH, guards it with `[ -n "$CHECK_EPOCH" ] || exit 1`, and
expands it into the check command. Post-fix, two consecutive runs both
execute make check (17.4s / 8.1s) with `RUN script/bootstrap` still
CACHED, so dependency layers are untouched and the build ceiling is not
at risk.

The guard is what makes a bare `docker build .` — the command
REPO_POLICIES named verbatim — fail closed rather than reuse the empty
and therefore stable cache key; verified failing in 0.455s. Holding the
epoch constant restores the false green (run 2 fully CACHED), which pins
the varying value as the operative mechanism rather than a coincidence.

Because the guard references $CHECK_EPOCH it is itself value-keyed:
BuildKit renders the epoch into that layer's description and re-runs the
layer when the value changes. Each stage therefore has two independent
invalidation points, the guard and the expansion, and the guard always
precedes the check RUN. Both are kept and the prose now records this;
the expansion remains defence in depth and is what puts the epoch in the
build log.

The false guarantee was org-canonical text in more than one document, so
it is corrected everywhere it appeared rather than only where the issue
first found it. REPO_POLICIES.md carried it in two places, and its Go
multistage template had check steps in two stages; ARG is stage-scoped,
so both stages get the treatment or the fleet inherits the half-fixed
shape. CODE_STYLEGUIDE_GO.md restated the guarantee for the bare command
this change makes fail closed. NEW_REPO_CHECKLIST.md specified the
pre-fix script/cibuild verbatim, so every new repo would have been born
with the false green, and EXISTING_REPO_CHECKLIST.md ended on a
`docker build` acceptance item that the guard makes unsatisfiable by
design — an agent working that checklist would have been led to delete
the guard to tick the last box. Both checklists' Dockerfile criteria were
also satisfiable by a Dockerfile whose check layers are still frozen, and
now require the ARG and guard in every check-running stage.

REPO_POLICIES.md's own Dockerfile criterion carried that same incomplete
form; it is tightened by cross-reference to the CHECK_EPOCH rule rather
than by duplicating the canonical block. The Go template's Key points
gain a caveat that the cache-bust turns the `COPY --from=lint` no-op into
a content-cache hit, so a repo using a file-dependency trick for stage
ordering must re-prove that ordering on a warm cache after adopting it.
That was re-proved in another repo in the org which uses the trick with a
marker file, where the ordering held; the caveat states explicitly that
it was not verified here, this repo being single-stage with no lint stage
to order against.
2026-08-09 15:15:03 +00:00

View File

@@ -92,14 +92,18 @@ style conventions are in separate documents:
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green — which requires
repos, the Dockerfile should bring up a development environment and run `ARG CHECK_EPOCH` and its guard in every stage containing a check-running
`make check`. For server repos, `make check` should run as an early build `RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies
stage before the final image is assembled. Dockerfiles install development this criterion while its check layers are served from cache, so the build
prerequisites by running `script/bootstrap` rather than duplicating installs cannot fail on a branch that is not green. For non-server repos, the
inline; COPY `script/` and the dependency manifests (`package.json` + Dockerfile should bring up a development environment and run `make check`. For
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap server repos, `make check` should run as an early build stage before the final
layer stays cached until dependencies change. image is assembled. Dockerfiles install development prerequisites by running
`script/bootstrap` rather than duplicating installs inline; COPY `script/` and
the dependency manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`,
etc.) before running it so the bootstrap layer stays cached until dependencies
change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker - **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an invalidates a `COPY` layer only when the copied content changes, so on an
@@ -130,8 +134,8 @@ style conventions are in separate documents:
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (observed as epoch into that layer's description (rendered as
`RUN [ -n "1786287053..." ] || exit 1`) and re-runs it whenever the value `RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build defence in depth, and it is what makes the epoch visible in the build
@@ -212,6 +216,16 @@ style conventions are in separate documents:
a stage dependency. BuildKit runs stages in parallel by default; without a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build. failure might not fail the overall build.
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.**
The cache-bust turns this no-op `COPY` into a content-cache hit, so an
ordering guarantee established on a cold cache does not automatically
carry over; it has to be re-checked warm. This was re-proved in another
repo in the org that uses the same file-dependency trick (there with a
marker file in place of `go.sum`), and the ordering held. It has **not**
been verified in this repo, which is single-stage and has no lint stage to
order against. Any repo relying on a file-dependency trick for stage
ordering should re-check it warm after adopting the bust rather than
assuming this result transfers.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must (e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example: