script/cibuild can report a green it did not earn: make check is served from Docker cache #23
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
script/cibuildisdocker build .with no cache control. TheDockerfileends with
COPY . .thenRUN make check. On an unchanged tree, Docker servesthe
RUN make checklayer from cache: the checks never execute and the buildstill exits 0.
Reproduced in this repo just now. Second consecutive run against an unchanged
tree:
No prettier output, no Hugo build output, no
Total in ...line — becausenothing ran. The first run of the pair (cold cache) took real time and produced
real output. Both exit 0 and both look identical to anything that only checks
the exit code.
This is not theoretical here. During the review of PR #17 a reviewer reported
that its
script/cibuildrun was fullyCACHED, and had to re-run it afterdocker builder prune -afto get a real result. A gate that returns successwithout running is worse than no gate, because it is trusted.
Reported fleet-wide; the same hole exists in the shared template, so this is
not a lora.vegas-specific defect. Tracked upstream in the
promptsrepo as itsissue #26.
Why this matters more than it looks
REPO_POLICIES.mdrequiresmainto always passmake checkand requires theDockerfileto runmake checkso "the build fails if the branch is notgreen". A cached layer satisfies neither while appearing to satisfy both.
It also interacts badly with how this repo just spent an outage: PR #17 shipped
a production break with a green
script/cibuild. The cache hole was not thecause there —
docker buildstructurally cannot exercise the Actions runtime —but it is the same category of defect. A green that proves nothing.
Fix
The upstream-proposed fix is a cache-busting build argument, e.g.:
with
script/cibuildpassing a changing value so the check layer is neverreused. Confirm the exact shape against the upstream
promptsissue #26 fixrather than inventing a local variant — this should stay identical across
repos, since
script/cibuildis one of the scripts that is meant to bebyte-identical everywhere.
Whatever shape is adopted,
--no-cacheon the whole build is the wrong answer:it would also discard the
script/bootstraplayer, turning a ~10-second checkinto a full toolchain reinstall every time and blowing the "Docker builds must
complete in under 5 minutes" budget for no benefit. Only the check layer needs
to be unconditional.
Definition of done
script/cibuildcannot servemake checkfrom cache. Demonstrate byrunning it twice in a row against an unchanged tree and showing the check
output present in both runs — paste both, not just the second.
script/bootstraplayer is still cached between runs (verify the secondrun does not reinstall hugo/node), so build time stays reasonable.
script/cibuildstill exits non-zero when a check genuinely fails. Prove it:deliberately break formatting in a scratch commit, confirm the build fails,
then revert.
promptsresolution soscript/cibuildstaysconsistent across repos. If it must diverge, say why in the PR.
make checkpasses andscript/cibuildsucceeds.TODO.mdupdated in the same commit.Depends on
Upstream
prompts#26 settling the canonical fix. If that is still open whenthis is picked up, either wait or implement the canonical shape and note that
it may need syncing. Do not invent a lora.vegas-only mechanism.
Out of scope
make checkruns. Thisissue is solely about the gate actually executing.
docker buildcannot exercise the Gitea Actionsruntime at all. That is inherent, was the subject of #7, and no cache fix
addresses it.
Ordering dependency: land this issue before the
.dockerignorechanges in#8, and re-verify this issue's behaviour afterwards.
A fleet-wide warning came in saying that repos with no
.dockerignoreareaccidentally protected from the cache hole:
.gitlands in the build context,.gitchurns on nearly every git operation, soCOPY . .is invalidatedconstantly and the check layers are forced to re-run. Adopting the canonical
.dockerignoreexcludes.git, removes the churn, and converts an accidentallysafe repo into one that reliably reports unearned greens.
That specific mechanism does not apply to lora.vegas. Verified just now —
.dockerignorehere already contains.git:Which is exactly why the cache hole was reproducible in the first place. This
repo has never had the accidental protection, so there is nothing here to lose.
But the ordering conclusion still holds, by a different route. #8 adds
.claude/to.dockerignore..claude/currently holdsworktrees/, whichis created and destroyed constantly by tooling — so it is, right now, a live
source of build-context churn that intermittently invalidates
COPY . .andforces the check layers to re-run. It is the same accidental protection, just
via a different directory.
So excluding
.claude/will make caching stickier and more consistent,which is desirable on its own terms but removes a source of accidental
re-execution. If #8 lands first, the gate gets quietly weaker and more reliably
so, with nothing in the output to signal it.
Required order:
that the check layer executes in both, and that the
script/bootstraplayer still caches.
.dockerignore/.claudeexclusion from #8..claude/changes the exact cache behaviour validated in step 1. A fix verified before
the context changed is not evidence about the context after.
Step 3 is the one most likely to be skipped, so it is called out as its own
item rather than as a parenthetical. Note also that this is the same class of
mistake that caused the #7 outage: validating against one environment and
assuming the result transfers to a changed one.
Recorded upstream on
prompts#27.Reopened
This issue was in
closedstate when picked up, but nothing onmain(961ec71)implements it: the
Dockerfilestill ends with a bareCOPY . ./RUN make checkand
script/cibuildis still a baredocker build .. No merged PR references #23 —the close timestamp coincides to within two seconds with the merge of PR #24
(
closes #9), so this looks like a mis-close rather than a decision. Reopened; itwill close via the PR.
Implementation plan
Upstream
prompts#26 is still open, but its canonical shape has settled acrossfifteen comments and four independent empirical confirmations (cattbox, rfscan, and
two separate dnswatcher probes). I am adopting that settled shape verbatim rather
than the
ARG CHECK_EPOCH=0sketch in this issue's body, which is superseded — adefault value is a stable cache key, which is precisely the defect.
DockerfileImmediately above the check step, replacing
RUN make check:Three deliberate choices, each traceable to an upstream finding:
ARGplaced afterCOPY . ., not before. Everything at or below theARGis invalidated; everything above it — including
COPY script/ script/andRUN script/bootstrap— keeps caching. Placing it higher would destroydependency caching and turn a ~10s check into a full toolchain reinstall.
ARG CHECK_EPOCH=0would make0the value on everyinvocation, i.e. a constant, i.e. cached — the bug unchanged.
RUNcommand. The bare unreferenced-ARGform does work here (four independent measurements upstream agree, and the one
contradicting claim was retracted), so expansion is hardening, not a fix: it makes
the cache miss contractual instead of dependent on BuildKit's handling of an
unreferenced
ARG, and it prints the epoch into the build log so a reader can seethe layer was keyed fresh.
[ -n ... ]guard. An unsetARGis the empty string, and empty is astable cache key — so without the guard a bare
docker build .(the commandREPO_POLICIES.mdnames verbatim) would still produce the false green. Failedsteps are never cached, so this fails on every such invocation rather than once:
the documented-but-unscripted path becomes a loud error instead of a quiet lie.
script/cibuild--build-arg CHECK_EPOCH="$(date +%s%N)". A command substitution that failsinside an argument does not trip
set -e(confirmed upstream indash), sothe inline form would silently degrade to an empty constant and restore the false
green. As a standalone assignment,
set -ecatches it.%Nrather than%s: this host runs ~18 concurrent sessions, and secondgranularity lets two concurrent invocations collide on an identical key.
$$is not decoration. busyboxdatesilently drops%Nandexits 0 — verified upstream inside a pinned alpine image — so on a busybox host
the epoch would degrade to seconds with no warning. The PID differs between
concurrent invocations regardless, making the guarantee unconditional. Both
%Nand
$$are POSIX-safe in the sense that matters:%Ndegrades silently and$$covers the degradation.
script/dockerGets the same
--build-arg. This is a required consequence of the guard, not scopecreep: without it the guard would break
make dockeroutright. Upstream independentlyrecommends it for its own reasons — local builds are almost always warm and nobody
watches
make dockerfor a suspicious duration — and leaving the two entrypointsdivergent would have them silently disagree about whether the tree is green.
Not doing
make checkruns (explicitly out of scope)..dockerignore— #8 lands after this, per the ordering recorded above.script/cibuildgrep its own outputfor
CACHEDon the check layer. That did not make it into the settled canonicalform, and inventing it here would be exactly the local variant this issue forbids.
It belongs upstream if it belongs anywhere.
Verification I will run and paste
script/cibuildruns on an unchanged tree, both pasted, withper-run
CACHEDstep counts. Acceptance is that both show real check output (bothHugo builds and the prettier line) and that the
script/bootstraplayer showsCACHEDin run 2. That second half is the validity control, not just a performancecheck: on a shared host a pair that spanned a cache eviction would show bootstrap
re-executing, and would have to be discarded rather than believed.
script/cibuildto plaindocker build .,keeping the
Dockerfilechange, and confirm the false green returns. Without this,a passing pair shows only that the build re-ran, not that
CHECK_EPOCHis why.(The guard means this now surfaces as a loud failure rather than a cached green,
which is a stronger result; I will report exactly what it produces.)
exit, revert.
make checkgreen.No
docker builder prunein any form, and no--no-cacheon the whole build. Anyscoped invalidation needed for diagnosis will use
--no-cache-filter.Status — implementation was started and stopped part-way when the
implementer hit an account capacity limit. No PR opened. Partial work is
preserved and pushed as branch
fix/23-cibuild-check-epoch(commitb6e0be8,titled
WIP:and deliberately carryingrefs #23, not a closing token).Pushing it rather than discarding it because the analysis in it is worth
keeping, but it must not be merged as-is and the branch is not a candidate
for review.
What the partial work got right
The mechanism is sound and the reasoning behind each choice is documented in
the diff:
ARG CHECK_EPOCHdeclared with no default, on the grounds that a defaultwould be a constant and a constant is a stable cache key — i.e. the defect
unchanged. That is correct and is the trap most naive versions of this fix
fall into.
RUNcommand rather than merely declared,so the cache miss does not depend on BuildKit's handling of a
declared-but-unreferenced
ARG, and the value shows up in the build log.script/cibuild, the value is assigned to a variable rather thansubstituted inline, because a failing command substitution inside an argument
list does not trip
set -e— the inline form would silently pass an emptystring and restore the cached false green. As the whole of an assignment its
exit status is the command's, so
set -ecatches it.%Nfor sub-second distinctness with$$appended, since busyboxdatesilently drops
%Nand still exits 0.Everything above
COPY . .still caches, so thescript/bootstraptoolchainlayer is preserved — which was the explicit constraint.
Why it is not mergeable
make dockerwould break. TheDockerfileguard fails the build whenCHECK_EPOCHis unset, andscript/dockerwas never updated to pass it.Verified:
script/dockerstill runs a baredocker build -t ... .. TheDockerfileheader comment already claims bothscript/cibuildandscript/dockerpass the argument — that claim is currently false, which isexactly the class of stale-documentation defect #9 was about.
prompts#26.The implementer's own last note was that it intended to simplify to match
upstream rather than keep the bespoke guard.
script/cibuildis meant to bebyte-identical across repos, so a lora.vegas-only variant is the wrong
outcome.
deliberate-failure proof required by this issue's definition of done was
run. Given this repo's history, an unverified cache fix is worth nothing —
the whole point is that it is easy to believe a gate works when it does not.
For whoever picks this up
Treat the branch as notes, not as a starting point to polish. Decide first
whether the guard belongs at all: it is defensible (it makes a bare
docker build .fail loudly rather than silently returning a false green) but it is anaddition beyond what upstream specifies, and it forces every entrypoint that
builds the image to pass the argument. If it is kept,
script/dockermust passCHECK_EPOCHtoo and the two scripts should generate the value identically.The definition of done in the issue body is unchanged and still governs,
including that both consecutive runs must be pasted, that the bootstrap layer
must still cache, and that a deliberate check failure must be shown to fail the
build.
Also unchanged: the ordering dependency with #8. This lands before the
.dockerignorework, and #8's.dockerignorechange requires re-verifyingthis issue's two-run proof afterwards.
Implementation plan (picking this up after the stalled partial work)
Branch:
fix/23-cibuild-check-epoch-v2, frommainat8034fd8. The earlierfix/23-cibuild-check-epochbranch is treated as notes only, per the statuscomment above; it is not being polished forward.
Upstream status, checked first
sneak/prompts#26 has not landed on that repo'smain—script/cibuildthere is still a bare
docker build .. The canonical form has, however,settled: it is implemented on that repo's
nextbranch (commit51c3945,carried by its PR #34) and the
#26portion passed independent re-review. PR#34 is still open only because a later commit on it, for a different issue,
needs rework.
So I am adopting the settled canonical four-element form verbatim rather than
inventing anything, and the PR will note that upstream has not merged yet and
may need re-syncing. Propagation to consuming repos is tracked upstream
separately as
sneak/prompts#35.Guard decision: keep it
The caller asked for a deliberate decision on whether the
[ -n "$CHECK_EPOCH" ] || exit 1guard belongs. Keeping it, for the reason itwas added upstream rather than as a preference: an unset
ARGis the emptystring, and empty is a stable cache key. Upstream measured a repo that had
"landed the fix" still producing the original false green through a bare
docker build .— checks ran on the cold run, then exit 0 in 0s with everycheck layer
CACHEDon the warm one. Documentation-only mitigation was showninsufficient. Without the guard the fix leaves the exact defect reachable
through the one command that a reviewer diagnosing a build is most likely to
type by hand, which in this repo has already burned three separate reviewers.
The cost is that every entrypoint building the image must pass the argument, so
script/dockergets it too and both scripts generate the value identically.That was precisely what made the previous attempt unmergeable.
DockerfileBelow
COPY . ., replacingRUN make check:ARGis stage-scoped and must be redeclared in every stage running checks;this image is single-stage, so one declaration is correct and complete. No
default value. The value is expanded into the
RUN— this is hardening, notthe fix: the bare unreferenced-
ARGform does work, but expansion makes thecache miss contractual rather than dependent on BuildKit's handling of an
unreferenced
ARG, and prints the epoch into the log. Both the guard and thecheck
RUNreference the value, so there are two independent invalidationpoints, not one; both stay.
Placement below
COPY . .is what preserves thescript/bootstraplayer,which on this repo compiles Hugo from source.
script/cibuildandscript/dockerAssigned on its own line, never inlined into the argument list: a failing
command substitution inside an argument does not trip
set -e, so the inlineform would degrade to an empty string and restore the cached false green.
%Nfor sub-second distinctness on a host running many concurrent sessions,
$$because busybox
datesilently drops%Nand still exits 0.script/dockergets the identical two lines with its-ttag.Docs
README.md's Entrypoints section claimedscript/cibuildisdocker build .;corrected, plus a short note that the image must be built through the scripts.
This repo has no
REPO_POLICIES.mdor checklist files yet, so the upstreamprose sweep has no other targets here —
git grep -nF 'docker build'nowreturns only the two canonical
--build-arginvocations and sites describingthe bare command as failing closed by design.
Not doing
make checkruns..dockerignore; #8 lands after this, per the ordering above.CACHED" self-check. Thatwas explicitly rejected upstream — the guard already turns the regression
case into a hard failure, and grepping progress output is format-dependent.
Verification to be pasted
script/cibuildruns on an unchanged tree, in full, withwall-clock times.
RUN script/bootstrapmust showCACHEDin run 2. Thisis not a performance note — it is what rules out the pair having spanned a
cache eviction, and rules out an accidental whole-build
--no-cache.CHECK_EPOCHtwice and confirm the falsegreen returns. Reverting
script/cibuildto a baredocker build .is nolonger a usable counterfactual once the guard exists, since that now fails.
non-zero exit, revert.
make dockerworks;make checkgreen.No
docker builder prunein any form, and no whole-build--no-cache.Implemented in #30 (branch
fix/23-cibuild-check-epoch-v2, commit223c520). Full evidence is pasted inthe PR body; summary against this issue's definition of done below.
1.
script/cibuildcannot serveRUN make checkfrom cache. TheDockerfiledeclaresARG CHECK_EPOCHwith no default immediately belowCOPY . ., guards it withRUN [ -n "$CHECK_EPOCH" ] || exit 1, and expandsit into the check command.
script/cibuildandscript/dockerboth buildepoch="$(date +%s%N)$$"on its own line and pass it.2. Two consecutive runs, both executing the checks. Yes — both pasted in
full in the PR. Both show two Hugo builds and
All matched files use Prettier code style!, with different epochs printed.15s then 6s wall.
3. The
script/bootstraplayer still caches. Run 2 shows#8 [4/7] RUN script/bootstrap→CACHED. Hugo was not recompiled.COPY . .is
CACHEDin run 2 as well, which is the mechanism working rather than aproblem: the content hash is unchanged — that is the defect — and the epoch
below it is what forces the checks to run anyway.
4. A genuine failure still fails. Appended badly-wrapped prose to
README.md;script/cibuildexited 1 on[warn] Code style issues found in the above file. Reverted.5.
make dockerworks. Exit 0, bootstrapCACHED, check layer executed,image tagged. This required updating
script/dockerto pass the argument,which is the gap that made the earlier branch unmergeable.
6.
make checkpasses. Green on the host.7.
TODO.mdupdated in the same commit.Two things worth flagging
Guard decision: kept. Reasoning in the PR. Short version: an unset
ARGisthe empty string and empty is a stable cache key, so without the guard the
original defect stays reachable through a bare
docker build .. Upstreammeasured a repo that had "landed the fix" and still produced the false green
that way. The accepted consequence is that a bare
docker build .now fails bydesign, exit 1, and keeps failing on repeat since failed steps are not cached.
A counterfactual was run, and it is the part I would look at first in
review. Two consecutive runs proving the checks ran does not by itself prove
CHECK_EPOCHis why — the tree had just changed. Passing a constantCHECK_EPOCHtwice reproduced the original false green on the fixedDockerfile: every layerCACHED, exit 0, no check output. So theper-invocation value is the active ingredient, not the file edit.
Note that the counterfactual named in the earlier plan comment — reverting
script/cibuildto a baredocker build .— is no longer usable once theguard exists, because that path now fails rather than producing a green. The
constant-epoch form replaces it and is the stronger test anyway.
Ordering
This lands before the
.dockerignorework in#8, per the ordering comment above.
The two-run proof here will need re-running after that change, since excluding
.claude/removes a current source of build-context churn. Not something thisPR can pre-empt.
Upstream
sneak/prompts#26 has not merged to that repo'smainyet — the shape hassettled and is implemented on its
nextbranch, and that is what this adoptsverbatim. Flagged in the PR body that it may need re-syncing once upstream
merges.