Make a missing CHECK_EPOCH fail the build instead of faking it (closes #91) #92
Reference in New Issue
Block a user
Delete Branch "fix-check-epoch-hardening"
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?
Adopts all four remaining upstream
CHECK_EPOCHhardening items fromsneak/prompts#26, as decided in the manager comment on #91. Closesthe gap PR #89 left open deliberately.
What changed
1. Fail closed on a missing value (
Dockerfile, both check stages).An unset
ARGis an empty string, and an empty string is a perfectlystable cache key — so the second and every later bare
docker build .on an unchanged tree replayed all three check layers, executed nothing,
and still exited 0. Failed steps are never cached, so the guard fires on
every invocation rather than once, converting a quiet lie into a loud
error. The guard is its own
RUNso the missing-arg case fails on thecheapest possible step, before any check starts.
2. Expanded form. Each check
RUNnow references the value:The bare form was not broken — it was measured working on this host in
PR #89. Expanding makes the cache miss contractual rather than dependent
on BuildKit's unreferenced-
ARGhandling staying as it is, and puts theepoch in the build log where a reader can see the layer was keyed fresh.
3. Unique epoch per invocation (
script/cibuild).epoch="$(date +%s)"becomesepoch="$(date +%s%N)$$".%Nalone isinsufficient: busybox drops it silently, exits 0, and hands back second
granularity with no warning.
$$differs between concurrent invocationsregardless. The bare-assignment form is kept deliberately — inlined into
the
--build-arg, a failing substitution does not abort underset -euand would silently yield an empty constant epoch, restoring the exact
false green this exists to prevent.
4.
script/dockerpasses the same fresh arg. Not the gate, butlocal builds are almost always warm, and two entrypoints disagreeing
about whether the tree is green is worse than either being wrong alone.
With the guard in place, passing it is now required rather than optional.
ARGscope is per-stage and this repo has gate steps in two stages(
make fmt-check+make lintinlint,make testinbuilder), soboth get their own declaration, guard and expansion. Placement is
unchanged from #85 — below
apk add,COPY go.mod go.sumandgo mod download.Docs: the
Dockerfileblock comment and theREADME.mdscript/cibuildentry both described the bare-
docker build .false green as a livecondition to work around, with #91 named as the tracker. Both are
rewritten to state that the case now fails closed.
TODO.mdupdated inthe same commit.
Verification
All numbers are recorded here and only here.
Environment note: the shared BuildKit cache on this host was
destroyed earlier by another session (
docker builder prune -af, ~41GB). No prune of any kind was run from this session — invalidation was
never needed, since the pre-fix baseline below found the cache already
warm for this tree.
CACHED: 0would be uninformative right now, sookline count is the primary evidence and dependency caching is judgedfrom the second run of each pair onward.
1. Negative control, bare
docker build ., two runsPre-fix, on the unmodified tree — the defect reproduced on both
runs, because the cache already held an empty-
CHECK_EPOCHentry forthis tree from an earlier session:
CACHEDoklinesBoth are the false green: all three check layers
CACHED, no checkexecuted, exit 0.
Post-fix, same command, two runs back to back:
oklinesBoth fail, with BuildKit naming the guard:
The second run failing is the load-bearing observation — it is what
demonstrates that a failed step is never cached, so the error is not a
one-shot.
2.
script/cibuild, back to back, unchanged treeoklines17862613551462794414203401786261495731625237576490Distinct epochs, both echoed in the build log by the expanded form.
Three independent proofs the checks genuinely re-executed in run 2:
oklines, which a replayed layer cannot produce;internal/database6.216s vs 5.785s,
internal/vaultik6.445s vs 6.235s. A cached layerreproduces its recorded output byte for byte, so differing timings
inside the check step are proof of real execution independent of any
reasoning about cache state;
3. Dependency layers still cache (also the pair-validity control)
Run 2 of the
script/cibuildpair, per-step:[lint 2/9] RUN apk add --no-cache make build-baseCACHED[lint 4/9] COPY go.mod go.sum ./CACHED[lint 5/9] RUN go mod downloadCACHED[builder 3/10] RUN apk add --no-cache make build-base sqliteCACHED[builder 5/10] COPY go.mod go.sum ./CACHED[builder 6/10] RUN go mod downloadCACHEDSo the
ARGdid not move too high: the checks bust, the dependencieshold. This doubles as the validity control for the pair — had a cache
wipe landed between the two runs, these layers would have re-executed
and the claim would have failed loudly rather than passing silently.
4.
script/docker, back to backoklines17862616402461628216972281786261769681695670832854Dependency layers
CACHEDin both, image taggeddocker.io/library/vaultik:latest.script/dockerandscript/cibuildnow agree.
5. Host-side cross-check
GOFLAGS=-count=1 make check— exit 0,0 issues.from the linter.Not a void run per #88: output contains no
parallel golangci-lint is running, and cites no path outside this worktree. Thegomodguarddeprecation warning is present and left alone (#90, reassigned
upstream).
Notes for the reviewer
control. The lint stage fails first, so
docker build .neverreaches the builder stage's
RUN [ -n "$CHECK_EPOCH" ] || exit 1. Itis there because
ARGis stage-scoped and a stage carrying a gate stepwithout its own guard would be a silent hole if the stage ordering ever
changed. I did not construct an artificial build to fire it in
isolation; stating this rather than implying the measurement covers it.
REPO_POLICIES.mdis untouched, per its org-canonical status. Itslines 170-172 assert "a successful build implies all checks pass". With
this change that statement is now true for every path into the
Dockerfile—script/cibuild,script/docker, and a baredocker build .(the last by failing rather than by passing). But it is trueby virtue of this repo having adopted the hardening, not by anything
the policy text itself guarantees, so a repo that has not adopted it
still reads a false promise there. Flagging rather than editing.
.golangci.ymlis unmodified, sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,verified before pushing. The lint-stage
FROMline and its digest(#78),
script/lint(#78/#80/#88), and.gitea/workflows/check.ymlare untouched —
git diffagainst those paths is empty.Single commit:
b101b4e.Review of PR #92 — VERDICT: PASS
Independent adversarial review of
b101b4e. Every number below is my ownmeasurement on this host, not a restatement of the PR's. No worktree of
this repo was modified, nothing was committed, and no
docker builder pruneof any kind was run.1. Negative control — the core claim. CONFIRMED
Bare
docker build .(no--build-arg) atb101b4e, twice back to back:oklinesBoth name the guard:
The second failure is the load-bearing one and it is present. A failed
step is not cached, so this is a standing error, not a one-shot.
2. Pre-fix control — the change is not a no-op. CONFIRMED
I re-ran the identical command against
origin/main(c3bb3b5) in aseparate worktree on the same host, twice:
oklinesThe false green is live on
mainright now and reproduces on both runs.This PR converts it into a hard failure on both runs. The defect is real
and currently reachable, and the fix closes it.
3.
script/cibuildstill works. CONFIRMEDUnchanged tree, back to back:
oklines1786262164730910946100422717862622893839236821136808Distinct epochs, both echoed by the expanded form. All three check layers
executed in run 2 (
#15/#16/#17in lint,#24/#25/#26inbuilder). BuildKit prints the substituted value in the step name — e.g.
#15 [lint 7/9] RUN [ -n "17862622893839236821136808" ] || exit 1—which is direct evidence the
ARGis in that layer's cache key ratherthan an inference about it.
4. Not a cold build — dependency layers still cache. CONFIRMED
Run 2 of the
script/cibuildpair, mapped from theCACHEDstepnumbers:
#12 [lint 2/9] RUN apk add --no-cache make build-baseCACHED#10 [lint 4/9] COPY go.mod go.sum ./CACHED#13 [lint 5/9] RUN go mod downloadCACHED#18 [builder 3/10] RUN apk add --no-cache make build-base sqliteCACHED#21 [builder 5/10] COPY go.mod go.sum ./CACHED#22 [builder 6/10] RUN go mod downloadCACHEDCached in both check stages. The
ARGdid not creep upward. Samepattern independently reproduced in the
script/dockerpair below.5.
script/docker. CONFIRMEDoklines1786262451355298699129422217862625848241255761427259Dependency layers
CACHEDin both stages on run 2; both check stagesexecuted. Image builds and works:
docker run --rm vaultik:latest versionreturns the version banner. The two entrypoints now agree.6. Builder-stage guard — self-flagged gap, now closed by measurement
The author correctly stated that the negative control does not reach the
builder-stage guard because the lint stage fails first, and did not imply
otherwise. I constructed the targeted probe they declined to build.
Method: a probe
Dockerfileoutside the repo (-f /tmp/builderprobe.Dockerfile, repo tree untouched and unmodified),byte-identical to the PR's
Dockerfileexcept that the single lint-stageline
RUN [ -n "$CHECK_EPOCH" ] || exit 1is removed, built with--target builderand no--build-arg. That lets an empty epoch survivethe lint stage and reach the builder stage.
Result, twice:
exit 1 on both runs. The builder-stage guard is live, correct, and
uncached on repeat. That gap is now measured, not merely argued.
7. The differing-durations primitive — ENDORSED, with one correction and one caveat
Sound, and it is a better primitive than
CACHED: 0(which isuninformative on this host right now). My own two
script/cibuildruns:internal/databaseinternal/vaultikinternal/blobinternal/pidlockCorrection to the stated mechanism. Under
BUILDKIT_PROGRESS=plainareplayed layer prints
CACHEDand no stdout at all — it does not"reproduce its recorded output byte for byte". So durations are not what
rules out layer replay; zero
oklines already does that. The conclusionholds a fortiori, but the reason given is not the operative one.
Where it genuinely adds value is one level below layer cache:
script/testrunsgo testwithout-count=1, so Go's own test-resultcache could in principle emit
ok pkg (cached)lines. Those would countas
oklines while executing nothing. Real per-package durations areexactly what distinguishes them. I verified
0occurrences of(cached)in both runs. That makes the primitive strictly stronger than
ok-linecount, not merely a restatement of it.
Caveat on use. Apply it to the whole duration vector, not to a single
package. As the table shows,
internal/pidlockwas identical to themillisecond across two genuinely-executed runs. A single matching package
is not evidence of replay; the vector matching exactly would be.
8. Guard failure modes — probed, none found
grep -rn CHECK_EPOCHover the whole tree:both
ARG CHECK_EPOCHdeclarations are bare, with no=. There is nodocker-composefile in the repo,.gitea/workflows/check.ymlinvokesonly
script/cibuild,.goreleaser.yamlhas no docker section, and no--build-arg CHECK_EPOCHappears without a value.docker buildinvocations in the repo arescript/cibuild:39andscript/docker:20, both passing--build-arg CHECK_EPOCH="$epoch".make dockershims toscript/docker. No unguarded path remains.script/cibuild:38and
script/docker:19areepoch="$(date +%s%N)$$"on their own line,never inlined into the argument. The PR #89 bug is not reintroduced.
Dockerfilecomment explicitly forbids adding a default, and nodefault exists.
9. Nothing weakened. CONFIRMED
.golangci.ymlsha256 =021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.git diff c3bb3b5..b101b4e -- REPO_POLICIES.md .gitea/ script/lint .golangci.ymlis empty.FROMline or digest changed (#78).assertions.
10. CI, mergeability, hygiene
b101b4e:check / check (pull_request)succeeded in2m13s. The duration is consistent with real execution, not a replay.
origin/mainis stillc3bb3b5and is an ancestor ofb101b4e; merge-tree reports zero conflicts. Fast-forwardable.body. Commit trailer list is empty — no attribution trailers.
(closes #91). Single commit.TODO.mdupdated in the same commit and it correctly defers allmeasurements to the PR rather than restating them, so there are no
divergent numbers across places.
git diff --checkclean; changed prose wraps at 80 columns; nonon-inclusive terminology; containerised
make fmt-checkandmake lintpassed cleanly across five in-container runs (structurally immuneto #88; no
parallel golangci-lint is running, no out-of-worktreepaths).
11. Honesty of the self-flagged items
Both handled correctly. The builder-guard limitation was stated plainly
rather than papered over — the PR says "stating this rather than implying
the measurement covers it", which is exactly right, and I closed it above.
REPO_POLICIES.mdwas correctly flagged and correctly left unedited givenits org-canonical status.
I also cross-checked the author's own reported figures against their run
logs still present on this host: 14
oklines, epoch1786261495731625237576490,internal/database5.785s andinternal/vaultik6.235s — matching the PR table exactly. The reportedmeasurements are genuine.
Nits (non-blocking, no rework required)
longer than all twelve most recent commits on
main(max 68).Cosmetic; no written rule in
REPO_POLICIES.mdcovers it.docker build .sees only the raw failing command text. A messagesuch as
|| { echo "CHECK_EPOCH must be set; use script/cibuild" >&2; exit 1; }would read better — but the bare form is the decidedupstream/org-canonical text, so diverging from it unilaterally here
would be worse than the nit. Explicitly not requesting a change.
TODO.md"Next Step" was not advanced — still "Triage the staleremote branches (issue #71)", unchanged from
main, while the file'sown Workflow section says to do the work in Next Step and rotate it.
Pre-existing pattern (PR #89 behaved identically) and this repo is
issue-driven; not this PR's to fix.
script/fmt-checkcoversgofmtonly, so markdown formatting isungated by the gate this PR hardens. Not introduced here, and the
changed prose is correctly wrapped anyway.
Verdict
PASS. Blocking findings: none.
This is genuinely clean. The change does exactly what the issue's
definition of done requires, the two-run negative control passes, the
pre-fix defect is confirmed live on
mainso the change is not a no-op,dependency caching is intact in both stages, both entrypoints agree, CI
is green, it is fast-forwardable onto
main, and the one gap the authorself-flagged has now been closed by direct measurement rather than
argument. Label
merge-readyand assign tosneak.