1 Commits
Author SHA1 Message Date
sneak ae183d5529 Gate the build on Docker lint and test phases (closes #40, closes #30)
check / check (push) Successful in 23s
Per the owner ruling on issue 40, linting and testing are phases of the
main Dockerfile rather than a separate lint file. script/lint and
script/test build one phase each by name with caching disabled, and the
final stage copies a harmless file from each so the image cannot be built
unless both passed — template-app-go's ordering trick, extended to the
test phase. A stage that is not the last is built only when something
depends on it or --target names it, so the gates are invoked by name and
the edges kept. script/check runs the gates and builds no image;
script/cibuild bootstraps first, because CI runs it alone and fmt-check
is native. Every build in script/ is tagged and uncached. No config
verify step. Issue 30 closes too: a container has its own lint cache and
lock.

Model: opus-5
2026-09-08 05:27:17 +00:00
8 changed files with 70 additions and 53 deletions
+8 -7
View File
@@ -123,16 +123,17 @@ alpine. We provide:
- `script/fmt` — format all markdown files with prettier (writes; native, not in - `script/fmt` — format all markdown files with prettier (writes; native, not in
a container) a container)
- `script/fmt-check` — check formatting (read-only; native) - `script/fmt-check` — check formatting (read-only; native)
- `script/check` — run all checks: `test`, `lint`, `fmt-check`, then - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
`docker build -t prompts .` (our own extension; no `--no-cache` there, because extension); builds no image
the two phases that build depends on were just built without it)
- `script/docker` - `script/docker`
`docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag `docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
coming from `script/projectname` (byte-identical across repos) coming from `script/projectname` (byte-identical across repos)
- `script/cibuild` — cd to the repo root, compute `version` from `git describe`, - `script/cibuild` — cd to the repo root, run `script/bootstrap`, run
run `script/check`, then `script/check`, compute `version` from `git describe`, then
`docker build --build-arg VERSION="$version" -t prompts .` (what CI runs; the `docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI
version is computed on the host because `.dockerignore` excludes `.git`) runs; it bootstraps because CI checks out and runs this alone while
`script/fmt-check` is native, and the version is computed on the host because
`.dockerignore` excludes `.git`)
- `script/precommit` — run by the git pre-commit hook (our own extension); calls - `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
+2
View File
@@ -29,6 +29,8 @@ fmt-check, and commit.
they pass. This also closes issue 30: a container has its own result cache and they pass. This also closes issue 30: a container has its own result cache and
its own lock, so a lint verdict can no longer belong to another checkout. No its own lock, so a lint verdict can no longer belong to another checkout. No
separate lint Dockerfile, and no `golangci-lint config verify` step. separate lint Dockerfile, and no `golangci-lint config verify` step.
`script/check` runs the gates and nothing else, and `script/cibuild`
bootstraps first, since it is all CI runs and `script/fmt-check` is native.
- 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out - 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
of version control: `.claude/` is one full checkout of the repo per in-flight of version control: `.claude/` is one full checkout of the repo per in-flight
agent, and under `COPY . .` all of it was reaching the image. Also closed the agent, and under `COPY . .` all of it was reaching the image. Also closed the
+7 -2
View File
@@ -99,6 +99,10 @@ with your task.
on the host. on the host.
- [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a - [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
dangling image behind on every run, on every host and CI runner dangling image behind on every run, on every host and CI runner
- [ ] `script/cibuild` runs `script/bootstrap` before `script/check`, and builds
the image with `--no-cache`. Without the bootstrap the CI run dies in
`script/fmt-check`, which runs the formatter on the host and finds nothing
installed.
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its - [ ] `script/bootstrap` installs no linter of its own — delete the block, its
version variables and its call site. A JS repo's `yarn install` stays; it version variables and its call site. A JS repo's `yarn install` stays; it
brings a linter along with every other dependency, and no verdict is taken brings a linter along with every other dependency, and no verdict is taken
@@ -151,8 +155,9 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds and demonstrably executed the checks — a - [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which
sub-second build, or `CACHED` on a gate layer, means nothing ran is what CI has, and demonstrably executed the checks — a sub-second build,
or `CACHED` on a gate layer, means nothing ran
- [ ] A planted lint violation fails both `make lint` and a plain - [ ] A planted lint violation fails both `make lint` and a plain
`docker build .`; revert it afterwards `docker build .`; revert it afterwards
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task
+10 -6
View File
@@ -113,15 +113,18 @@ are thin shims calling them. Model scripts:
container) container)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only; - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
native) native)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`, then - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
builds the image; must not modify files modify files
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); `--no-cache`, plus the `script/projectname` (byte-identical across repos); `--no-cache`, plus the
version as a build arg version as a build arg
- [ ] `script/cibuild` — cd to repo root, run `script/check`, then - [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run
`docker build --build-arg VERSION="$version" .` (what CI runs) `script/check`, then
`docker build --no-cache --build-arg VERSION="$version" .` (what CI runs).
The bootstrap is required: CI checks out and runs this alone, and
`script/fmt-check` runs the formatter on the host.
- [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a - [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a
dangling image behind dangling image behind
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
@@ -135,8 +138,9 @@ are thin shims calling them. Model scripts:
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds and demonstrably executed the checks — a - [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which
sub-second build, or `CACHED` on a gate layer, means nothing ran is what CI has, and demonstrably executed the checks — a sub-second build,
or `CACHED` on a gate layer, means nothing ran
- [ ] Plant a lint violation and confirm both `make lint` and a plain - [ ] Plant a lint violation and confirm both `make lint` and a plain
`docker build .` fail on it; revert. A plain build that passes proves the `docker build .` fail on it; revert. A plain build that passes proves the
final stage is missing its `COPY --from=` edge to the gate phases. final stage is missing its `COPY --from=` edge to the gate phases.
+28 -20
View File
@@ -60,12 +60,16 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/check`, and builds the image with the version; the repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
Gitea workflow calls it. Four further scripts are our own extensions to the with the version; the Gitea workflow calls it. **`script/cibuild` runs
standard: `script/check` runs `script/test`, `script/lint` and `script/bootstrap` first**, because the workflow checks out the repo and runs
`script/fmt-check`, then builds the image; `script/precommit` is what the git nothing else, while `script/fmt-check` runs the formatter on the host: on a
pre-commit hook runs, and it calls `script/check`; `script/install-precommit` pristine checkout with nothing installed the run dies there, after the
installs the git pre-commit hook (the `make hooks` target shims to it); and containerised gates have passed. 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 `script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g. name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay `script/docker` assembles its image tag from it — so those scripts stay
@@ -115,7 +119,7 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing. and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in `script/check`, **Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling `script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image. runner; a tagged one replaces the previous image.
@@ -139,14 +143,13 @@ style conventions are in separate documents:
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates - **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still the check `RUN` is served from cache, nothing executes, and the build still
exits 0. `script/lint`, `script/test`, `script/cibuild` and `script/docker` exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
therefore pass `--no-cache`, and a bare `docker build .` is not evidence that `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
anything ran: a sub-second build reporting success is a cache hit, not a four, and there is no fifth — `script/check` runs the two gate phases and
result. The one build that may go through the cache is the image build inside `script/fmt-check`, and builds no image of its own. A bare `docker build .` is
`script/check`, which runs immediately after the two gate phases were built not evidence that anything ran: a sub-second build reporting success is a
uncached and so reuses that run. Never invalidate by pruning — cache hit, not a result. Never invalidate by pruning — `docker builder prune`
`docker builder prune` and friends destroy a build cache shared with every and friends destroy a build cache shared with every other build on the host.
other build on the host.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -218,10 +221,13 @@ style conventions are in separate documents:
`script/docker` and `script/cibuild`; no stage may call `git describe`. `script/docker` and `script/cibuild`; no stage may call `git describe`.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push. That script runs the gate phases and then the runs `script/cibuild` on push, and checks out the repo as its only other step.
image build, so a successful run means every check passed; a bare That script bootstraps, runs the gate phases, and then builds the image, so a
`docker build .` does not carry the same guarantee, because its gate phases successful run means every check passed; a bare `docker build .` does not
may come from the cache. carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -352,7 +358,9 @@ style conventions are in separate documents:
# trip `set -e`, so the inline form degrades to an empty constant. # trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache --build-arg VERSION="$version" . docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
``` ```
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
+4 -10
View File
@@ -1,22 +1,16 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check) and then the # script/check: run all checks (test, lint, fmt-check). Our own
# image build. Our own extension to scripts-to-rule-them-all. test and # extension to scripts-to-rule-them-all. test and lint are Docker
# lint are Docker phases; fmt-check is native, because a formatter # phases; fmt-check is native, because a formatter writes the working
# writes the working tree. Must not modify any files. # tree. Must not modify any files.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
# No --no-cache here: the two phases this build depends on were just
# built uncached above, so what it reuses is that run and not an
# older one.
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+9 -5
View File
@@ -1,7 +1,10 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. script/check runs the gates and # script/cibuild: run the CI build. It bootstraps first: a CI runner
# builds the image; this repeats the build with the version, which # checks out and runs this and nothing else, and script/fmt-check runs
# reuses the phases just built rather than re-running them. # the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,6 +12,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore # empty constant. VERSION is computed here because .dockerignore
@@ -16,8 +21,7 @@ main() {
# version without failing. # version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
"$SCRIPT_DIR/check" docker build --no-cache \
docker build \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+2 -3
View File
@@ -1,9 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # 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.
# --no-cache because this is a standalone entrypoint: the lint and test # --no-cache because the gate phases the final stage depends on are RUN
# phases the final stage depends on must run rather than be served from # steps, and a cached one is a check that did not run.
# an older build.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"