Adopt the remaining upstream CHECK_EPOCH hardening (expanded form, unset guard, unique epoch, script/docker) #91
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?
Follow-up to #85 / PR #89. That PR closes the false-green hole in
script/cibuildand its fix is verified working on this host. Upstreamsneak/prompts#26 has since accumulated four further hardening itemsthat PR #89 deliberately did not adopt, because its rework was
explicitly scoped to
script/cibuildand to prose, with theDockerfileARG CHECK_EPOCHplacement frozen after independentmeasurement confirmed it correct.
None of these is a defect in the landed fix. Each is a case where the
guard, if it ever breaks, breaks green — so they are worth closing
deliberately rather than leaving implicit.
1. Expanded form, so the cache miss is contractual
sneak/prompts#26 comment 48122 settled on:The bare, unreferenced
ARGthis repo ships is not broken: fourindependent upstream measurements plus this repo's own back-to-back
runs and withheld-
--build-argcounterfactual all confirm adeclared-but-unreferenced
ARGenters BuildKit's cache key. Upstreamsays bare-form repos "need no urgent rework". The argument for
expanding is that it is correct under either reading of BuildKit's
behavior, so it survives that behavior changing, and the epoch appears
in the build log where a reader can see the layer was keyed fresh.
2. Fail closed when
--build-argis withheldsneak/prompts#26 comment 48284. An unsetARGis an empty string,which is a stable cache key, so a bare
docker build .— the commandREPO_POLICIES.mdnames verbatim — still produces the original falsegreen on the second consecutive run. Measured on PR #89's own branch:
274ms, 0
oklines, all three check layersCACHED, exit 0.Failed steps are never cached, so this fails on every invocation rather
than once, turning a quiet lie into a loud error. Documentation-only
mitigation is known insufficient: upstream shipped exactly that and
someone still ran the documented command and got the false green.
3. Make the epoch unique per invocation, not per second
sneak/prompts#26 comments 48237 and 48458.date +%sissecond-granular, so two concurrent invocations in the same second get
identical epochs and the later one can be served from cache. Not
currently reachable here — the warm floor is well over two minutes — but
concurrency is the norm on this host.
date +%s%Nis not sufficientalone: busybox silently drops
%N, exits 0, and gives back secondgranularity with no warning. The portable form is:
$$differs between concurrent invocations regardless of whether%Nis honored.
4.
script/dockerhas the identical holesneak/prompts#26 comments 47571 and 48076.script/dockeris a baredocker build -t ... .with no cache control. It is not the gate, butonce
script/cibuildis fixed the two entrypoints silently disagreeabout whether the tree is green, and local builds are almost always
warm.
Definition of done
with the reason recorded in the repo rather than only in review.
change, a bare
docker build .run twice back to back must failloudly on both runs rather than reporting a cached green on the
second. The two-run requirement is not optional — an empty
CHECK_EPOCHis itself a novel cache key, so the first run executesand a single run would wrongly suggest there is no defect.
apk add,COPY go.mod go.sum,go mod download) must still reportCACHEDon an untouched-tree run. Ifthe build lands near cold-build wall time, the
ARGwas moved toohigh and the change should be rejected.
.golangci.ymlmust stay at sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,and the lint-stage
FROMline and its digest are not to be touched(#78).
Manager note — dispatching this now that #85 has merged (
mainis pastPR #89). Decisions on the four items, so the implementer is not
re-litigating them.
Adopt all four. Ordering by value:
Item 2 (fail-closed guard) is the reason this issue is not optional.
A bare
docker build .still replays the check layers from the secondconsecutive run onward — independently reproduced twice on PR #89's
branch at 400ms and 356ms, zero
oklines, all three check layersCACHED, exit 0. That matters more than a normal residual gap becauseREPO_POLICIES.mdnamesdocker build .verbatim as a thing thatmust be green. So the documented command is precisely the one that lies.
Documentation-only mitigation is already known insufficient: upstream
shipped exactly that and someone still ran the documented command and got
the false green.
Note the mechanism, because it is unintuitive: an unset
ARGis an emptystring, and an empty string is a perfectly stable cache key. Failed steps
are never cached, so
RUN [ -n "$CHECK_EPOCH" ] || exit 1fails onevery invocation rather than once — which is what converts a quiet lie
into a loud error.
Item 1 (expanded form) — adopt. The bare unreferenced
ARGis notbroken; it was verified working here across five consecutive untouched-tree
runs. But it works because of BuildKit's current cache-key behavior, which
is an implementation detail we happen to depend on. Referencing the value
in the
RUNmakes the cache miss contractual rather than incidental, andputs the epoch in the build log where a reader can see the layer was keyed
fresh.
Item 3 (unique epoch) — adopt
epoch="$(date +%s%N)$$". Not currentlyreachable (warm floor is well over two minutes), but concurrency is normal
on this host and second-granularity is a latent collision. Heed the
busybox caveat:
%Nis silently dropped, exits 0, and yields secondgranularity with no warning —
$$is what makes it correct regardless.Item 4 (
script/docker) — adopt. It is not the gate, but oncescript/cibuildis honest andscript/dockeris not, two entrypointsdisagree about whether the tree is green, and local builds are almost
always warm.
Environmental warning for whoever picks this up
The shared BuildKit cache on this host was destroyed earlier
(
docker builder prune -affrom another session, ~41 GB). Consequences:CACHED: 0is temporarily uninformative as evidence of a forcedrun — everything is cold, so that condition is trivially satisfied. The
okline count still carries the signal, since a cached build yieldszero
oklines regardless of why.script/testuses
-race -timeout 30sper package (see #69). Retry before treating atimeout as a defect.
docker builder prune. Scope invalidation to your ownbuild:
docker build --no-cache, or--no-cache-filter=<stage>. It isshared mutable state on a shared host and the blast radius is every
other session. This is worth stating explicitly because "prove nothing
was cached" makes pruning look like the most rigorous move available; it
is the most destructive one.
Implementation plan, branch
fix-check-epoch-hardeningoffmainatc3bb3b5. All four items adopted as decided in the manager comment;nothing here re-opens them.
Dockerfile — items 1 and 2, both check stages
ARGis per-stage and this repo has two stages carrying gate steps(
make fmt-check+make lintinlint,make testinbuilder),so each gets the full treatment, with the
ARGstaying exactly whereit is today — below
apk add,COPY go.mod go.sumandgo mod download, so dependency layers keep caching.Lint stage:
Builder stage:
The guard is its own
RUNrather than folded into each check line sothat the missing-arg case fails on the cheapest possible step, before
any check starts, and reads as one thing rather than three.
The existing block comment is rewritten: it currently documents the
unset-
ARGhole as a live condition to be worked around and points atthis issue as the tracker. That text becomes false with this change, so
it is replaced with the mechanism (unset
ARGis an empty string, anempty string is a stable cache key, failed steps are never cached, so
the guard fires on every invocation rather than once) and the one-line
upstream justification for the expanded form: expand the value into the
command so the cache miss does not depend on BuildKit's
unreferenced-
ARGhandling. The lint-stageFROMline and its digestare untouched (#78), as is
.golangci.yml.script/cibuild — item 3
epoch="$(date +%s)"becomesepoch="$(date +%s%N)$$". Bareassignment form is kept deliberately — inlining the substitution into
the
--build-argwould not abort underset -euand would silentlyyield an empty constant epoch, which is the exact false green this
exists to prevent. The comment gains the busybox caveat:
%Nissilently dropped with exit 0, giving second granularity with no
warning, and
$$is what makes the per-invocation guarantee holdregardless.
script/docker — item 4
Gets the identical epoch assignment and
--build-arg CHECK_EPOCH, sothe two entrypoints cannot disagree about whether the tree is green.
With the guard in place a bare
docker buildnow fails, so passing thearg here is required, not optional.
Docs
README.md'sscript/cibuildentry currently describes the baredocker build .false green as reachable and names this issue as thetracker; that paragraph is rewritten to say the case now fails closed.
TODO.mdis updated in the same commit per its Workflow section.REPO_POLICIES.mdis org-canonical and stays untouched. Its line 171still asserts "a successful build implies all checks pass" — with this
change that is true for
script/cibuild,script/dockerand a baredocker build .alike (the last by failing rather than by passing),but the sentence is only accurate by accident of this repo having
adopted the hardening. It will be noted in the PR, not edited.
Verification — negative controls, not inspection
Recorded once, in the PR:
docker build ., no--build-arg, run twice back to back.Both must fail non-zero. One run is not sufficient: an empty
CHECK_EPOCHis itself a novel cache key, so pre-fix the first runexecutes and only the second fakes.
script/cibuildback to back on an unchanged tree: second run muststill execute all three check layers, with real wall time and real
oklines carrying differing durations. Exit codes capturedimmediately.
apk add,COPY go.mod go.sum,go mod download) must still reportCACHEDfrom the second run onward.This doubles as the validity control for the pair: a cache wipe
landing mid-pair would make them re-execute and the claim would fail
loudly instead of passing silently.
script/docker.GOFLAGS=-count=1 make checkas a cross-check.BUILDKIT_PROGRESS=plainthroughout. The shared BuildKit cache on thishost was destroyed earlier by another session, so
CACHED: 0isuninformative as evidence right now and
okline count is the primarysignal; dependency caching is judged from the second run onward. No
docker builder pruneof any kind will be run — invalidation is scopedwith
--no-cache-filterif it is needed at all.