script/cibuild can report a green it did not earn (Docker layer cache) #32
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/cibuildis a baredocker build .with no cache control, and theDockerfiledoesCOPY . .followed byRUN make check. When the tree is unchanged, Docker serves the check layer from cache: the suite never runs, and the build still exits 0. A green that nobody earned.Reported fleet-wide by the manager coordinating the other repos, and observed concretely on
dnswatcher: SUCCESS in 0.262 seconds with every layerCACHED; the same build forced uncached took 64.3 seconds and was a real pass.This is the same class of defect as #24 — a shared template manufacturing greens. #24 is the local gate lying because
script/bootstrapnever checks the linter's version; this is the CI gate lying because the check layer is cached.How exposed is this repo, concretely
Narrower than it first looks, but real.
COPY . .is invalidated by any change to the tree, so a branch with actual changes does get a genuine run. The hole opens when the tree is unchanged between builds — and that is exactly what a merge commit is here. Every merge this repo has done is a non-fast-forward merge of a branch with no divergence, so the merge commit's tree is byte-identical to the branch head's:So the CI run on each merge commit was almost certainly a full cache hit. The branch-head runs are the ones that carried real signal, and for the merges already landed there is independent evidence they executed for real (recorded on the PRs): PR #2's reviewer executed the pinned lint image by digest and ran a controlled three-way config experiment inside it; PR #29's
make dockerfailed with sixgoconstfindings and then passed after the fix, which a cached layer cannot do.main's current tree is the one PR #29 validated.The danger is forward-looking: a re-run of CI on an unchanged commit, or any future merge, reports green without executing anything.
Definition of done
ARG CHECK_EPOCHis declared immediately above eachRUNthat constitutes a gate, and referenced so it busts that layer. Note this Dockerfile has three such steps across two stages —RUN make fmt-checkandRUN make lintin the lint stage, andRUN make checkin the build stage — andARGis per-stage, so it must be declared in both.script/cibuildpasses--build-arg CHECK_EPOCH="$(date +%s)". Dependency layers (go mod download, base images) must stay cached — the point is to bust only the check layers, not to make CI slow.script/dockergets the same treatment, so a developer runningmake dockerlocally cannot be fooled either. This matters here:make dockeris currently the only trustworthy gate in this repo while #24 is open.script/cibuildtwice in a row on an unchanged tree and show in the PR that the check steps executed both times, with timings. A sub-second second run is a failure of this issue.make checkandmake dockergreen.Upstream: tracked as
prompts#26 anddnswatcher#115, which fold in #24 and #19 as related template defects. If the canonicalDockerfileandscript/cibuildtemplates are fixed upstream first, prefer re-vendoring them over a bespoke local fix — but do not wait on that if it stalls.Confirmed live in this repo, independently and by accident.
The reviewer of PR #31 went to run the authoritative gate and found that
make dockerwas a 17-layer cache hit that proved nothing — it exited 0 without executing either the lint stage or the builder-stagemake check. They forced a cold build instead, which took 48.2 seconds for the lint stage on pinned golangci-lint v2.12.2 and 57.6 seconds formake checkas the unprivileged user. Both genuinely passed, so PR #31 was safe to merge — but only because the reviewer noticed and refused to accept the cached result.That is the whole issue in one observation: the gate returned success in a fraction of a second, and the only thing standing between that and a false green was a reviewer being suspicious. The next reviewer might not be.
It also sharpens the priority. While #24 is open (
script/bootstrapnever checking the linter version, so localmake checkruns v2.10.1 against a v2.12.2 pin),make dockeris supposed to be the one trustworthy gate in this repo — and this issue means it is only trustworthy when it happens to miss cache. Two independent ways to get a green nobody earned, layered on top of each other.Raising this above the other tooling issues in the milestone. #24 and #32 together should land before the remaining code work, so that the gates protecting that work are real.
Implementation plan
Branch
cibuild-cache-bustoffmain(b8ebe5f), in a scratchworktree.
Checked upstream first, per the issue's closing note:
prompts#26 isstill open and the canonical
Dockerfileinpromptsstill readsCOPY . ./RUN make checkwith noARG. There is nothing tore-vendor, so this is the bespoke local fix. It matches the shape #26
recommends, so re-vendoring later should be a no-op or close to it.
1.
Dockerfile— three gate steps, two stagesARGis per-stage, so one declaration would leave the other stagecacheable. Declaring it in both:
ARG CHECK_EPOCHafterCOPY . ., immediatelyabove
RUN make fmt-checkandRUN make lint.ARG CHECK_EPOCHafterUSER builder,immediately above
RUN make check.In both places the value is referenced in the RUN command itself
(the command string is what BuildKit hashes; a declared-but-unreferenced
ARGinvalidates nothing). Reference is anechoof the epoch, whichdoubles as evidence in
BUILDKIT_PROGRESS=plainoutput that the layerreally executed.
Position matters as much as presence: below
COPY go.mod go.sum/RUN go mod download/RUN apk add/COPY . ., so the dependencylayers above are untouched and stay cached. Only the gate layers and
what follows them (
RUN make build) go cold. A build that goes fullycold every time would be a regression, not a fix.
2.
script/cibuildandscript/dockerBoth get
--build-arg CHECK_EPOCH="$(date +%s)". POSIXsh,set -eu, no bashisms.script/dockermatters as much ascibuildhere —
make dockeris the gate a human runs by hand, and per thecomment above it is the one that actually fooled a reviewer on PR #31.
script/cibuild's header comment currently asserts the guarantee itdid not provide; it gets rewritten to say why the implication now
holds.
3. Two things I will not break
COPY --from=lint /usr/bin/golangci-lintatDockerfile:29iswhat forces BuildKit to finish the lint stage before the build stage
proceeds. Busting the lint stage's trailing layers must not let
BuildKit satisfy that copy from cache without running the stage. I
will verify the lint gates appear in the plain progress output of the
build-stage run, not just assume it.
make checkruns asbuilderbecause the permission-denied tests rely onchmod(0),which root ignores.
ARG/USERordering is chosen so the drop tobuilderstill happens beforemake check.4. Verification to be posted on the PR
script/cibuildtwice back to back on an unchanged tree, withBUILDKIT_PROGRESS=plain, showing all three gates executing onboth runs, with wall-clock times. A sub-second second run is a
failure.
dependency layers were still served from cache.
script/docker.make checkgreen.TODO.mdgets a Completed Steps entry in the same commit; committitle ends with
(closes #32).Out of scope
Propagating the fix to the canonical templates — that is
prompts#26and its follow-up. Nothing here touches #24, #19, or the test suite.