Make script/cibuild unable to report an unearned green (closes #85)
All checks were successful
check / check (push) Successful in 3m13s

script/cibuild was a bare `docker build .`. On an unchanged tree Docker
served the check RUN layers from cache, so make fmt-check, make lint and
make test never executed - and the build still exited 0. Measured at
221ms with zero ok lines and every check layer CACHED, against 162s for a
real run. CI showed the same signature: 6 second "successes" on main.

An ARG CHECK_EPOCH now sits immediately above the check RUNs in both
stages - each stage declares its own, since ARG scope is per-stage - and
script/cibuild passes a fresh value per invocation. Dependency and module
layers sit above the ARG and still cache, so this does not make every
build cold.

The epoch is assigned before the build rather than inlined into the
--build-arg. Under `set -eu` a command substitution that fails inside an
argument does not abort the script: CHECK_EPOCH would become an empty
string, an empty string is a constant, a constant CHECK_EPOCH restores
the cached false green, and the guard would silently disarm itself while
still exiting 0. As a bare assignment, set -e catches a failing date and
no build starts.

The README and Dockerfile state the guarantee conditionally. It holds per
build context and CHECK_EPOCH value, and depends on script/cibuild
passing a fresh one - a bare `docker build .` with no --build-arg still
replays the check layers from the second consecutive run onward. That
residual gap is tracked in #91 along with the remaining upstream
hardening.

Verification is recorded once, in the PR's verification comment, rather
than restated with differing numbers in three places.
This commit was merged in pull request #89.
This commit is contained in:
2026-08-09 09:37:55 +02:00
parent 3bcdbcfd83
commit c3bb3b5580
4 changed files with 97 additions and 9 deletions

View File

@@ -19,7 +19,34 @@ RUN go mod download
# Copy source code
COPY . .
# Run formatting check and linter
# Run formatting check and linter.
#
# CHECK_EPOCH must stay immediately above these RUNs. These layers are
# keyed on its value, so they are cache-eligible only for a value
# already built against this same tree. script/cibuild passes a fresh
# value on every invocation, which is what makes its green mean the
# checks really executed.
#
# The guarantee is conditional on that fresh value, not absolute. A
# build that omits --build-arg -- a bare `docker build .` -- gets an
# empty CHECK_EPOCH, and an empty string is a constant: the first such
# build runs the checks, and every one after it on an unchanged tree
# replays these layers from cache, never executing a check and still
# exiting 0, a green nothing earned. Gate through script/cibuild.
# Making the missing-arg case fail loudly instead is tracked in #91.
#
# CHECK_EPOCH is deliberately not referenced by the commands below: a
# declared-but-unreferenced ARG does enter BuildKit's cache key, which
# is measured on this host rather than assumed (PR #89). Upstream
# sneak/prompts #26 prefers expanding the value into the command so
# that the miss is contractual rather than dependent on that behavior
# staying as it is; adopting that here is tracked in #91. Do not delete
# this ARG as dead code -- the gate depends on it.
#
# ARG scope is per-stage, so the builder stage declares its own.
# Everything above this line (apk, go.mod, `go mod download`) is
# deliberately outside the busted range and keeps caching.
ARG CHECK_EPOCH
RUN make fmt-check
RUN make lint
@@ -44,7 +71,11 @@ RUN go mod download
# Copy source code
COPY . .
# Run tests
# Run tests. See the CHECK_EPOCH comment in the lint stage, including
# the conditions the guarantee depends on; ARG scope is per-stage, so
# this stage needs its own declaration, and it must stay immediately
# above the check RUN.
ARG CHECK_EPOCH
RUN make test
# Build (pure Go, no CGO required since we use modernc.org/sqlite)