diff --git a/Dockerfile b/Dockerfile index cb6489a..ac7232a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,17 @@ COPY package.json yarn.lock ./ RUN script/bootstrap COPY . . -RUN make check + +# 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. 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. +ARG CHECK_EPOCH +RUN [ -n "$CHECK_EPOCH" ] || exit 1 +RUN echo "check epoch: ${CHECK_EPOCH}" && make check diff --git a/README.md b/README.md index 3991bfc..e47911b 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,11 @@ alpine. We provide: extension) - `script/docker` — build the Docker image, tagged via `script/projectname` (byte-identical across repos) -- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the - image build runs `script/check`) +- `script/cibuild` — cd to the repo root and + `docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image + build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what + stops Docker serving that check from cache on an unchanged tree — a bare + `docker build .` fails closed on purpose) - `script/precommit` — run by the git pre-commit hook (our own extension); calls `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own diff --git a/TODO.md b/TODO.md index fac919c..9e3b80b 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,15 @@ fmt-check, and commit. # Completed Steps +- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild` + and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the + `Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its + lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a + bare `docker build .` fail closed. Corrected the org-canonical text that + asserted a successful build implies all checks pass, across every document + carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents + to write the pre-fix `script/cibuild` and ended on an acceptance item the + guard makes unsatisfiable), and the Go styleguide. - 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema config already deployed byte-identical across the org's Go repos (settings under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually diff --git a/prompts/CODE_STYLEGUIDE_GO.md b/prompts/CODE_STYLEGUIDE_GO.md index 043e8f1..d81ffff 100644 --- a/prompts/CODE_STYLEGUIDE_GO.md +++ b/prompts/CODE_STYLEGUIDE_GO.md @@ -1,6 +1,6 @@ --- title: Code Styleguide — Go -last_modified: 2026-03-18 +last_modified: 2026-08-09 --- 1. Try to hard wrap long lines at 77 characters or less. @@ -101,9 +101,16 @@ last_modified: 2026-03-18 `golangci-lint`. 1. Write a `Dockerfile` for every repo, even if it only runs the tests and - linting. `docker build .` should always make sure that the code is in an - able-to-be-compiled state, linted, and any tests run. The Docker build - should fail if linting doesn't pass. + linting. `script/cibuild` and `script/docker` should always make sure that + the code is in an able-to-be-compiled state, linted, and any tests run, and + the build should fail if linting doesn't pass. That guarantee holds only + because those scripts pass a per-invocation `CHECK_EPOCH` build arg that + busts the check layers out of the Docker cache; without it an unchanged tree + serves those layers from cache and the build reports a green it never ran. A + bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` + guard — always go through `script/cibuild` or `script/docker`. See + [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) + for the canonical form. 1. Every repo must have a `Makefile`. See [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 2f45550..69cb02b 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-07-06 +last_modified: 2026-08-09 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -29,10 +29,15 @@ with your task. if missing - [ ] `.editorconfig` exists — fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` -- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a - build step — fetch `.dockerignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` -- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on +- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); + Dockerfile runs `make check` as a build step, and every stage containing a + check-running `RUN` declares `ARG CHECK_EPOCH` with the + `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the + `CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is + served from cache on an unchanged tree and the build reports a green it + never ran. +- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on push — reference `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` - [ ] Language-specific config: @@ -104,5 +109,6 @@ with your task. # Final - [ ] `make check` passes -- [ ] `docker build` succeeds +- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design, + on the `CHECK_EPOCH` guard) - [ ] Commit and merge fixes before starting your actual task diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 2eb58ea..5b0bcfe 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-07-06 +last_modified: 2026-08-09 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -52,7 +52,12 @@ Template files can be fetched from: `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` - - All Dockerfiles must run `make check` as a build step + - All Dockerfiles must run `make check` as a build step, and every stage + containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the + `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the + `CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is + served from cache on an unchanged tree and the build reports a green it + never ran. - Server: also builds and runs the application - Non-server: brings up dev environment and runs `make check` - Image pinned by sha256 hash with version/date comment @@ -90,8 +95,14 @@ are thin shims calling them. Model scripts: - [ ] `script/projectname` — outputs the project name (used by `script/docker` for the image tag) - [ ] `script/docker` / `make docker` — builds Docker image, tagged via - `script/projectname` (byte-identical across repos) -- [ ] `script/cibuild` — cd to repo root, `docker build .` (what CI runs) + `script/projectname` (byte-identical across repos); assigns + `epoch="$(date +%s%N)$$"` on its own line and passes + `--build-arg CHECK_EPOCH="$epoch"` +- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on + its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .` + (what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in + `REPO_POLICIES.md` for why each element is load-bearing. A bare + `docker build .` fails closed by design. - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/install-precommit` — installs the pre-commit hook that runs `script/precommit` diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 79d2fb7..39e2241 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-07 +last_modified: 2026-08-09 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,19 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where + `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the + Gitea workflow calls it. Four further scripts are our own extensions to the + standard: `script/check` runs `script/test`, `script/lint`, and + `script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, + and it calls `script/check`; `script/install-precommit` installs the git + pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -99,6 +101,58 @@ style conventions are in separate documents: `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap layer stays cached until dependencies change. +- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker + invalidates a `COPY` layer only when the copied content changes, so on an + unchanged tree the `RUN make check` layer is served from cache, the suite + never runs, and the build still exits 0. A sub-second `docker build` reporting + success is a cache hit, not a result. The canonical form, in **every** stage + containing a check-running `RUN`: + + ```dockerfile + ARG CHECK_EPOCH + RUN [ -n "$CHECK_EPOCH" ] || exit 1 + RUN echo "check epoch: ${CHECK_EPOCH}" && make check + ``` + + and in both `script/cibuild` and `script/docker`: + + ```sh + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" . + ``` + + All four elements are load-bearing; none is optional, and each guards a + failure mode that otherwise fails green: + - `ARG` is stage-scoped, so a single declaration leaves the other check + stages frozen while the fix reviews as complete. Declare it in every stage + that runs checks, immediately above the first such `RUN`. + - Expand the value into the command. This makes the cache miss contractual + rather than dependent on BuildKit's handling of an unreferenced `ARG`, and + it puts the epoch in the build log. The guard is itself value-keyed, for + the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the + epoch into that layer's description (observed as + `RUN [ -n "1786287053..." ] || exit 1`) and re-runs it whenever the value + changes. Each stage therefore has two independent invalidation points, and + the guard always precedes the check `RUN`. Keep both: the expansion is + defence in depth, and it is what makes the epoch visible in the build + output. + - The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is + a stable cache key, so without it a bare `docker build .` still produces + the false green. Failed steps are never cached, so the guard fails on + every such invocation, loudly. A bare `docker build .` failing is by + design. + - Assign `epoch=` on its own line, never inline in the `--build-arg` + argument: a failing command substitution inside an argument does not trip + `set -e`, so the inline form silently degrades to an empty constant. The + `$$` suffix is required because busybox `date` drops `%N` and exits 0, so + on an alpine host the epoch would degrade to second granularity and + concurrent invocations would collide. + + This invalidates the check layers and everything after them while leaving + `go mod download`, `script/bootstrap`, and the pinned toolchain install + cached, so it does not push against the five-minute Docker build ceiling. + Blanket `--no-cache` also works but is wasteful and can blow that ceiling. + - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go repos use a multistage build where linting runs in an independent stage based on the `golangci/golangci-lint` image (pinned by hash). This stage runs @@ -119,7 +173,9 @@ style conventions are in separate documents: COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check + ARG CHECK_EPOCH + RUN [ -n "$CHECK_EPOCH" ] || exit 1 + RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN make lint # Build stage @@ -133,7 +189,9 @@ style conventions are in separate documents: COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + ARG CHECK_EPOCH + RUN [ -n "$CHECK_EPOCH" ] || exit 1 + RUN echo "check epoch: ${CHECK_EPOCH}" && make test ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath \ @@ -165,11 +223,28 @@ style conventions are in separate documents: - The build stage runs `make test` after compilation setup. Tests run in the build stage, not the lint stage, because they may require compiled artifacts or heavier dependencies. + - `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is + stage-scoped: declaring it only in the lint stage leaves `make test` + frozen at the last cached result. In each stage the guard sits immediately + below the `ARG` so a bare `docker build .` fails instead of reusing the + empty cache key, and the value is expanded into the first check `RUN` so + the cache miss does not rely on BuildKit's unreferenced-`ARG` handling. + Both of those lines reference `$CHECK_EPOCH`, so both are value-keyed: + each stage is invalidated at two independent points. The later `RUN`s in + the same stage need no expansion of their own: they are already + invalidated by their busted parent layer. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` (which runs + `docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile + runs `make check`, so a successful build implies all checks pass — but that + implication holds **only** because of the `CHECK_EPOCH` cache-bust described + above. Without it, an unchanged tree serves the check layer from cache and the + build reports a green it never earned. A bare `docker build .` fails closed by + design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through + `script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as + evidence without confirming it ran: a sub-second wall time, or `CACHED` on the + check layer, means nothing was executed. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with diff --git a/script/cibuild b/script/cibuild index 75cc3e6..786533a 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,13 +1,20 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs script/check, so -# a successful build implies all checks pass. +# script/cibuild: run the CI build. The Dockerfile runs script/check, but +# that only proves anything because CHECK_EPOCH is a fresh nonce on every +# invocation: without it Docker serves the check layer from cache on an +# unchanged tree and the build exits 0 without running the suite. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build . + # Assign on its own line: a failing command substitution inside an + # argument does not trip `set -e`, which would silently degrade the + # nonce to an empty constant. `$$` is required because busybox `date` + # drops %N without erroring. + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..ac8897d 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,9 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# Identical in all repos; the tag comes from script/projectname. +# Identical in all repos; the tag comes from script/projectname. The +# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh +# nonce on every invocation; without it a warm cache turns this into a +# green that proves nothing. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +11,13 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + # Assign on its own line: a failing command substitution inside an + # argument does not trip `set -e`, which would silently degrade the + # nonce to an empty constant. `$$` is required because busybox `date` + # drops %N without erroring. + epoch="$(date +%s%N)$$" + docker build --build-arg CHECK_EPOCH="$epoch" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@"