diff --git a/Dockerfile b/Dockerfile index c14fec0..ba7dd74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,10 @@ # build, then the read-only prettier docs check), so the image build # fails on any formatting or Hugo build error. This is what CI # (script/cibuild) runs on every push. +# +# Build this only via script/cibuild or script/docker: both pass the +# CHECK_EPOCH build argument that this file requires, and a bare +# `docker build .` fails by design. See the guard below for why. # alpine 3.21, 2026-02-28 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 @@ -19,5 +23,23 @@ RUN script/bootstrap COPY . . +# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and +# script/docker. Without it an unchanged tree serves this layer from +# cache and the build reports a green it never ran. ARG is stage-scoped, +# so it must be redeclared in every stage that runs checks - this image +# has one stage, so one declaration. Declared with no default: a default +# would be a constant, and a constant is a stable cache key. The guard +# makes a bare `docker build .` fail loudly instead of silently reusing +# the empty (and therefore stable) cache key. Expand the value into the +# command so the cache miss does not depend on BuildKit's handling of an +# unreferenced ARG. Both the guard and the check RUN reference the value, +# so both are value-keyed: there are two independent invalidation points +# here, not one. Keep both. +# +# Everything above this point still caches, so the script/bootstrap +# layer - which compiles Hugo from source - is not rebuilt. +ARG CHECK_EPOCH +RUN [ -n "$CHECK_EPOCH" ] || exit 1 + # Run all checks - build fails if any check fails. -RUN make check +RUN echo "check epoch: ${CHECK_EPOCH}" && make check diff --git a/README.md b/README.md index b377288..2038896 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,16 @@ provide: - `script/check` — run `script/test`, `script/lint`, then `script/fmt-check`; modifies no tracked files - `script/docker` — build the Docker image tagged with the project name -- `script/cibuild` — the CI build (`docker build .`); the Dockerfile runs - `make check` +- `script/cibuild` — the CI build; the Dockerfile runs `make check` - `script/install-precommit` — install the git pre-commit hook that runs `script/check` +Build the image through `script/cibuild` or `script/docker` only. Both pass a +per-invocation `CHECK_EPOCH` build argument that the Dockerfile requires, so the +`make check` layer can never be served from cache — without it Docker returns a +green it did not earn. A bare `docker build .` fails closed on the Dockerfile's +`CHECK_EPOCH` guard rather than caching its way to a false success. + A convenience `make serve` target runs `hugo server` for local preview. ## License diff --git a/TODO.md b/TODO.md index 690d280..bee6d6b 100644 --- a/TODO.md +++ b/TODO.md @@ -27,6 +27,22 @@ Update `README.md` accordingly. # Completed Steps +- 2026-08-09: stopped `script/cibuild` reporting a green it never earned (closes + #23). `COPY . .` is keyed on content, so on an unchanged tree Docker served + `RUN make check` from cache: the checks never executed and the build still + exited 0. Three separate reviewers had already been fooled by it here. The + `Dockerfile` now declares `ARG CHECK_EPOCH` below `COPY . .` with no default + (a default is a constant, and a constant is a stable cache key), guards it + with `RUN [ -n "$CHECK_EPOCH" ] || exit 1`, and expands it into the check + command; `script/cibuild` and `script/docker` both pass + `epoch="$(date +%s%N)$$"` — assigned on its own line, because a failing + command substitution inside an argument does not trip `set -e`, and with `$$` + because busybox `date` drops `%N` silently. This is the canonical shape + settled upstream in `prompts` #26, which has not merged there yet, so it may + need re-syncing. Verified with two consecutive runs on an unchanged tree that + both executed the checks while `RUN script/bootstrap` stayed `CACHED`, a + constant-epoch counterfactual that restored the false green, and a planted + prettier failure that failed the build - 2026-08-09: disabled the unused `taxonomy` and `term` page kinds in `hugo.toml` (closes #13). Hugo enables the `tags` and `categories` taxonomies by default; this single-page site has no taxonomy terms and no taxonomy diff --git a/script/cibuild b/script/cibuild index 968fae1..1f7a945 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,13 +2,29 @@ # script/cibuild: run the CI build. The Dockerfile runs `make check`, # so a successful build implies all checks pass. The Gitea workflow # runs this on push. +# +# That implication only holds because of CHECK_EPOCH. Docker keys the +# `RUN make check` layer on content, so on an unchanged tree it is +# served from cache: the checks never execute and the build still exits +# 0. Passing a value that differs on every invocation invalidates that +# layer and everything below it, while the script/bootstrap toolchain +# layer above it keeps caching. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build . + # Assigned to a variable rather than substituted inline in the + # argument list: a command substitution that fails inside an + # argument does not trip `set -e`, so the inline form would quietly + # pass an empty string and restore the cached false green. As the + # whole of an assignment its exit status is the command's, so + # `set -e` catches it. `%N` keeps two invocations within the same + # second distinct; busybox date silently drops `%N` and still exits + # 0, so `$$` is appended to cover that degradation. + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" . } main "$@" diff --git a/script/docker b/script/docker index b7de6ec..c2db9b1 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,11 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # The tag comes from script/projectname. +# +# The Dockerfile requires the CHECK_EPOCH build argument, generated here +# exactly as script/cibuild generates it: see that script for why the +# check layer must not be allowed to cache, and why the value is built +# in an assignment rather than inline. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +13,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@"