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
a container)
- `script/fmt-check` — check formatting (read-only; native)
- `script/check` — run all checks: `test`, `lint`, `fmt-check`, then
`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/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension); builds no image
- `script/docker`
`docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
coming from `script/projectname` (byte-identical across repos)
- `script/cibuild` — cd to the repo root, compute `version` from `git describe`,
run `script/check`, then
`docker build --build-arg VERSION="$version" -t prompts .` (what CI runs; the
version is computed on the host because `.dockerignore` excludes `.git`)
- `script/cibuild` — cd to the repo root, run `script/bootstrap`, run
`script/check`, compute `version` from `git describe`, then
`docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI
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/check`
- `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
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.
`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
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
+7 -2
View File
@@ -99,6 +99,10 @@ with your task.
on the host.
- [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
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
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
@@ -151,8 +155,9 @@ with your task.
# Final
- [ ] `make check` passes
- [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
sub-second build, or `CACHED` on a gate layer, means nothing ran
- [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which
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
`docker build .`; revert it afterwards
- [ ] 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)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
native)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`, then
builds the image; must not modify files
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files
- [ ] `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); `--no-cache`, plus the
version as a build arg
- [ ] `script/cibuild` — cd to repo root, run `script/check`, then
`docker build --build-arg VERSION="$version" .` (what CI runs)
- [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run
`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
dangling image behind
- [ ] `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 docker` succeeds
- [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
sub-second build, or `CACHED` on a gate layer, means nothing ran
- [ ] `script/cibuild` succeeds in a fresh clone with nothing installed, which
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
`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.
+28 -20
View File
@@ -60,12 +60,16 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
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
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`, then builds the image; `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
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
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
name. Scripts that need the name call `script/projectname` — e.g.
`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
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
image behind on every invocation, on every developer host and every CI
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
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
exits 0. `script/lint`, `script/test`, `script/cibuild` and `script/docker`
therefore pass `--no-cache`, and a bare `docker build .` is not evidence that
anything ran: a sub-second build reporting success is a cache hit, not a
result. The one build that may go through the cache is the image build inside
`script/check`, which runs immediately after the two gate phases were built
uncached and so reuses that run. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host.
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `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 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`.
- 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
image build, so a successful run means every check passed; a bare
`docker build .` does not carry the same guarantee, because its gate phases
may come from the cache.
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
successful run means every check passed; a bare `docker build .` does not
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
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.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -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
+4 -10
View File
@@ -1,22 +1,16 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check) and then the
# image build. Our own extension to scripts-to-rule-them-all. test and
# lint are Docker phases; fmt-check is native, because a formatter
# writes the working tree. Must not modify any files.
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$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 "$@"
+9 -5
View File
@@ -1,7 +1,10 @@
#!/bin/sh
# script/cibuild: run the CI build. script/check runs the gates and
# builds the image; this repeats the build with the version, which
# reuses the phases just built rather than re-running them.
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,6 +12,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
@@ -16,8 +21,7 @@ main() {
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
"$SCRIPT_DIR/check"
docker build \
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
+2 -3
View File
@@ -1,9 +1,8 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# --no-cache because this is a standalone entrypoint: the lint and test
# phases the final stage depends on must run rather than be served from
# an older build.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"