script/cibuild can report a cached green: docker build serves RUN make check layers from cache #54

Open
opened 2026-08-09 07:38:10 +02:00 by clawbot · 1 comment
Collaborator

Repo-local counterpart to a fleet-wide finding. The same defect was observed concretely in another repo: a script/cibuild reporting SUCCESS in 0.262 seconds with every layer CACHED, and 64.3s with a genuine pass when forced uncached.

The defect

script/cibuild is a plain docker build --ulimit memlock=-1:-1 . with no cache control. The Dockerfile does COPY . . at line 9 and line 27, each followed by the check steps — RUN make fmt-check and RUN make lint in the lint stage, RUN make test and RUN make build in the builder stage.

Docker keys those RUN layers on the hash of everything before them. On a byte-identical tree the COPY layers hit cache, so every check layer hits cache too: the suite never executes, the linter never runs, and the build exits 0. CI reports green having verified nothing.

The trigger is any re-run on an unchanged tree: re-running CI on the same commit, a manual re-trigger after an infrastructure blip, a runner with a warm layer cache picking up a commit it has already built, or anyone running script/cibuild twice locally.

Why this repo is worse than average

It compounds with #32. script/test currently ends with go test ./... || go test -v ./..., so the verbose rerun's exit status becomes the script's and a test that fails then passes on retry also produces a green.

Stacked, the two mean "CI is green" in this repo has been carrying very little information: one path skips execution entirely, the other swallows a real failure. Both need closing, and closing only one leaves a hole.

Not a live incident, for the record

I re-verified PR #29 against this and its green is genuine — CI wall-clock of 2m0s on the head and 1m8s on the pre-rework commit, tree deltas of 60 and 10 files that invalidate COPY . ., and a reviewer's targeted negative control that produced a specific predicted failure inside the container, which a cached layer cannot do. So this is a latent hazard to close, not a merge to retract. Detail on PR #29.

Definition of done

  • A re-run of script/cibuild on a byte-identical tree executes the checks again rather than serving them from cache, and demonstrably so.
  • Dependency layers stay cached. The fix must not turn every CI run into a full go mod download plus base-image re-pull — that is what makes people disable it.
  • The known-good approach, matching the upstream fix: declare ARG CHECK_EPOCH immediately above the first check step, and have script/cibuild pass --build-arg CHECK_EPOCH="$(date +%s)". Everything above the ARG keeps caching normally; everything from it down re-runs.
  • Placement matters and is the whole trick. The ARG must sit after COPY go.mod go.sum and RUN go mod download, and before the COPY . . / check sequence. Too early and dependency caching is destroyed; too late and the check layers still cache.
  • This Dockerfile has two stages with check steps — lint (make fmt-check, make lint) and builder (make test, make build). ARG is scoped per stage, so both stages need their own declaration, and --build-arg must reach both. Fixing only the builder stage leaves lint silently cached, which is exactly the kind of half-fix that reads as done.
  • Verified empirically, with numbers recorded on this issue: run script/cibuild twice in a row on an unchanged tree and show that the second run still executes the suite. Report both wall-clocks and the CACHED layer counts. A build that drops from minutes to under a second is the signature to rule out.
  • Confirm dependency layers really do stay cached, by showing the second run is still meaningfully faster than a --no-cache build.
  • TODO.md updated in the same commit.

Implementation requirements

  • Do not use --no-cache. It works and it is the wrong fix: it discards the base images and the module download too, turning a 2-minute build into a many-minute one for no benefit. The point is to invalidate exactly the check layers and nothing above them.
  • Keep --ulimit memlock=-1:-1. The suite needs it for the memguard test; removing it breaks the build in a way that looks unrelated.
  • script/cibuild must stay POSIX sh with set -eu and the repo-root cd idiom.
  • Sequence after PR #29 lands, which touches the Dockerfile.
  • Coordinate with #32 / PR #53 and the #52 decision — those change what make test does, and the verification for this issue involves running it. If #52 lands the build-tag split, make sure the second CI job gets the same treatment, or it will develop the identical cached-green problem on day one.
  • Worth checking whether .gitea/workflows/check.yml does anything that would defeat or duplicate this, such as its own caching layer.
Repo-local counterpart to a fleet-wide finding. The same defect was observed concretely in another repo: a `script/cibuild` reporting SUCCESS in **0.262 seconds** with every layer `CACHED`, and 64.3s with a genuine pass when forced uncached. ## The defect `script/cibuild` is a plain `docker build --ulimit memlock=-1:-1 .` with **no cache control**. The `Dockerfile` does `COPY . .` at line 9 and line 27, each followed by the check steps — `RUN make fmt-check` and `RUN make lint` in the lint stage, `RUN make test` and `RUN make build` in the builder stage. Docker keys those `RUN` layers on the hash of everything before them. On a **byte-identical tree** the `COPY` layers hit cache, so every check layer hits cache too: the suite never executes, the linter never runs, and the build exits 0. CI reports green having verified nothing. The trigger is any re-run on an unchanged tree: re-running CI on the same commit, a manual re-trigger after an infrastructure blip, a runner with a warm layer cache picking up a commit it has already built, or anyone running `script/cibuild` twice locally. ## Why this repo is worse than average It compounds with #32. `script/test` currently ends with `go test ./... || go test -v ./...`, so the verbose rerun's exit status becomes the script's and a test that fails then passes on retry also produces a green. Stacked, the two mean **"CI is green" in this repo has been carrying very little information**: one path skips execution entirely, the other swallows a real failure. Both need closing, and closing only one leaves a hole. ## Not a live incident, for the record I re-verified PR #29 against this and its green is genuine — CI wall-clock of 2m0s on the head and 1m8s on the pre-rework commit, tree deltas of 60 and 10 files that invalidate `COPY . .`, and a reviewer's targeted negative control that produced a specific predicted failure inside the container, which a cached layer cannot do. So this is a latent hazard to close, not a merge to retract. Detail on PR #29. ## Definition of done - A re-run of `script/cibuild` on a byte-identical tree **executes the checks again** rather than serving them from cache, and demonstrably so. - Dependency layers stay cached. The fix must not turn every CI run into a full `go mod download` plus base-image re-pull — that is what makes people disable it. - The known-good approach, matching the upstream fix: declare `ARG CHECK_EPOCH` **immediately above** the first check step, and have `script/cibuild` pass `--build-arg CHECK_EPOCH="$(date +%s)"`. Everything above the `ARG` keeps caching normally; everything from it down re-runs. - Placement matters and is the whole trick. The `ARG` must sit **after** `COPY go.mod go.sum` and `RUN go mod download`, and **before** the `COPY . .` / check sequence. Too early and dependency caching is destroyed; too late and the check layers still cache. - This `Dockerfile` has **two** stages with check steps — lint (`make fmt-check`, `make lint`) and builder (`make test`, `make build`). `ARG` is scoped per stage, so **both stages need their own declaration**, and `--build-arg` must reach both. Fixing only the builder stage leaves lint silently cached, which is exactly the kind of half-fix that reads as done. - Verified empirically, with numbers recorded on this issue: run `script/cibuild` twice in a row on an unchanged tree and show that the second run still executes the suite. Report both wall-clocks and the `CACHED` layer counts. A build that drops from minutes to under a second is the signature to rule out. - Confirm dependency layers really do stay cached, by showing the second run is still meaningfully faster than a `--no-cache` build. - `TODO.md` updated in the same commit. ## Implementation requirements - **Do not use `--no-cache`.** It works and it is the wrong fix: it discards the base images and the module download too, turning a 2-minute build into a many-minute one for no benefit. The point is to invalidate exactly the check layers and nothing above them. - Keep `--ulimit memlock=-1:-1`. The suite needs it for the memguard test; removing it breaks the build in a way that looks unrelated. - `script/cibuild` must stay POSIX sh with `set -eu` and the repo-root `cd` idiom. - Sequence after PR #29 lands, which touches the `Dockerfile`. - Coordinate with #32 / PR #53 and the #52 decision — those change what `make test` does, and the verification for this issue involves running it. If #52 lands the build-tag split, make sure the second CI job gets the same treatment, or it will develop the identical cached-green problem on day one. - Worth checking whether `.gitea/workflows/check.yml` does anything that would defeat or duplicate this, such as its own caching layer.
clawbot added this to the 1.0.0 milestone 2026-08-09 07:38:10 +02:00
Author
Collaborator

Reproduced first-hand in this repo. Two consecutive script/cibuild runs on a byte-identical tree (the merge-ready head 397011a, nothing touched between them):

wall clock CACHED layers exit
Run 1 78s 1 0
Run 2 1s 19 0

Run 2's check layers, verbatim from the build output:

#12 [lint 6/7] RUN make fmt-check
#12 CACHED
#23 [builder 8/9] RUN make test
#23 CACHED
#24 [lint 7/7] RUN make lint
#24 CACHED
#27 [builder 9/9] RUN make build
#27 CACHED

All four check layers cached. Exit 0. Nothing verified. One second, and script/cibuild reports success.

Run 1, by contrast, genuinely executed — real per-package results with real timings:

#24 24.13 ok  git.eeqj.de/sneak/secret/internal/cli     12.388s
#24 24.14 ok  git.eeqj.de/sneak/secret/internal/secret   1.825s
#24 24.14 ok  git.eeqj.de/sneak/secret/internal/vault    2.136s
#24 24.14 ok  git.eeqj.de/sneak/secret/pkg/agehd         0.277s
#24 24.14 ok  git.eeqj.de/sneak/secret/pkg/bip85         0.019s

So the signature to watch for is exactly as reported fleet-wide: a build that drops from over a minute to about a second, with the CACHED count jumping from ~1 to ~19. The 78s-to-1s ratio here is the same phenomenon as the 0.262s false green observed elsewhere.

Two things this pins down for whoever implements the fix:

Both stages are affected, confirming the two-ARG requirement in the definition of done. make fmt-check and make lint cached in the lint stage, make test and make build cached in the builder stage. A fix that adds ARG CHECK_EPOCH to only one stage leaves the other silently cached and still reports green — and it would look correct in review.

The verification target is now concrete. After the fix, run 2 on an unchanged tree must show the four check layers executing rather than CACHED, while the base-image and go mod download layers stay cached. Practically: run 2 should land somewhere well under 78s but clearly above 1s. If it comes back at 1s the fix did not work; if it comes back near 78s the ARG was placed too high and dependency caching was destroyed. Both failure modes are visible in the wall-clock alone, so record it.

For the record on the timing side, relevant to #52: this run had internal/cli at 12.388s and the whole suite around 16.6s, without -race. That is a different machine and load than the 18.7s measured in #52, and it lands on the same conclusion from a second direction — the suite is already sitting against the 20-second policy ceiling before the race detector is switched on at all.

Reproduced first-hand in this repo. Two consecutive `script/cibuild` runs on a byte-identical tree (the merge-ready head `397011a`, nothing touched between them): | | wall clock | `CACHED` layers | exit | |---|---|---|---| | Run 1 | **78s** | 1 | 0 | | Run 2 | **1s** | **19** | 0 | Run 2's check layers, verbatim from the build output: ``` #12 [lint 6/7] RUN make fmt-check #12 CACHED #23 [builder 8/9] RUN make test #23 CACHED #24 [lint 7/7] RUN make lint #24 CACHED #27 [builder 9/9] RUN make build #27 CACHED ``` **All four check layers cached. Exit 0. Nothing verified.** One second, and `script/cibuild` reports success. Run 1, by contrast, genuinely executed — real per-package results with real timings: ``` #24 24.13 ok git.eeqj.de/sneak/secret/internal/cli 12.388s #24 24.14 ok git.eeqj.de/sneak/secret/internal/secret 1.825s #24 24.14 ok git.eeqj.de/sneak/secret/internal/vault 2.136s #24 24.14 ok git.eeqj.de/sneak/secret/pkg/agehd 0.277s #24 24.14 ok git.eeqj.de/sneak/secret/pkg/bip85 0.019s ``` So the signature to watch for is exactly as reported fleet-wide: a build that drops from over a minute to about a second, with the `CACHED` count jumping from ~1 to ~19. The 78s-to-1s ratio here is the same phenomenon as the 0.262s false green observed elsewhere. Two things this pins down for whoever implements the fix: **Both stages are affected, confirming the two-`ARG` requirement in the definition of done.** `make fmt-check` and `make lint` cached in the *lint* stage, `make test` and `make build` cached in the *builder* stage. A fix that adds `ARG CHECK_EPOCH` to only one stage leaves the other silently cached and still reports green — and it would look correct in review. **The verification target is now concrete.** After the fix, run 2 on an unchanged tree must show the four check layers executing rather than `CACHED`, while the base-image and `go mod download` layers stay cached. Practically: run 2 should land somewhere well under 78s but clearly above 1s. If it comes back at 1s the fix did not work; if it comes back near 78s the `ARG` was placed too high and dependency caching was destroyed. Both failure modes are visible in the wall-clock alone, so record it. For the record on the timing side, relevant to #52: this run had `internal/cli` at 12.388s and the whole suite around 16.6s, without `-race`. That is a different machine and load than the 18.7s measured in #52, and it lands on the same conclusion from a second direction — the suite is already sitting against the 20-second policy ceiling before the race detector is switched on at all.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/secret#54