1 Commits
Author SHA1 Message Date
sneak 7f4ef15610 Gate the build on Docker lint and test phases (closes #40, closes #30)
check / check (push) Failing after 15s
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 each build one phase by name with caching disabled, and the
final stage copies a harmless file from each so the image cannot be built
unless both passed — the ordering trick already in template-app-go's
Dockerfile, extended to the test phase. A phase that is not the last
stage is built only when something depends on it or --target names it, so
the gates are always invoked by name and the edges are kept. Every build
in script/ is tagged. No config verify step; fmt stays on the host. This
closes issue 30 too: a container has its own result cache and lock.

Model: opus-5
2026-09-08 04:58:45 +00:00
8 changed files with 53 additions and 70 deletions
+7 -8
View File
@@ -123,17 +123,16 @@ 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` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check`, then
extension); builds no image `docker build -t prompts .` (our own extension; no `--no-cache` there, because
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, run `script/bootstrap`, run - `script/cibuild` — cd to the repo root, compute `version` from `git describe`,
`script/check`, compute `version` from `git describe`, then run `script/check`, then
`docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI `docker build --build-arg VERSION="$version" -t prompts .` (what CI runs; the
runs; it bootstraps because CI checks out and runs this alone while version is computed on the host because `.dockerignore` excludes `.git`)
`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,8 +29,6 @@ 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
+2 -7
View File
@@ -99,10 +99,6 @@ 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
@@ -155,9 +151,8 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which - [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
is what CI has, and demonstrably executed the checks — a sub-second build, sub-second build, or `CACHED` on a gate layer, means nothing ran
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
+6 -10
View File
@@ -113,18 +113,15 @@ 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`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`, then
modify files builds the image; must not 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/bootstrap`, run - [ ] `script/cibuild` — cd to repo root, run `script/check`, then
`script/check`, then `docker build --build-arg VERSION="$version" .` (what CI runs)
`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`
@@ -138,9 +135,8 @@ are thin shims calling them. Model scripts:
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which - [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
is what CI has, and demonstrably executed the checks — a sub-second build, sub-second build, or `CACHED` on a gate layer, means nothing ran
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.
+20 -28
View File
@@ -60,16 +60,12 @@ 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/bootstrap`, runs `script/check`, and builds the image repo root, runs `script/check`, and builds the image with the version; the
with the version; the Gitea workflow calls it. **`script/cibuild` runs Gitea workflow calls it. Four further scripts are our own extensions to the
`script/bootstrap` first**, because the workflow checks out the repo and runs standard: `script/check` runs `script/test`, `script/lint` and
nothing else, while `script/fmt-check` runs the formatter on the host: on a `script/fmt-check`, then builds the image; `script/precommit` is what the git
pristine checkout with nothing installed the run dies there, after the pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
containerised gates have passed. Four further scripts are our own extensions installs the git pre-commit hook (the `make hooks` target shims to it); and
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
@@ -119,7 +115,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 **Every `docker build` in `script/` is tagged**, here and in `script/check`,
`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.
@@ -143,13 +139,14 @@ 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. Every `docker build` in `script/` therefore passes `--no-cache`: exits 0. `script/lint`, `script/test`, `script/cibuild` and `script/docker`
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the therefore pass `--no-cache`, and a bare `docker build .` is not evidence that
four, and there is no fifth — `script/check` runs the two gate phases and anything ran: a sub-second build reporting success is a cache hit, not a
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is result. The one build that may go through the cache is the image build inside
not evidence that anything ran: a sub-second build reporting success is a `script/check`, which runs immediately after the two gate phases were built
cache hit, not a result. Never invalidate by pruning — `docker builder prune` uncached and so reuses that run. Never invalidate by pruning —
and friends destroy a build cache shared with every other build on the host. `docker builder prune` and friends destroy a build cache shared with every
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
@@ -221,13 +218,10 @@ 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, and checks out the repo as its only other step. runs `script/cibuild` on push. That script runs the gate phases and then the
That script bootstraps, runs the gate phases, and then builds the image, so a image build, so a successful run means every check passed; a bare
successful run means every check passed; a bare `docker build .` does not `docker build .` does not carry the same guarantee, because its gate phases
carry the same guarantee, because its gate phases may come from the cache. The may come from the cache.
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
@@ -358,9 +352,7 @@ 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 \ docker build --no-cache --build-arg VERSION="$version" .
--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
+10 -4
View File
@@ -1,16 +1,22 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check) and then the
# extension to scripts-to-rule-them-all. test and lint are Docker # image build. Our own extension to scripts-to-rule-them-all. test and
# phases; fmt-check is native, because a formatter writes the working # lint are Docker phases; fmt-check is native, because a formatter
# tree. Must not modify any files. # writes the working 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 "$@"
+5 -9
View File
@@ -1,10 +1,7 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. script/check runs the gates and
# checks out and runs this and nothing else, and script/fmt-check runs # builds the image; this repeats the build with the version, which
# the formatter on the host, which a pristine checkout cannot do. # reuses the phases just built rather than re-running them.
# --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)"
@@ -12,8 +9,6 @@ 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
@@ -21,7 +16,8 @@ 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"
docker build --no-cache \ "$SCRIPT_DIR/check"
docker build \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+3 -2
View File
@@ -1,8 +1,9 @@
#!/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 the gate phases the final stage depends on are RUN # --no-cache because this is a standalone entrypoint: the lint and test
# steps, and a cached one is a check that did not run. # phases the final stage depends on must run rather than be served from
# an older build.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"