Gate the build on Docker lint and test phases (closes #40, closes #30)
All checks were successful
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
This commit is contained in:
2026-09-08 04:58:31 +00:00
parent 51df10e1f7
commit ae183d5529
12 changed files with 324 additions and 135 deletions

View File

@@ -1,15 +1,58 @@
# Lint phase. The linter is invoked directly rather than through `make
# lint` or `script/lint`, which are themselves a docker build and would
# recurse into a daemon that does not exist in a build step.
#
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apk here; node
# and yarn are already in the base image, so those steps are skipped).
# Dependency manifests are copied first so the bootstrap layer is
# cached until they change.
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
RUN make check
RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Test phase, same shape and for the same reason.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN echo "No tests defined."
# Development environment, and the last stage: a plain `docker build .`
# names no target and so builds this one. Nothing is wanted from the two
# phases above; the copies are what make BuildKit build them first, so
# this image cannot be produced unless lint and test passed. A stage
# appended after this one would drop all three out of a plain build.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
COPY --from=lint /app/package.json /dev/null
COPY --from=test /app/package.json /dev/null
# script/bootstrap installs all prerequisites. Manifests are copied
# first so that layer stays cached until dependencies change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# The version is computed on the host and passed in, because
# .dockerignore excludes .git.
ARG VERSION=dev
LABEL org.opencontainers.image.version="${VERSION}"

View File

@@ -116,20 +116,24 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test`run the test suite (no tests defined here) - `script/test``docker build --no-cache --target test -t prompts-test .`,
- `script/lint` — lint the markdown files with prettier building the `test` phase of the `Dockerfile` (no tests defined here)
- `script/fmt`format all markdown files with prettier (writes) - `script/lint``docker build --no-cache --target lint -t prompts-lint .`,
- `script/fmt-check` — check formatting (read-only) building the `lint` phase, which runs prettier over the markdown files
- `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` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension); builds no image
- `script/docker` build the Docker image, tagged via `script/projectname` - `script/docker`
(byte-identical across repos); same `--no-cache` and `VERSION` as `docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
`script/cibuild` 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
then `docker build --no-cache --build-arg VERSION="$version" .` (what CI runs; `script/check`, compute `version` from `git describe`, then
the image build runs `script/check`, `--no-cache` is what stops Docker serving `docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI
those checks from cache on an unchanged tree, and the version is computed on runs; it bootstraps because CI checks out and runs this alone while
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

10
TODO.md
View File

@@ -21,6 +21,16 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-09-08: Moved linting and testing into Docker as phases of the main
`Dockerfile`, per the owner ruling on issue 40. `script/lint` and
`script/test` build one phase each by name with `--no-cache` — the same answer
issue 26 got, so no separate cache-busting mechanism survives — and the final
stage copies a harmless file from both, so the image cannot be built unless
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 - 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

View File

@@ -105,14 +105,18 @@ last_modified: 2026-09-08
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. `golangci-lint`. Run it with `make lint`, never by invoking the binary: the
linter runs as a phase of the `Dockerfile` and is not installed on the host
by any repo. Invoked directly on a shared host it reads a result cache keyed
on file content rather than location, and a host-global lock, so its answer
may belong to another checkout entirely.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
linting. `script/cibuild` and `script/docker` should always make sure that linting. It carries the lint and test phases, and the final stage depends on
the code is in an able-to-be-compiled state, linted, and any tests run, and both, so a build makes sure the code is in an able-to-be-compiled state,
the build should fail if linting doesn't pass. Go through those scripts linted, and its tests run. Go through `script/cibuild` or `script/docker`
rather than a bare `docker build .`: they pass `--no-cache`, without which rather than a bare `docker build .`: they pass `--no-cache`, without which
an unchanged tree serves the check layers from cache and the build reports a an unchanged tree serves the gate layers from cache and the build reports a
green it never ran. green it never ran.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. See

View File

@@ -31,10 +31,18 @@ with your task.
so check the entries rather than the file's presence. so check the entries rather than the file's presence.
- [ ] `.editorconfig` exists — fetch from - [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a - [ ] `Dockerfile` and `.dockerignore` exist; the Dockerfile carries a `lint`
build step, and `script/cibuild` and `script/docker` build it with phase and a `test` phase, and the final stage carries a `COPY --from=` of
`--no-cache` — fetch `.dockerignore` from a harmless file from each — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- [ ] Nothing has been appended after the final stage, and the gate phases are
reachable from it. A stage nothing depends on is built only when
`--target` names it, so a lost `COPY --from=` edge leaves `docker build .`
passing while the gate never runs. Confirm by planting a violation, not by
reading the file.
- [ ] The gate phases invoke their tools directly, never through `make lint` or
`script/test` — those are themselves a `docker build` and would recurse
inside a build step
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix, - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix,
only genuinely root-anchored entries such as `.git` are unprefixed, and only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified — the `.gitignore`'s patterns have not been transplanted unmodified — the
@@ -81,6 +89,24 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` and `script/test` build their phase by name
(`docker build --no-cache --target <phase> -t <name>-<phase> .`), and no
host invocation anywhere in the repo can produce a lint verdict — grep for
the linter's own name across `script/`, the `Makefile` and CI config, not
just `script/lint`. A second path is likeliest here: a `make lint-fast`,
an older host-versus-container branch, or a CI step calling the binary
directly. `script/fmt` and `script/fmt-check` are expected hits and stay
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
from it.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 90-second timeout and completes within the 60-second - [ ] `make test` has a 90-second timeout and completes within the 60-second
hard cap (over 20 seconds is green but must be filed as an improvement hard cap (over 20 seconds is green but must be filed as an improvement
@@ -129,6 +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 check 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
`docker build .`; revert it afterwards
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -72,9 +72,13 @@ Template files can be fetched from:
declared in the stage that compiles. **No stage calls `git describe`** declared in the stage that compiles. **No stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without `.dockerignore` excludes `.git`, so it yields an empty version without
failing the build. failing the build.
- All Dockerfiles must run `make check` as a build step - The Dockerfile carries a `lint` phase and a `test` phase, each invoking
- Server: also builds and runs the application its tool directly rather than through `make` or `script/`, and the final
- Non-server: brings up dev environment and runs `make check` stage carries a `COPY --from=` of a harmless file from each so the image
cannot be built unless both passed. Keep the final stage last: a stage
nothing depends on is built only when `--target` names it.
- Server: the final stage builds and runs the application
- Non-server: the final stage brings up the dev environment
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs - [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference `script/cibuild` on push — reference
@@ -100,21 +104,29 @@ are thin shims calling them. Model scripts:
installs installs
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`, - [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test`runs real tests, not a no-op (90-second - [ ] `script/test` / `make test``docker build --no-cache --target test .`,
timeout, 60-second hard cap on wall time) tagged; the phase runs real tests, not a no-op (90-second timeout,
- [ ] `script/lint` / `make lint` — runs linter 60-second hard cap on wall time)
- [ ] `script/fmt` / `make fmt`formats code (writes) - [ ] `script/lint` / `make lint``docker build --no-cache --target lint .`,
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) tagged. No lint verdict may come from a host invocation of the linter.
- [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a
container)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
native)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; 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); passes `--no-cache` `script/projectname` (byte-identical across repos); `--no-cache`, plus the
like `script/cibuild` version as a build arg
- [ ] `script/cibuild` — cd to repo root, `docker build --no-cache .` (what CI - [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run
runs; without `--no-cache` an unchanged tree serves the check layers from `script/check`, then
cache and the build reports a green it never ran) `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` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit` `script/precommit`
@@ -126,8 +138,12 @@ 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 check 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
`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.
- [ ] No secrets in repo, and none in the build context: enumerate a probe image - [ ] No secrets in repo, and none in the build context: enumerate a probe image
rather than reading `.dockerignore` rather than reading `.dockerignore`
- [ ] No mutable image/package references - [ ] No mutable image/package references

View File

@@ -60,17 +60,22 @@ 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 and runs `docker build --no-cache .`; the Gitea workflow calls it. repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
Four further scripts are our own extensions to the standard: `script/check` with the version; the Gitea workflow calls it. **`script/cibuild` runs
runs `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` `script/bootstrap` first**, because the workflow checks out the repo and runs
is what the git pre-commit hook runs, and it calls `script/check`; nothing else, while `script/fmt-check` runs the formatter on the host: on a
`script/install-precommit` installs the git pre-commit hook (the `make hooks` pristine checkout with nothing installed the run dies there, after the
target shims to it); and `script/projectname` (literally that filename) simply containerised gates have passed. Four further scripts are our own extensions
outputs the project's name. Scripts that need the name call to the standard: `script/check` runs `script/test`, `script/lint` and
`script/projectname` — e.g. `script/docker` assembles its image tag from it `script/fmt-check`; `script/precommit` is what the git pre-commit hook runs,
so those scripts stay byte-identical across all repos. Repo-type-specific and it calls `script/check`; `script/install-precommit` installs the git
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in pre-commit hook (the `make hooks` target shims to it); and
`script/precommit`, not in the hook itself. Model scripts are at `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/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -89,97 +94,140 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`, and it carries the repo's gates: a
as a build step so the build fails if the branch is not green. For non-server `lint` phase and a `test` phase, with the final stage depending on both so the
repos, the Dockerfile should bring up a development environment and run image cannot be built unless they pass. For non-server repos the final stage
`make check`. For server repos, `make check` should run as an early build brings up a development environment; for server repos it is the runtime image.
stage before the final image is assembled. Dockerfiles install development Dockerfiles install development prerequisites by running `script/bootstrap`
prerequisites by running `script/bootstrap` rather than duplicating installs rather than duplicating installs inline; COPY `script/` and the dependency
inline; COPY `script/` and the dependency manifests (`package.json` + manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap running it.
layer stays cached until dependencies change.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
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/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.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **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/cibuild` and `script/docker` therefore pass `--no-cache`, and exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
a bare `docker build .` is not evidence that anything ran: a sub-second build `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
reporting success is a cache hit, not a result. Never invalidate by pruning — four, and there is no fifth — `script/check` runs the two gate phases and
`docker builder prune` and friends destroy a build cache shared with every `script/fmt-check`, and builds no image of its own. A bare `docker build .` is
other build on the host. 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.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **The gate phases are separate stages, and the build stage depends on both.**
repos use a multistage build where linting runs in an independent stage based The lint phase is based on the `golangci/golangci-lint` image (pinned by
on the `golangci/golangci-lint` image (pinned by hash). This stage runs hash), so lint failures surface in seconds rather than after a full compile,
`make fmt-check` and `make lint` before the full build begins. The build stage and the test phase is based on the Go image. The canonical Go repo
then declares an explicit dependency on the lint stage via `Dockerfile`:
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile ```dockerfile
# Lint stage — fast feedback on formatting and lint issues # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make fmt-check RUN golangci-lint run --config .golangci.yml ./...
RUN make lint
# Build stage # Test phase
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/ -o /app ./cmd/app/
# Runtime stage # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it - The lint phase uses the `golangci/golangci-lint` image directly (it has
includes both Go and the linter), so there is no need to install the both Go and the linter), so nothing needs installing.
linter separately. - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates purpose is the ordering edge. BuildKit runs stages in parallel by default,
a stage dependency. BuildKit runs stages in parallel by default; without and a stage nothing depends on is not built at all, so without these two
this line, the build stage would not wait for lint to finish and a lint lines a red gate would not fail the build.
failure might not fail the overall build. - Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`. `vips-dev`), install them in the lint phase with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the - `ARG VERSION=dev` is declared in the stage that compiles and supplied by
build stage, not the lint stage, because they may require compiled `script/docker` and `script/cibuild`; no stage may call `git describe`.
artifacts or heavier dependencies.
- 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` (which runs `docker build --no-cache .`) on push. Since runs `script/cibuild` on push, and checks out the repo as its only other step.
the Dockerfile already runs `make check`, a successful build implies all That script bootstraps, runs the gate phases, and then builds the image, so a
checks pass — an implication that holds only because of the `--no-cache` successful run means every check passed; a bare `docker build .` does not
above. 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
@@ -203,15 +251,17 @@ style conventions are in separate documents:
suite that exceeds it fails. Under 20 seconds is the target. A suite between suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an 20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test improvement bug against that repo. Add a 90-second timeout to the test
invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately invocation (`go test -timeout 90s`). The backstop deliberately sits above the
sits above the hard cap so that it catches a genuinely hung test rather than a hard cap so that it catches a genuinely hung test rather than a merely slow
merely slow one. one.
- **`make test` should use the conditional verbose rerun pattern.** Run tests - **The test command should use the conditional verbose rerun pattern.** Run
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to tests without `-v` (verbose) first. If tests fail, automatically rerun with
show full output. This keeps CI logs and `docker build` output clean on `-v` to show full output. This keeps CI logs and `docker build` output clean
success (just package/suite summaries) while providing full diagnostic detail on success (just package/suite summaries) while providing full diagnostic
on failure (every test case, every assertion). The general shell pattern: detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile ```makefile
test: test:
@@ -308,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
@@ -355,8 +407,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), installed commit-pinned via v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
only pin, since no repo installs golangci-lint on the host: bumping the
version means changing it and nothing else.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests

View File

@@ -1,6 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files. # 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 set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,13 +1,19 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. --no-cache because the checks are # script/cibuild: run the CI build. It bootstraps first: a CI runner
# RUN steps: on an unchanged tree Docker serves them from cache and the # checks out and runs this and nothing else, and script/fmt-check runs
# build exits 0 having run nothing. # 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
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
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
@@ -15,7 +21,9 @@ 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 --build-arg VERSION="$version" . docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"

View File

@@ -1,8 +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 for the same reason as script/cibuild: a cached check layer # --no-cache because the gate phases the final stage depends on are RUN
# is a check that did not 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)"

View File

@@ -1,13 +1,23 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Linting markdown files..." docker build --no-cache \
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always --target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
} }
main "$@" main "$@"

View File

@@ -1,12 +1,19 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. # script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "No tests defined." docker build --no-cache \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"