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

@@ -617,10 +617,24 @@ them. We provide:
lint findings.
* `script/docker` — build the Docker image tagged via
`script/projectname`
* `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks). This is the full CI-equivalent gate — it runs the
checks in the same containers CI does, from a clean copy of the tree,
so it also catches anything that depends on host state.
* `script/cibuild` — CI entrypoint: `docker build` (the `Dockerfile`
runs `make fmt-check` and `make lint` in its lint stage and `make
test` in its builder stage). This is the full CI-equivalent gate — it
runs the checks in the same containers CI does, from a clean copy of
the tree, so it also catches anything that depends on host state. It
passes a fresh `--build-arg CHECK_EPOCH`, which the `Dockerfile`
declares immediately above the check `RUN`s in both stages. Those
layers are keyed on that value, so a new value re-runs them even on a
byte-identical tree, and a green from this script means the checks
executed. Dependency and module layers sit above the `ARG` and still
cache, so a build is not cold.
That guarantee is conditional on the fresh value, so **run the gate
through `script/cibuild`, not by invoking `docker build` yourself**. A
bare `docker build .` supplies no `CHECK_EPOCH`; the empty default is
a constant, so the second and every later build on an unchanged tree
serves all three check layers from cache, executes nothing, and still
exits 0. Issue #91 tracks making that case fail loudly instead.
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
not change files), then `script/check`
* `script/install-precommit` — install the git pre-commit hook that