script/cibuild reports a green it did not earn when the tree is unchanged #85
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?
script/cibuildcan exit 0 without running the test suite or the linter.Not "print less" — exit 0 having executed nothing.
Mechanism
script/cibuildis a baredocker build .with no cache control:The
DockerfiledoesCOPY . .and thenRUN make fmt-check/RUN make lint(lint stage) andCOPY . ./RUN make test(builderstage). When the tree has not changed since the previous build, Docker
serves those
RUNlayers from cache. The commands never execute. Thebuild still exits 0.
Reproduction on this repo
Measured on
mainat50e20b4, clean tree, back to back.First run — tree changed since the last build, so the
COPYlayerwas invalidated and everything genuinely ran:
Second run, immediately after, nothing touched:
1.15 seconds. Fifteen cached layers. Zero
oklines — the test suiteproduced no output because it did not run. Identical
EXIT=0. A callerthat checks only the exit code cannot distinguish a 252-second real pass
from a 1-second replay.
Why this is worse than a quiet build
A silent build is suspicious. This is a confident green: the exit code
is the one signal most automation trusts, and it is wrong. It also fails
in the most dangerous direction — the longer a tree sits unchanged, the
more likely the "verification" is a replay, which is exactly the state a
branch is in when someone is about to merge it.
This has already bitten twice here: both the review and the re-review of
PR #83 received
EXIT=0from fully-cached builds and correctly discardedthem, re-running uncached instead. It did not cause a bad merge only
because reviewers were explicitly told not to trust a bare exit code.
Context
This is the third distinct way this repo's gate has manufactured an
unearned green:
script/lintresolvedgolangci-lintfromPATHwhile CIpinned v2.12.2 by digest; two different agents reported false greens.
context-gated (hardening, not yet a live bug).
Reported fleet-wide; the shared template carries the same hole and it is
filed upstream in
sneak/promptsas #26, tracked insneak/dnswatcheras#115. Fixing it here should match whatever lands upstream rather than
inventing a local variant.
Definition of done
script/cibuildcannot report success without the check stagesactually executing. The known-good approach: an
ARG CHECK_EPOCHdeclared immediately above the
RUN make ...check lines, withscript/cibuildpassing--build-arg CHECK_EPOCH="$(date +%s)". Thisbusts only the check layers — dependency and module layers stay
cached, so the build stays fast.
ARGmust sit immediately above the checkRUNs. Too early and it invalidates dependency layers, making everybuild a cold build; too late and the checks stay cached.
make fmt-check,make lint) and the builder stage (make test). Fixing only oneleaves half the gate fake.
script/cibuildtwice back toback on an unchanged tree and confirm the second run still executes the
suite — non-trivial wall time, real
oklines, noCACHEDon thecheck layers. Paste both runs' timings into the PR. A fix that is not
demonstrated against the actual failure mode is not done.
turn every build into a cold build. Report before/after wall times for
a changed-tree build.
.golangci.ymlunchanged (sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb);the
DockerfileFROMline remains the single source of truth for thelinter version (#78).
Interim rule
Until this lands, a bare
script/cibuildexit code is not evidence.Verification must either show a non-cached run (real
oklines, plausiblewall time, no
CACHEDon check layers) or use a host-sideGOFLAGS=-count=1 make check. Every PR claiming green should state whichit was.
Implementation plan
Branch
fix-cibuild-cacheoffmainat3bcdbcf. Matching theupstream fix in
sneak/prompts#26 rather than inventing a localvariant.
1.
Dockerfile— lint stage. Insert, immediately above the checkRUNs and belowCOPY . .:2.
Dockerfile— builder stage. Same, immediately aboveRUN make test.ARGscope is per-stage in Docker, so the builderstage needs its own declaration; the lint stage's does not carry over.
Placement is below
COPY go.mod go.sum .//RUN go mod downloadandbelow the
apk addlayers in both stages, so dependency and toolchainlayers keep caching. Only the check
RUNs (and, in the builder stage,the
go buildthat followsmake test) are invalidated per build.3.
script/cibuild.docker build --build-arg CHECK_EPOCH="$(date +%s)" ., POSIX sh, consistent with the otherscript/entrypoints.script/dockeris deliberately left alone — itbuilds an image, it is not the gate.
4.
README.mdentrypoints entry forscript/cibuildupdated tosay the checks are forced to re-execute rather than being served from
the layer cache, so the documented behavior matches.
5.
TODO.mdupdated per the Workflow section, in the same commit.Verification plan
Directly against the failure mode in the report, not by trusting an
exit code:
script/cibuildtwice back to back on an unchanged tree, capturing$?immediately, wall time,okline count, and the per-layerCACHEDstatus from--progress=plain. The second run must shownon-trivial wall time, real
oklines with durations, and noCACHEDon the check layers.branch, to confirm the reproduction still holds at
3bcdbcfand thatthe delta is attributable to the fix.
CACHEDstatus of thego mod downloadandapk addlayers, toprove dependency caching survives and this is not a cold build every
time.
GOFLAGS=-count=1 make checkas an independentcross-check.
.gitea/workflows/check.ymlrunsscript/cibuildand is unchanged;confirmed the script still works standalone with no environment
beyond a Docker daemon.
Out of scope, untouched:
.golangci.yml(sha256021cc83f...46bcb), the lint-stageFROMline and its digest,script/lint's pinned-image logic.Closed by PR #89, merged to
mainasc3bb3b5.First verification run using the fixed gate, on the merged
main:The 190 seconds is now itself the evidence. The identical command on
the identical clean tree returned in 1.15 seconds before this fix,
with zero
oklines and 15 cached layers, and exited 0 both times. Theexit code never distinguished the two states; wall time and
ok-linecount did. That is the whole point of the change.
Independent corroboration from the runner side:
check / checknow takes2m57s on the fixed head, against the "Successful in 6s" signature
on pre-fix
maincommits. This was never a local-workstation artifact —CI itself had been issuing 6-second unearned greens.
What the guarantee actually is
Deliberately stated conditionally, in the README and the
Dockerfile,rather than absolutely: the check layers cannot be replayed when
script/cibuildpasses a freshCHECK_EPOCH. It holds per (buildcontext, epoch value). After a session spent deleting false claims about
what this gate proves, shipping a new absolute claim that is not absolute
would have been the wrong lesson.
Known residual gap, tracked in #91
A bare
docker build .with no--build-argstill replays the checklayers from the second consecutive run onward — reproduced twice at
400ms and 356ms, zero
oklines, all three check layersCACHED, exit 0.An unset
ARGis an empty string, and an empty string is a stable cachekey.
That gap matters more than it sounds:
REPO_POLICIES.mdnamesdocker build .verbatim as a command that must be green, so thedocumented command is exactly the one that can still lie. #91 is
dispatched and adds
RUN [ -n "$CHECK_EPOCH" ] || exit 1to both checkstages — failed steps are never cached, so it fails on every invocation
rather than once.
Note on the fix's own near-miss
The first attempt inlined
--build-arg CHECK_EPOCH="$(date +%s)". Underset -eu, a failing command substitution in an argument position doesnot abort, so a failing
datewould have yielded an empty — henceconstant — epoch and silently restored the exact false green this issue
exists to eliminate. The guard would have disarmed itself and still
exited 0. Caught in review, fixed by assigning the epoch on its own line,
and verified behaviorally by shadowing
dateto fail:script/cibuildexits 1 and no build starts.
Worth recording because it is the same defect one level up. A guard
against unearned greens is exactly the kind of code that can fail green.