diff --git a/Dockerfile b/Dockerfile index 582c882..0b57a9e 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. A bare +# `docker build .` fails by design - see the guard below. # alpine 3.21, 2026-02-28 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 @@ -15,5 +19,28 @@ RUN script/bootstrap COPY . . -# Run all checks - build fails if any check fails. -RUN make check +# Everything from here down is invalidated on every build; everything +# above it still caches. `COPY . .` alone is not enough to force the +# checks to run: it is keyed on content, so an unchanged tree serves +# `RUN make check` from cache and the build reports a green without +# having run anything. CHECK_EPOCH is a per-invocation value passed by +# script/cibuild and script/docker, so the check layer is never reused. +# +# Declared with no default on purpose. A default would be a constant, +# and a constant is a stable cache key - the defect unchanged. +ARG CHECK_EPOCH + +# An unset ARG is the empty string, which is also a stable cache key, so +# without this guard a bare `docker build .` would still get the cached +# green. Failed steps are never cached, so this fails on every such +# invocation rather than once. +RUN [ -n "$CHECK_EPOCH" ] || { \ + echo "CHECK_EPOCH is unset: build via script/cibuild or script/docker" >&2; \ + exit 1; \ + } + +# Run all checks - build fails if any check fails. The epoch is expanded +# into the command so the cache miss does not depend on BuildKit's +# handling of a declared-but-unreferenced ARG, and so the value is +# visible in the build log. +RUN echo "check epoch: ${CHECK_EPOCH}" && make check diff --git a/script/cibuild b/script/cibuild index 968fae1..0cbc93a 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 leaving the script/bootstrap +# toolchain layer above it cached. 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 silently + # pass an empty string and restore the cached false green. As the + # whole of an assignment, its exit status is the command's and + # `set -e` catches it. `%N` keeps two invocations in the same second + # distinct; busybox date silently drops `%N` and exits 0, so `$$` is + # appended to cover that case. + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" . } main "$@"