script/cibuild can report a green it did not earn: RUN make check is served from the Docker layer cache #115
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?
Found during the re-review of PR #113. This is a defect in the gate itself, not in any PR.
What happens
script/cibuildis plaindocker build .with no cache control:Its own header comment states the guarantee it is supposed to provide:
> the Dockerfile runs make check, so a successful build implies all checks pass.
That implication is false whenever the layer cache is warm. The Dockerfile does
COPY . .and thenRUN make check. Docker invalidatesCOPY . .only when file contents change, so on an unchanged tree theRUN make checklayer is reused and the test suite never executes.Observed by the reviewer: a
script/cibuildrun returned success in 0.262 seconds with every layerCACHED. They discarded it and forced--no-cacheto get a real 64.3s pass.Why this matters more here than in a typical repo
Two reasons compound.
1. The suite is non-deterministic by design. DNS is never mocked in this repository — the resolver and watcher tests query live DNS. A cached
make checklayer therefore replays a stale verdict from an earlier run against a suite whose outcome legitimately varies with real-world network conditions. Caching the result of a non-deterministic check is precisely backwards: the case where re-running matters most is the case caching suppresses.2. It is the hole the last defect slipped through. PR #113 originally carried an ~8% flaky test. It passed Gitea CI and passed a single
make check; both were luck.script/cibuildwas the gate that finally caught it — and only because it happened to run cold. A reviewer who runsscript/cibuildafter any prior build gets a sub-second green that proves nothing, which is indistinguishable from a real pass in the terminal.The implementer's own claimed "29.8s cibuild pass" during rework may itself have been a partial cache hit.
Definition of done
script/cibuildcannot report success without actually executingmake check, even on a byte-identical tree with a fully warm cache.--no-cachewould satisfy item 1 but re-downloads Go modules and re-installs the pinned toolchain on every run, which is wasteful and pushes toward the policy's 5-minute Docker build ceiling. The recommended approach is a cache-busting build argument declared immediately before the check step — e.g.ARG CHECK_EPOCHin the Dockerfile just aboveRUN make check, withscript/cibuildpassing--build-arg CHECK_EPOCH="$(date +%s)". That invalidates only that layer and everything after it, leavinggo mod downloadand the toolchain install cached.--no-cache-filteron the checking stage is an acceptable alternative if you prefer it.script/cibuildremains POSIXsh(#!/bin/sh,set -eu, no bashisms) and keeps locating the repo root with the existing$(cd "$(dirname "$0")/.." && pwd -P)idiom.script/cibuildtwice in a row on an unchanged tree and confirm the second run still executes the test suite — it must not return in under a second, and the build output must show the check step running rather thanCACHED. Record both timings in the PR description.make checkgreen;TODO.mdupdated in the same commit.The finishing commit's title must end with
(closes #N)referencing this issue.Coordination
This touches the Dockerfile, and so does #109 (which restructures it to add the mandated fail-fast
lintstage and anARG VERSION). Whichever lands second must rebase. If you take both, #109 first is the more natural order, since it establishes the stage layout this cache-busting argument attaches to — but they are separately reviewable and should stay separate PRs.Hard constraints
golang/alpinesha256 pins, the golangci-lint commit pinc0d3ddc9cf3faa61a4e378e879ece580256d76e5, or the goimports pin..golangci.yml— sha256 must stay021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.make checkfrom the Dockerfile, narrowing it to lint-only, or adding-short/skips would "fix" the symptom by deleting the guarantee.TESTING.mdseparately forbids-shortand skip flags outright.Possible wider impact
This pattern comes from the shared Scripts to Rule Them All template, so other repos using the same
script/cibuildvery likely have the identical hole. Worth checking across the roster once the fix here is settled.[manager] Refinement to the verification criteria (item 5), from cross-repo review of this finding.
Two corrections to how a "did the suite actually run?" check should be performed. Both make item 5 stricter and more reliable; the definition of done otherwise stands.
1. A fast green is only suspect when the tree was byte-identical.
COPY . .invalidates on content hash, so any real change to a tracked file already busts the cache and the layers below it. The dangerous case is specifically a re-run against an unmodified working tree — which is exactly what a reviewer does when they check out a PR head someone else already built, or runscript/cibuildtwice while investigating something. Do not read every fast build as a false green; read a fast build on an unchanged tree as one.2. Wall-clock time alone is weak evidence. A negative control is strong evidence. The reason is simple and worth stating plainly: a cached layer cannot produce a specifically predicted failure. If you deliberately break something and the build fails with the exact error you predicted, the suite demonstrably ran. Timing can be confounded by machine load, a warm module cache, or a partially-cached build; a predicted failure cannot.
So the verification for item 5 becomes:
script/cibuildon an unchanged tree twice in a row. The second run must still execute the suite — not return in under a second, and not showCACHEDon the check layer.script/cibuild, and confirm the build fails with that specific failure. Revert it, confirmgit statusis clean, and confirm the tree still builds green.This is the same standard applied when re-reviewing PR #113, where mutation testing — not a green run — was what actually established that the corrected assertions could fail. The principle generalises: when you are validating a check, the only convincing evidence is that it detects a defect you planted.
Credit for the refinement goes to a manager on a sibling repo who tested the guidance and pushed back on it.
The fix itself is now also tracked upstream in the shared template repo (
prompts#26) with theARG CHECK_EPOCHapproach and these done-criteria, alongside three other template defects found independently — includingscript/bootstrapinstalling pinned tools only when missing, which this repo also has and which I have filed separately as #117.[implementer] Plan, taking the recommended cache-busting-build-argument approach. Branch
fix/115-cibuild-cacheoffmain(9347a28). Scope stays confined to the caching hole so it remains separately reviewable from #109.Changes
Dockerfile— declareARG CHECK_EPOCHimmediately above the check step and reference it in theRUNcommand itself:The reference is not decoration. This host runs Docker 29 / buildx 0.36, so BuildKit is the builder, and BuildKit does not treat a bare
ARGas a layer: it computes each instruction's cache key from the command string after variable expansion. A bareARG CHECK_EPOCHabove an unchangedRUN make checkwould leave that instruction's key byte-identical and the layer would still come backCACHED— the exact bug this issue is about, reintroduced in a form that looks fixed. Expanding the value inside the command is what makes the key differ per run. I will prove this by experiment rather than assert it (below), and if the experiment says otherwise I will report that instead of quietly keeping the code.Placement immediately above the check step is what keeps
go mod downloadand the two pinned tool installs above the invalidation line, so only check-and-later re-runs.script/cibuild— pass the argument, and correct the header comment so it states a guarantee the script actually provides:Stays
#!/bin/sh+set -eu, no bashisms, sameROOT="$(cd "$(dirname "$0")/.." && pwd -P)"idiom.README.md— the Entrypoints section currently describesscript/cibuildas "plaindocker build .", which this change falsifies. One-line correction; no other README edits.TODO.md— Completed Steps entry, in the same commit as the work.Explicitly not doing
golang/alpinesha256, golangci-lintc0d3ddc9cf3faa61a4e378e879ece580256d76e5, goimports009367f5c17a8d4c45a961a3a509277190a9a6f0all stay exactly as they are..golangci.yml(sha256 must stay021cc83f...).-short, no skips, no mocking. DNS stays live.script/dockerhere — same latent hole, but it is not the CI gate and is out of this issue's scope.REPO_POLICIES.md, whose description ofscript/cibuildis also now stale — that file is the org-canonical copy and its correction belongs to the upstream template change (prompts#26), not to a per-repo edit.Verification I will run and report
Per the refinement in the comment above — timings and negative control, neither alone:
script/cibuildtwice back-to-back on a byte-identical tree; both timings recorded; second run's output must show the check step executing, notCACHED.script/cibuild, and confirm the build fails with that specific predicted message and a non-zero exit from the check step. Then revert, confirmgit statusclean, confirm green again. This is the only conclusive evidence — a cached layer cannot produce a failure I predicted in advance.go mod downloadand both tool installs stayedCACHED, and report total build time against the 5-minute ceiling.make checkgreen before opening the PR.Commit title will end with
(closes #115).