2 Commits

Author SHA1 Message Date
dcf75f6c72 Close three gaps between the containerised-lint rule and its first adopters
All checks were successful
check / check (push) Successful in 22s
The rule landed in 12e8db8 is right; these are the three places where the
canonical text and the repos implementing it can diverge without either
side looking wrong.

1. `.dockerignore` excluding the agent scratch directory is now stated as a
   correctness precondition of containerised linting rather than a
   context-size measure. `Dockerfile.lint` lints whatever `COPY . .` copies,
   and language toolchains discover files by walking the tree instead of
   reading `.gitignore`, so a nested worktree in the context puts the
   foreign-tree false reds back inside the container — in the convincing
   form, where the findings are real but belong to another checkout.
   sneak/quak measured the same discovery mechanism taking a test count
   from 210 to 1050.

2. The cache-bust build arg is fixed at `CHECK_EPOCH` in `Dockerfile.lint`
   as well as in `Dockerfile`. A per-file name is invisible to the grep that
   proves every build is busted, which makes a renamed guard and a missing
   guard read identically. sneak/quak's lint file currently names it
   `LINT_EPOCH`.

3. The formatting check must run in exactly one of the two images, and
   either placement is allowed. Splitting lint out of the `Dockerfile` is
   precisely the moment `fmt-check` gets dropped from both, and running the
   formatter beside the linters is the better shape wherever it is the same
   pinned dependency — it takes the last host toolchain off the checked
   path for the reason the linter came off it.

Both checklists carry the matching items, since a repo that satisfies the
policy prose but not the checklist is the drift this is meant to stop.

Refs #40
2026-08-10 12:59:44 +00:00
12e8db8b0e Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 29s
The linter is no longer installed on the host and no longer invoked
there. script/lint is now `docker build -f Dockerfile.lint .` and
nothing else, with the linter running as a build step, so a successful
build of that file is a clean lint — and it works unchanged where the
docker daemon is remote and bind mounts are impossible.

That removes three host-only failure mechanisms rather than mitigating
them: the result cache keyed on file content rather than location, which
produced a confirmed false green and a string of findings reported
against other checkouts; the host-global $TMPDIR/golangci-lint.lock,
which fails a run with `parallel golangci-lint is running` in a way no
caller can distinguish from findings; and host/container version skew,
which hid thirteen findings on one repo. A container per run has its own
cache, its own lock and a binary pinned by digest.

Resolving the recursion this creates. script/lint is a docker build, so
a Dockerfile that runs `make check` would nest a build inside a build
step where there is no daemon. Fixed by direction, not detection: the
main Dockerfile runs script/test and script/fmt-check individually, with
a comment saying why `make check` must not come back, and script/cibuild
runs script/lint first for fail-fast feedback. script/check still runs
all three, so developers and the pre-commit hook are unaffected.

Dockerfile.lint carries the same CHECK_EPOCH guard as the main image,
with the ARG placed below the dependency layer so only the lint steps
re-run. Blanket --no-cache was rejected: it re-runs the dependency
install on every lint and makes linting network-dependent.

golangci-lint config verify is kept, on measurement rather than
preference. Under the pinned v2.12.2, a bogus top-level key and a bogus
key nested under linters.settings.lll both pass `golangci-lint run` with
exit 0 and `0 issues` while config verify exits 3 and names them; an
unknown linter name fails run and passes config verify. The two catch
disjoint classes, and `run` alone silently ignores the class where a
threshold reads as configured and is not applied. The concern that
config verify fetches its JSON schema over live HTTPS does not hold for
this version: every case reproduced byte-identically under
`docker run --network none`, in a container where `getent hosts
golangci-lint.run` exits 2. The schema is embedded in the pinned binary.

Two canonical forms are superseded and deleted rather than left standing
beside the new one, because consuming repos read these documents
literally and two contradictory canonical script/lint forms is worse
than either. The script/bootstrap golangci-lint install landed for
#28 is removed: nothing invokes
a host linter now, so it can only reintroduce the skew it was written to
close. Its version-enforcement principle — compare version not presence,
re-resolve through PATH after installing, let a mis-parse fall through
to reinstall, and call it — stays documented for any other pinned host
tool. The per-checkout GOLANGCI_LINT_CACHE/TMPDIR wrapper is removed
with it; its entire subject was making a host run trustworthy. Adopting
repos delete .lint-cache/ from .gitignore and .dockerignore too. The Go
multistage lint stage and its COPY --from=lint ordering trick go the
same way: that stage ran `make lint`, which is now a docker build.

Corrected everywhere the claim that a successful docker build implies
lint passed — REPO_POLICIES.md, both repo checklists, the Go styleguide
and the README. The guarantee now belongs to script/cibuild, which runs
both container builds; a bare `docker build .` never lints at all.

Verified in this repo, not only documented: two consecutive script/lint
runs on a byte-identical tree both executed prettier (4.556s and 3.738s,
lint layers DONE with a fresh epoch printed, dependency layers CACHED as
intended); a planted violation failed the build naming the file, and
reverting it went green; a bare `docker build -f Dockerfile.lint .`
failed on the guard; make check, script/docker and script/cibuild all
green with the check layers demonstrably executing; and the main image
build completed without attempting a nested build.
2026-08-10 12:49:34 +00:00
13 changed files with 645 additions and 461 deletions

View File

@@ -1,37 +1,75 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with # Docker matches this file with moby/patternmatcher: Go filepath.Match
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # semantics plus a `**` extension, compiled to a regexp. Plain
# `/` and an unprefixed pattern is anchored at the context root. Every # filepath.Match has no `**` at all. What follows from that: `*` does not
# depth-independent pattern therefore needs `**/`, or `config/.env` and # cross `/`, and a pattern without a leading `**/` is anchored at the
# `certs/server.key` still ship while the file reads as solved. Only # build-context root. Every depth-independent pattern therefore needs the
# genuinely root-anchored entries go unprefixed. Never transplant these # `**/` prefix — without it `config/.env` and `certs/server.key` still
# into .gitignore, where `**/` is wrong. # ship while the file reads as solved.
# #
# Matching is case-sensitive, so secrets use character ranges rather # Root-anchored entries are for paths that occur exactly once, at the
# than an ALL-CAPS twin, which would still miss `Server.Key`. # context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context. In-repo agent scratch is the other case, for the same reason
# — with the caveat recorded at that entry: anchoring is exact only
# where agents run at the repo root, and a repo where they do not must
# add its own entries.
# #
# Extend with this repo's own host-built artifacts, written anchored: # Matching is case-sensitive, so `**/*.key` does not match
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # `certs/SERVER.KEY`, which is reachable on the case-insensitive
# deletes the package directory from the context. # filesystems most laptops use. Adding an ALL-CAPS twin per pattern is
# not the fix: it still misses `Server.Key` while reading as though case
# were handled. Character ranges cover every spelling in one line, so
# every secret name below is written that way — including the
# extensionless SSH keys and `.envrc`, because on those same
# case-insensitive filesystems direnv reads `.ENVRC` and ssh reads
# `ID_RSA`.
#
# `**/*.[eE][nN][vV]` also excludes a committed env template such as
# `example.env`. If the build genuinely needs one, re-include it with a
# negation after the pattern: `!docs/example.env`.
#
# Extend this file with the repo's own host-built artifacts (compiled
# binaries, test binaries, coverage output); those are per-repo and
# belong here because a host build otherwise drops them into the
# context.
# Excluding .git means `git describe` cannot run in any build stage and # Repository metadata: exactly one, at the context root. Excluding it
# fails quietly there; pass the version in with --build-arg VERSION. # means `git describe` cannot run in any build stage, and it fails
# quietly there rather than erroring, so a version embedded that way
# comes out empty. Compute the version on the host and pass it in with
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
.git .git
# Agent scratch: one full checkout of the repo per in-flight agent. # In-repo agent scratch: a directory holding a full additional checkout
# Anchored because it occurs once where agents run at the repo root. # of the repo for each in-flight agent. Anchored because it occurs
# KNOWN GAP: a repo running agents in subdirectories still ships # exactly once *where agents run at the repo root*, which is the
# `services/api/.claude/` and must add its own anchored entry. # convention this file assumes; the `**/` form would also match any
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
.claude .claude
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers both the bare `.env` name (`*` matches
# convention. Re-include a committed template with a negation if the # the empty string) and the `prod.env` convention.
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]
**/.[eE][nN][vV].* **/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC] **/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates # Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. # (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM] **/*.[pP][eE][mM]
**/*.[kK][eE][yY] **/*.[kK][eE][yY]
**/*.[pP]12 **/*.[pP]12
@@ -48,7 +86,8 @@
**/.DS_Store **/.DS_Store
**/Thumbs.db **/Thumbs.db
# Editor state: never a build input, and it churns COPY. # Editor state. Never a build input, and it churns under a developer's
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp **/*.swp
**/*.swo **/*.swo
**/*~ **/*~

View File

@@ -3,26 +3,35 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app WORKDIR /app
# Makes script/lint run the linter directly rather than building # script/bootstrap installs all prerequisites (make via apk here; node
# Dockerfile.lint, which would need a docker daemon here. # and yarn are already in the base image, so those steps are skipped).
ENV LINT_IN_CONTAINER=1 # Dependency manifests are copied first so the bootstrap layer is
# cached until they change.
# script/bootstrap installs all prerequisites. Manifests are copied
# first so that layer stays cached until dependencies 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 . .
# CHECK_EPOCH is a per-invocation nonce from script/cibuild and # CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# script/docker; without it an unchanged tree serves this layer from # script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped, # cache and the build reports a green it never ran. ARG is stage-scoped,
# so declare it in every stage that runs checks. The guard fails a bare # so it must be redeclared in every stage that runs checks. The guard
# `docker build .`, which would otherwise reuse the empty (and therefore # makes a bare `docker build .` fail loudly instead of silently reusing
# stable) cache key. The value is also expanded into the check command, # the empty (and therefore stable) cache key. Expand the value into the
# so the cache miss does not depend on BuildKit's handling of an # command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG; keep both references. # unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
# The individual non-lint checks, NOT `make check`. Lint is deliberately
# absent here: `script/lint` is itself a `docker build` (of
# Dockerfile.lint), so running `make check` in this image would attempt
# a docker build inside a build step, where there is no daemon. Putting
# `make check` back reintroduces exactly that recursion. Lint is not
# skipped — script/cibuild runs script/lint first, in its own container,
# before this build starts.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

View File

@@ -1,27 +1,41 @@
# Lint-only image, built by script/lint when it is not already inside a # Lint-only image. `script/lint` builds this file and nothing else: the
# container. Linting is a build step, so a successful build is a clean # linter runs as a build step, so a successful build IS a clean lint.
# lint, and nothing is bind-mounted, which matters when the daemon is # Building rather than bind-mounting is what makes it work where the
# remote. # docker daemon is remote and bind mounts are impossible.
#
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# This repo's linter is prettier over markdown. A Go repo's version of
# this file differs only in the base image and the two lint commands;
# see the containerised-lint rule in prompts/REPO_POLICIES.md.
# #
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app WORKDIR /app
# Makes script/lint run the linter directly instead of recursing into # Dependency layer first, and deliberately above the ARG below, so it
# another docker build, which has no daemon here. # stays cached and only the lint steps re-run on every invocation.
ENV LINT_IN_CONTAINER=1 # Without that ordering the cache-bust would reinstall dependencies on
# every lint and make linting network-dependent.
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
# ARG sits after the dependency layer so that layer stays cached and # CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# only the lint re-runs. The guard fails a bare `docker build # it an unchanged tree serves the lint layer from cache and the build
# -f Dockerfile.lint .`, which would otherwise reuse the empty (stable) # reports a lint it never ran — a green that proves nothing, which is
# cache key and report a lint it never ran. # the whole failure mode this file exists to avoid reintroducing. The
# guard makes a bare `docker build -f Dockerfile.lint .` fail loudly
# instead of silently reusing the empty (and therefore stable) cache
# key. The value is expanded into the lint command as well, so the cache
# miss does not depend on BuildKit's handling of an unreferenced ARG and
# the epoch is visible in the build log. Keep both references.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint RUN echo "lint epoch: ${CHECK_EPOCH}" && \
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always

View File

@@ -117,21 +117,25 @@ alpine. We provide:
- `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` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files with prettier. Inside a container - `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The
(`LINT_IN_CONTAINER=1`, set by both Dockerfiles) it runs prettier directly; on linter runs in a container, always: it is never installed on the host and
a host it builds `Dockerfile.lint` so the linter still runs in a container never invoked there. Linting happens as a build step, so a successful build is
a clean lint, and the same per-invocation `CHECK_EPOCH` nonce used elsewhere
is what stops Docker serving that lint from cache on an unchanged tree
- `script/fmt` — format all markdown files with prettier (writes) - `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension). Needs a docker daemon, since `script/lint` is a container build
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as (byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild` `script/cibuild`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then - `script/cibuild` — cd to the repo root, run `script/lint` first, then assign
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image `epoch="$(date +%s%N)$$"` and
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what `docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs). Two
stops Docker serving that check from cache on an unchanged tree — a bare container builds: the lint image, then the main image, which runs
`docker build .` fails closed on purpose) `script/test` and `script/fmt-check` but deliberately not `make check` — that
would nest a docker build inside a build step. A bare `docker build .` fails
closed on purpose
- `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

68
TODO.md
View File

@@ -21,27 +21,53 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Moved every lint run into a container. `script/lint` now runs the - 2026-08-10: Closed three gaps the containerised-lint rule left between the
linter directly when `LINT_IN_CONTAINER=1` and otherwise builds canonical text and the first repos to implement it. `.dockerignore` excluding
`Dockerfile.lint`, so the linter never runs on a developer host — closing the the agent scratch directory is now stated as a correctness precondition of
content-keyed result cache that produced a confirmed false green, the that rule rather than a context-size measure: the lint image lints whatever
host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew. `COPY . .` copies, and toolchains discover files by walking the tree instead
Detection is on that marker alone: a false negative inside a container fails of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds
loudly on the missing daemon, while a false positive on a host would silently back inside the container — `sneak/quak` measured the same discovery mechanism
restore host linting, so `/.dockerenv` is rejected outright — measured absent taking a test count from 210 to 1050. The cache-bust arg is fixed at
inside BuildKit `RUN` steps and present on hosts that are themselves `CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is
containers. Everything else keeps its existing shape: `make check` still runs invisible to the grep that proves every build is busted, making a renamed
in the image, `script/cibuild` is still one build, and the Go multistage lint guard indistinguishable from a missing one. And the formatting check is now
stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the required to run in exactly one of the two images, with either placement
same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check`
the lint re-runs. The `script/bootstrap` golangci-lint install and the gets dropped from both, and running the formatter beside the linters is the
per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS better shape where it is the same pinned dependency.
repo's `yarn install` stays, since the rule is about where a verdict comes - 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
from, not about which binaries exist. `golangci-lint config verify` was kept made this repo do it rather than merely document it. `script/lint` is now
on measurement: a bogus config key passes `golangci-lint run` with `0 issues` `docker build -f Dockerfile.lint .` and nothing else; the linter is never
and fails `config verify`, and every case reproduced byte-identically under installed on the host and never invoked there, so a run cannot inherit another
`--network none`, so the schema is embedded and the line costs no network. checkout's content-keyed result cache, the host-global
Comment blocks across the touched files were cut hard in the same pass. `$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned
one — the three mechanisms behind a confirmed false green, a string of
findings reported against other agents' checkouts, and a container that saw
thirteen findings the host missed. Linting runs as a build step, so a
successful build is a clean lint, which also works where the docker daemon is
remote and bind mounts are impossible. The recursion this creates is resolved
by direction rather than by detection: the main `Dockerfile` runs the
individual non-lint checks instead of `make check`, and `script/cibuild` runs
`script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries
the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the
dependency layer so only the lint steps re-run — blanket `--no-cache` was
rejected because it makes every lint reinstall its dependencies over the
network. Two canonical forms were superseded rather than left standing beside
the new one, since consuming repos read this document literally: the
`script/bootstrap` golangci-lint install (nothing runs a host linter now, so
it can only reintroduce skew; the version-enforcement principle stays
documented for other pinned host tools) and the per-checkout
cache/lock/`.lint-cache` wrapper (its whole subject was making a host run
trustworthy). The Go multistage lint stage goes with them: it ran `make lint`,
which is now a docker build. `golangci-lint config verify` was kept on
measurement, not preference — a bogus config key passes `golangci-lint run`
with `0 issues` and fails `config verify`, and every case reproduced
byte-identically under `docker run --network none`, so the schema is embedded
in the pinned binary and the line costs no network. Verified with two
consecutive runs on an unchanged tree both executing the linter, a planted
violation caught and reverted, the bare-build guard firing, and the main image
building without attempting a nested build.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it. - 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two

View File

@@ -111,21 +111,24 @@ last_modified: 2026-08-10
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`. Run it with `make lint`, never by invoking the binary: the `golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`:
linter always runs in a container, and `golangci-lint` is not installed on the linter runs in a container, always, and is never installed on the host.
the host by any repo. Invoked directly on a shared host it reads a result A `golangci-lint` invoked directly on a shared host reads a result cache
cache keyed on file content rather than location, and a host-global lock, so keyed on file content rather than location and a host-global lock, so its
its answer may belong to another checkout entirely. 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. It runs
linting. `script/cibuild` and `script/docker` should always make sure that the non-lint checks; linting lives in `Dockerfile.lint` and is run by
the code is in an able-to-be-compiled state, linted, and any tests run, and `script/cibuild` before the main build, because `script/lint` is itself a
the build should fail if linting doesn't pass. That guarantee holds only `docker build` and cannot run inside one. So `script/cibuild` is what
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that guarantees the code is in an able-to-be-compiled state, linted, and tested —
busts the check layers out of the Docker cache; without it an unchanged tree **a successful `docker build .` on its own does not, because it never
serves those layers from cache and the build reports a green it never ran. A lints.** That guarantee holds only because each build passes a
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
guard — always go through `script/cibuild` or `script/docker`. See the Docker cache; without it an unchanged tree serves those layers from
cache and the build reports a green it never ran. A bare `docker build .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard — always go
through `script/cibuild`, `script/docker` or `script/lint`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form. for the canonical form.

View File

@@ -36,21 +36,31 @@ with your task.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
Dockerfile runs `make check` as a build step, and every stage containing a Dockerfile runs the **non-lint** checks as build steps (`script/test`,
check-running `RUN` declares `ARG CHECK_EPOCH` with the `script/fmt-check`), and every stage containing a check-running `RUN`
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is guard immediately below it — see the `CHECK_EPOCH` rule in
served from cache on an unchanged tree and the build reports a green it `REPO_POLICIES.md`. Without them the check layer is served from cache on
never ran. an unchanged tree and the build reports a green it never ran.
- [ ] **Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`** — the lint - [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint`
stage and the build stage both. This is the item an existing repo most stage or a `COPY --from=lint ... /dev/null` ordering line. This is the
often fails after adopting the containerised lint: without it item an existing repo most often fails: `script/lint` is now a
`script/lint` tries to build `Dockerfile.lint` from inside a build step, `docker build`, so both of those nest a docker build inside a build step.
where there is no daemon. Delete the stage; `script/cibuild` running `script/lint` first is what
- [ ] `Dockerfile.lint` exists and `script/lint` builds it when not already in a replaces its fail-fast purpose.
container — see the containerised-lint rule in `REPO_POLICIES.md`. Base - [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the
image pinned by sha256 with a version/date comment, `ARG CHECK_EPOCH` containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
**after** the dependency layer with the guard below it. base image is pinned by sha256 with a version/date comment, it carries
`ARG CHECK_EPOCH` **after** the dependency layer with the guard below it,
and it invokes the linter directly rather than through `make lint`. The
arg is named `CHECK_EPOCH` in this file too — a repo that calls it
`LINT_EPOCH` here is missed by the grep that checks every build is
cache-busted.
- [ ] The formatting check runs in exactly one of the two images — either
`script/fmt-check` in the `Dockerfile` or the formatter beside the linters
in `Dockerfile.lint`, whichever puts it on the pinned toolchain. Neither
image running it is the failure to look for here, since moving lint out of
the `Dockerfile` is exactly when it gets dropped.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled - [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored — binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
@@ -59,8 +69,11 @@ with your task.
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/` - [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/`
prefix. Agent worktrees are entire checkouts of the repo, so they inflate prefix. Agent worktrees are entire checkouts of the repo, so they inflate
the context by a multiple of it and can copy another session's unreviewed the context by a multiple of it and can copy another session's unreviewed
work into an image layer. Confirm by enumerating the image, not by reading work into an image layer — and `Dockerfile.lint` then lints that checkout
the file — `.gitignore` hides these from `git status` too. as though it were this one, because toolchains discover files by walking
the tree and never read `.gitignore` (`sneak/quak`: 210 discovered tests
became 1050). Confirm by enumerating the image, not by reading the file —
`.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The - [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with canonical anchored entry misses `services/api/.claude/` in a monorepo with
@@ -109,24 +122,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` is the canonical detect-and-branch form, and no host - [ ] `script/lint` is the canonical container build and nothing else. No host
invocation anywhere in the repo can produce a lint **verdict** — grep for linter invocation survives anywhere in the repo — grep for the linter's
the linter's own name across `script/`, the `Makefile` and CI config, not own name in `script/`, the `Makefile` and CI config, not just in
just `script/lint`. A second path is likeliest here: a `make lint-fast`, `script/lint`. An existing repo is where a second path to the linter is
an older container-versus-host branch, or a CI step calling the binary likeliest to exist: a `make lint-fast`, a container-versus-host branch, or
directly. **Expected hits that are not the defect**: `script/fmt`, and in a CI step that calls the binary directly.
a repo whose formatter is also its linter, `script/fmt-check`. Everything - [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install
else the grep finds is a real second path and goes. block, its version and ref variables, and its call site: nothing invokes a
- [ ] Detection is on `LINT_IN_CONTAINER` alone. Reject any `/.dockerenv` or host linter any more, so all it can still do is put a differently
cgroup heuristic: absent in BuildKit `RUN` steps, present on hosts that versioned binary where somebody runs it by hand and believes the result.
are themselves containers, and a false positive lints on the host.
- [ ] `script/bootstrap` installs no golangci-lint. Delete the block, its
version and ref variables, and its call site. A JS repo's `yarn install`
stays — it brings a linter along with every other dependency, which is
fine as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR` - [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`. `.gitignore` and `.dockerignore`. A container has its own cache and its
own lock, so keeping the wrapper leaves two contradictory `script/lint`
forms in the fleet.
- [ ] `script/cibuild` runs `script/lint` before the main `docker build`.
Without that line CI never lints at all, because the main image
deliberately does not.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 30-second timeout - [ ] `make test` has a 30-second timeout
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile - [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
@@ -175,7 +188,7 @@ with your task.
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE` - [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
both times, never `CACHED` and never sub-second both times, never `CACHED` and never sub-second
- [ ] `script/cibuild` succeeds (a bare `docker build .` or - [ ] `script/cibuild` succeeds and runs both container builds (a bare
`docker build -f Dockerfile.lint .` fails closed by design, on the `docker build .` or `docker build -f Dockerfile.lint .` fails closed by
`CHECK_EPOCH` guard) design, on the `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -73,27 +73,36 @@ Template files can be fetched from:
declared in the stage that compiles, and **no stage calls `git describe`** declared in the stage that compiles, and **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, and every stage - The `Dockerfile` runs the **non-lint** checks as build steps —
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the `script/test` and `script/fmt-check`, never `make check`. `script/lint` is
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the a `docker build` of `Dockerfile.lint`, so `make check` here nests a build
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is inside a build step, where there is no daemon. Put a comment above those
served from cache on an unchanged tree and the build reports a green it `RUN` lines saying so. Every stage containing a check-running `RUN` must
never ran. declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
- Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`, so guard immediately below it — see the `CHECK_EPOCH` rule in
`script/lint` runs the linter natively instead of trying to build `REPO_POLICIES.md`. Without them the check layer is served from cache on
`Dockerfile.lint` where there is no daemon. an unchanged tree and the build reports a green it never ran.
- Go repos: separate `lint` stage on the `golangci/golangci-lint` image,
with `COPY --from=lint /src/go.sum /dev/null` in the build stage to force
the ordering. Re-prove that ordering warm after adopting `CHECK_EPOCH`.
- Server: also builds and runs the application - Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check` - Non-server: brings up dev environment and runs those checks
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image `script/lint` builds when it is - [ ] `Dockerfile.lint` — the lint-only image that `script/lint` builds. Same
not already inside a container. Sets `ENV LINT_IN_CONTAINER=1`; same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the `ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` **after** the dependency layer so only the lint re-runs. Base image `ARG` placed **after** the dependency layer so only the lint steps re-run.
pinned by sha256 with a version/date comment. Copy from Base image pinned by sha256 with a version/date comment. Go repos use
`REPO_POLICIES.md`. `golangci/golangci-lint` and run both `golangci-lint config verify` and
`golangci-lint run`; other repos use the same pattern around their own
linter (eslint, ruff, prettier). Copy the canonical file from
`REPO_POLICIES.md`. The linter is invoked directly there, never via
`make lint`, which would recurse. The arg keeps the name `CHECK_EPOCH` in
this file as well, so one grep covers both builds.
- The formatting check runs in exactly one of the two images: either
`script/fmt-check` in the `Dockerfile`, or the formatter beside the
linters in `Dockerfile.lint` where that is the same pinned dependency.
Never neither, never both.
- `.dockerignore` must exclude the agent scratch directory before this image
is trusted: it lints whatever is in the build context, and toolchains walk
the tree rather than reading `.gitignore`, so an agent worktree that
reaches the context is linted as though it were the repo.
- [ ] 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
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -120,26 +129,26 @@ are thin shims calling them. Model scripts:
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 (30-second - [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout) timeout)
- [ ] `script/lint` / `make lint`runs the linter directly when - [ ] `script/lint` / `make lint`builds `Dockerfile.lint` and nothing else:
`LINT_IN_CONTAINER=1`, otherwise `epoch="$(date +%s%N)$$"` on its own line `epoch="$(date +%s%N)$$"` on its own line, then
then `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The
No lint verdict may come from a host invocation. Copy from linter is never installed on the host and never invoked there. Copy the
`REPO_POLICIES.md`. Detect on `LINT_IN_CONTAINER` only — never canonical script from `REPO_POLICIES.md`; it is byte-identical across
`/.dockerenv`, which is absent in BuildKit `RUN` steps and present on repos. Without the nonce this script exits 0 on an unchanged tree having
hosts that are themselves containers. Without the nonce this exits 0 on an linted nothing.
unchanged tree having linted nothing; without `-f Dockerfile.lint` it
builds the main image and lints nothing at all.
- [ ] `script/fmt` / `make fmt` — formats code (writes) - [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `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. It needs a docker daemon, because `script/lint` is a
container build, and it must never be called from inside a build stage
- [ ] `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); carries the same three `script/projectname` (byte-identical across repos); carries the same three
version lines as `script/cibuild` below, and passes version lines as `script/cibuild` below, and passes
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"` `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, then, each on its own line: - [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for
fail-fast feedback, then, each on its own line:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
@@ -151,17 +160,17 @@ are thin shims calling them. Model scripts:
. .
``` ```
(what CI runs). Both build args are mandatory, and both assignments must be (what CI runs). The `script/lint` call is not optional: the main image does
on their own line: a failing command substitution inside an argument does not lint, so without it CI never lints. Both build args are mandatory, and
not trip `set -e`, so the inline form degrades silently to an empty both assignments must be on their own line: a failing command substitution
constant. The `[ -n "$version" ]` line is a live check that fires on an inside an argument does not trip `set -e`, so the inline form degrades
export with no `.git` and on a repo with no commits — keep it, and do not silently to an empty constant. The `[ -n "$version" ]` line is a live check
collapse it into `|| echo unknown`, which makes it unreachable. See the that fires on an export with no `.git` and on a repo with no commits — keep
`CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each it, and do not collapse it into `|| echo unknown`, which makes it
element is load-bearing. A bare `docker build .` fails closed by design, and unreachable. See the `CHECK_EPOCH` and git-describe rules in
so does a bare `docker build -f Dockerfile.lint .`. The image runs `REPO_POLICIES.md` for why each element is load-bearing. A bare
`make check`, which includes lint, so `script/cibuild` needs no separate `docker build .` fails closed by design, and so does a bare
lint step. `docker build -f Dockerfile.lint .`.
- [ ] `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
@@ -177,7 +186,7 @@ are thin shims calling them. Model scripts:
build: run it twice on an unchanged tree and confirm the lint layer says build: run it twice on an unchanged tree and confirm the lint layer says
`DONE`, never `CACHED`, both times `DONE`, never `CACHED`, both times
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds and demonstrably executes - [ ] `script/cibuild` succeeds and runs both container builds
- [ ] No secrets in repo - [ ] No secrets in repo
- [ ] No mutable image/package references - [ ] No mutable image/package references
- [ ] No unnecessary files in repo root - [ ] No unnecessary files in repo root

View File

@@ -60,7 +60,8 @@ 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 repo root, runs `script/lint` first for fail-fast feedback (that is itself a
container build — see the containerised-lint rule below), and then runs
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
`version` is computed on the host because `.git` is not in the build context `version` is computed on the host because `.git` is not in the build context
@@ -93,22 +94,29 @@ 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`. It must run the repo's checks as build
as a build step so the build fails if the branch is not green — which requires steps so the build fails if the branch is not green — which requires
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running `ARG CHECK_EPOCH` and its guard in every stage containing a check-running
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies `RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies
this criterion while its check layers are served from cache, so the build this criterion while its check layers are served from cache, so the build
cannot fail on a branch that is not green. cannot fail on a branch that is not green.
**Every Dockerfile must also set `ENV LINT_IN_CONTAINER=1`**, above the **It runs the individual non-lint checks — `script/test` and
checks. `script/lint` builds `Dockerfile.lint` when it is not already in a `script/fmt-check` — and never `make check`.** `script/lint` is itself a
container; without the marker it would try that from inside a build step, `docker build` (of `Dockerfile.lint`, per the containerised-lint rule
where there is no daemon. See the containerised-lint rule below. below), so a `RUN make check` in this file attempts a docker build inside a
build step, where there is no daemon. Lint is not skipped by this: it runs
in its own container, and `script/cibuild` runs it first. Put a comment to
that effect directly above those `RUN` lines, because `make check` is what
the next person will reach for. Of the two, only `script/test` is fixed
here: a repo may run its formatter in `Dockerfile.lint` beside the linters
instead, and some should — see the containerised-lint rule below. It must
then run in that file and not in this one, and never in neither.
For non-server repos, the Dockerfile should bring up a development For non-server repos, the Dockerfile should bring up a development
environment and run `make check`. For server repos, `make check` should run environment and run those checks. For server repos, they should run as an
as an early build stage before the final image is assembled. Dockerfiles early build stage before the final image is assembled. Dockerfiles install
install development prerequisites by running `script/bootstrap` rather than development prerequisites by running `script/bootstrap` rather than
duplicating installs inline; COPY `script/` and the dependency manifests duplicating installs inline; COPY `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it
so the bootstrap layer stays cached until dependencies change. so the bootstrap layer stays cached until dependencies change.
@@ -118,18 +126,19 @@ style conventions are in separate documents:
unchanged tree the check layer is served from cache, the suite never runs, and unchanged tree the check layer is served from cache, the suite never runs, and
the build still exits 0. A sub-second `docker build` reporting success is a the build still exits 0. A sub-second `docker build` reporting success is a
cache hit, not a result. This applies to **every** file that runs checks in a cache hit, not a result. This applies to **every** file that runs checks in a
build step`Dockerfile` and `Dockerfile.lint` alike; a `Dockerfile.lint` build step, which since linting moved into its own container means
without the cache-bust is a lint that never ran, reported as a pass. The `Dockerfile` and `Dockerfile.lint` both — a `Dockerfile.lint` without the
canonical form, in **every** stage containing a check-running `RUN`, placed cache-bust is a lint that never ran, reported as a pass. The canonical form,
**after** the dependency-install layer so that layer stays cached: in **every** stage containing a check-running `RUN`, placed **after** the
dependency-install layer so that layer stays cached:
```dockerfile ```dockerfile
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command>
``` ```
and in both `script/cibuild` and `script/docker`: and in `script/lint`, `script/cibuild` and `script/docker`:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
@@ -141,15 +150,12 @@ style conventions are in separate documents:
. .
``` ```
`script/lint` needs the same nonce but is **not** this command: it builds a
different file with `-f Dockerfile.lint` and passes no version. Copy its
form from the containerised-lint rule below, not this block — a
`docker build` with no `-f` builds the main image and lints nothing.
The `VERSION` lines are there for a different reason, covered by the The `VERSION` lines are there for a different reason, covered by the
git-describe rule below; they are shown here so the two rules do not each git-describe rule below; they are shown here so the two rules do not each
document half a command. All four `CHECK_EPOCH` elements are load-bearing; document half a command. `script/lint` passes only `CHECK_EPOCH`, since no
none is optional, and each guards a failure mode that otherwise fails green: version is embedded in a lint image. All four `CHECK_EPOCH` elements are
load-bearing; none is optional, and each guards a failure mode that
otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check - `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`. that runs checks, immediately above the first such `RUN`.
@@ -176,65 +182,107 @@ style conventions are in separate documents:
concurrent invocations would collide. concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving This invalidates the check layers and everything after them while leaving
`go mod download` and `script/bootstrap` cached, so it does not push against `go mod download`, `script/bootstrap`, and the pinned toolchain install
the five-minute Docker build ceiling. Blanket `--no-cache` is not an cached, so it does not push against the five-minute Docker build ceiling.
acceptable substitute: it also busts the dependency layer, so every run Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or
reinstalls dependencies over the network instead of only the first and those on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every
after a manifest change. Never reach for `docker builder prune` — the build invocation, which makes linting network-dependent and pushes a lint that
cache is shared with every other build on the host. should take seconds toward the build ceiling. Never reach for
`docker builder prune` to achieve the same end — the build cache is shared
with every other build on the host, including other people's.
- **Every lint run happens in a container.** `script/lint` runs the linter - **Every lint run happens in a container, and `script/lint` is that container
directly when it is already inside one, and otherwise builds `Dockerfile.lint` build.** The linter is never installed on the host and never invoked there.
so that it is. Either way the linter never runs on a developer host, where its Every repo carries a `Dockerfile.lint` next to its `Dockerfile`; the linter
answer is not trustworthy: runs as a **build step**, so a successful build _is_ a clean lint. Building
- **Confirmed false green.** golangci-lint keys cached results on file rather than bind-mounting is deliberate: it is what makes the pattern work
**content, not location**, so a second checkout of the same commit serves unchanged where the docker daemon is remote and bind mounts are impossible.
its findings. One implementer reported `0 issues` on a branch genuinely Docker is assumed available in every environment. Discarding the linter's
red with a `goconst` finding. Own-clones-instead-of-worktrees does not cache on every run is the point of this rule, not a cost it pays.
help; two clones are byte-identical exactly as two worktrees were.
- **False reds**: findings reported against other checkouts and against
worktrees already deleted; in one case 399 issues returned to a clean
clone that genuinely lints 0.
- **Lock contention indistinguishable from findings.** golangci-lint flocks
`$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`, `acquireFileLock()`),
host-global and independent of `GOLANGCI_LINT_CACHE`, 5-second timeout. It
prints `parallel golangci-lint is running`, analyzes nothing, exits
non-zero. Not fixed by per-cache isolation — measured.
- **Version skew**: a host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed.
A container has its own cache, its own `TMPDIR` and a binary pinned by This closes a family of defects, every one of them an artefact of running
digest, so none of it is reachable. This supersedes the per-checkout the linter on a shared host, and every one of them observed rather than
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper, which existed only to make a host hypothesised:
run trustworthy; delete it on adoption. - **A confirmed false green.** An implementer reported `0 issues` on a
branch that was genuinely red with a `goconst` finding. golangci-lint keys
cached results on file **content, not location**, so a second checkout of
the same commit holds byte-identical files and serves its result. Note
what content-keying implies: moving agents from worktrees into their own
clones does **not** help, because two clones are byte-identical exactly as
two worktrees were. It removes the foreign-path symptom and leaves the
mechanism live, which makes the defect quieter rather than rarer.
- **False reds**, repeatedly: findings reported against `../wt82-lint/...`,
against another agent's checkout, and against a worktree that had already
been deleted; in one case 399 issues returned to a clean clone that
genuinely lints 0.
- **Lock contention that cannot be distinguished from findings.**
golangci-lint flocks `$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`,
`acquireFileLock()`) — host-global, keyed on the temp directory, entirely
independent of `GOLANGCI_LINT_CACHE`, with a 5-second acquire timeout, so
it fails precisely when the host is busiest. On failure it prints
`parallel golangci-lint is running`, analyzes nothing, and exits non-zero.
**Proven not fixed by per-cache isolation**: two concurrent runs with
entirely separate cache directories still collided.
- **Version skew.** A host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed on one repo, and a
local `make check` green against a `make docker` that rejected the same
commit with six `goconst` findings.
The canonical `script/lint`, whose executable lines are the same in every A container per run has its own cache, its own `TMPDIR` and therefore its
repo apart from the native lint command: own lock, and a binary pinned by digest, so none of the above is reachable.
That is also why the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper
that used to be canonical here is **gone rather than kept alongside this**:
its entire subject was making a host run trustworthy, and there are no host
runs. Consuming repos delete it when they adopt this; see the adoption list
at the end of this rule.
The canonical `Dockerfile.lint` for a Go repo:
```dockerfile
# Lint-only image. `script/lint` builds this file and nothing else: the
# linter runs as a build step, so a successful build IS a clean lint.
#
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
golangci-lint config verify --config .golangci.yml
RUN golangci-lint run --config .golangci.yml ./...
```
and the canonical `script/lint`, identical in every repo:
```sh ```sh
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Inside a container, run it directly; on a # script/lint: run the linter. The linter is never installed on the host
# host, build Dockerfile.lint so it runs in one anyway. # and never invoked there — it runs in a container, one way, everywhere,
# # so a run cannot inherit another checkout's cache, another process's
# LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY # lock, or a host toolchain that differs from the pinned one.
# accepted signal. Do not add a /.dockerenv fallback: it is absent inside
# BuildKit RUN steps and present on hosts that are themselves containers,
# so it both misses and false-positives — and a false positive silently
# restores host linting.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then # not trip `set -e`, and `$$` is required because busybox `date`
exec golangci-lint run --config .golangci.yml ./... # drops %N without erroring. Without a fresh nonce the lint layer is
fi # served from cache and this script exits 0 having linted nothing.
# Own line, and `$$` because busybox `date` drops %N silently.
# Without a fresh nonce the lint layer is cached and this exits 0
# having linted nothing.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build \ docker build \
--build-arg CHECK_EPOCH="$epoch" \ --build-arg CHECK_EPOCH="$epoch" \
@@ -245,137 +293,123 @@ style conventions are in separate documents:
main "$@" main "$@"
``` ```
and `Dockerfile.lint`, the standalone path for a developer host:
```dockerfile
# Lint-only image, built by script/lint when not already in a container.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# ARG after the dependency layer so only the lint re-runs.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
golangci-lint config verify --config .golangci.yml
RUN make lint
```
Load-bearing properties: Load-bearing properties:
- **Detection rests on `LINT_IN_CONTAINER=1` and nothing else.** Every
Dockerfile in the repo sets it; a host does not. The asymmetry is the
whole design: a **false negative** inside a container tries a nested
`docker build`, finds no daemon and fails loudly, while a **false
positive** on a host silently lints there — the exact defect this rule
exists to kill. So the signal must be one only our own images can produce.
`/.dockerenv` is not such a signal and must not be used, even as a
fallback: measured, it is **absent** inside BuildKit `RUN` steps and
**present** on any host that is itself a container, which is the common
case for CI runners and agent sandboxes. It fails in both directions, and
one of them is the dangerous one.
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .` - **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted on an unchanged tree returns a sub-second cached success having linted
nothing. The `ARG` goes **after** the dependency layer so only the lint nothing — the same false green the `CHECK_EPOCH` rule above exists to
re-runs; `--no-cache` would also reinstall dependencies on every lint. close, arriving through a new file. The `ARG` goes **after** the
dependency layer so `go mod download` / `yarn install` stay cached and
only the lint steps re-run. Blanket `--no-cache` also busts the dependency
layer, which makes every lint network-dependent.
- **Non-Go repos get the same pattern around their own linter** — `eslint`, - **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck`. Only the base image and the native lint `ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
command change. not every Go lint run. Only the base image and the lint commands change;
the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
node image, runs `script/bootstrap` as the dependency layer, and lints
with the linter from `node_modules`, which is also how it gets the version
pinned in `package.json` rather than whatever is on the host.
- **The lint container lints whatever is in the build context, so
`.dockerignore` is part of this rule and not merely hygiene.** `COPY . .`
copies an agent scratch worktree — an entire second checkout of the repo —
into the lint image unless `.dockerignore` excludes it, and language
toolchains discover files by walking the tree rather than by reading
`.gitignore`, so `./...`, `eslint .` and `prettier --check .` all descend
into it. `sneak/quak` measured this on the same discovery mechanism in its
test runner: a nested `.claude/` worktree took the discovered test count
from 210 to 1050 (https://git.eeqj.de/sneak/quak/issues/30). Left in the
context it re-creates _inside_ the container the foreign-tree false reds
that moving lint into a container was adopted to end, and it does so in
the convincing form — the findings are real, they simply belong to another
checkout. See the `.dockerignore` rules below, and verify by enumerating
the image rather than by reading the patterns.
- **The build arg is named `CHECK_EPOCH` in `Dockerfile.lint` too**, not
`LINT_EPOCH` or any other per-file name, and `script/lint` passes it under
that name. Both files guard the same failure under the same contract, and
the single name is what lets a reviewer grep a repo for `CHECK_EPOCH` and
see every cache-bust it has. Rename it in one file and that grep silently
misses it, so a renamed guard and an absent guard read identically without
opening both Dockerfiles.
- **The formatting check runs in exactly one of the two images, and either
one is allowed.** The canonical `Dockerfile` above runs `script/fmt-check`
because that is where the non-lint checks live. A repo may instead run its
formatter in `Dockerfile.lint` beside the linters, which is the better
shape wherever the formatter is the same pinned dependency as the linter
(`prettier` out of `node_modules`, say), because it takes the last host
toolchain off the checked path for the same reason the linter came off it.
What is not allowed is running it in neither image, or in both. Whichever
image runs it carries the epoch guard, and `script/check` still runs all
three targets on the developer's side either way.
- **Keep `golangci-lint config verify`, and it costs no network.** The two - **Keep `golangci-lint config verify`, and it costs no network.** The two
commands catch disjoint classes, measured under the pinned v2.12.2: a commands catch **disjoint** classes of defect, measured under the pinned
bogus top-level key and a bogus key under `linters.settings.lll` both pass v2.12.2 against a config carrying one planted defect at a time: a bogus
`golangci-lint run` with **exit 0 and `0 issues`** while `config verify` top-level key and a bogus key nested under `linters.settings.lll` both
exits 3 and names them; an invalid value type fails both; an unknown pass `golangci-lint run` with **exit 0 and `0 issues`** while
linter name fails `run` and passes `config verify`. So `run` alone `config verify` exits 3 and names the key; an invalid value type fails
silently ignores an unknown key — the mode where a threshold reads as both; an unknown linter name fails `run` and passes `config verify`. So
configured and is not applied. It needs no network: every case reproduced `run` alone silently ignores an unknown key, which is exactly the mode
byte-identically under `docker run --network none`, in a container where where a threshold reads as configured and is not applied. The earlier
caution that `config verify` resolves its JSON schema over a live HTTPS
fetch does **not** hold for this pinned version: every case above was
re-run under `docker run --network none` and produced byte-identical
diagnostics and exit statuses, in a container where
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the `getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin. pinned binary. Re-run that control when bumping the pin rather than
- **A failed `script/lint` that names no finding is not a lint result.** On treating the result as permanent.
the host path `docker build` exits 1 both for findings and for a build - **No repo installs a linter on the host, in `script/bootstrap` or anywhere
that never got there (daemon down, image unpullable, disk full). BuildKit else.** A host install is now dead weight whose only remaining effect is
names the failing step; read it, fix the environment, re-run. Do not to reintroduce the version skew above.
record a verdict from a run that did not lint. - **`script/check` still runs `test`, `lint` and `fmt-check`**, so a
developer and the pre-commit hook get all three. It therefore requires a
docker daemon, and it must never be invoked from inside a build stage —
see the `Dockerfile` rule above.
- If the project uses `//go:embed` directives referencing build artifacts
(e.g. a web frontend compiled elsewhere), `Dockerfile.lint` must create
placeholder files so the directives resolve:
`RUN mkdir -p web/dist && touch web/dist/index.html`. It must not depend
on the real build output; it exists to fail fast.
- If linting requires CGO or system libraries (e.g. `vips-dev`), install
them in `Dockerfile.lint`.
**Scope: this rule is about linters, and a formatter is not one.** **What a consuming repo does to adopt this**, in order: add
`script/fmt` writes your working tree, so it can only run on the host, and `Dockerfile.lint`; replace `script/lint` with the build above; delete the
`script/fmt-check` is its read-only twin. In a repo whose formatter **is** `lint` stage from its `Dockerfile` along with the
its linter (prettier over markdown; this repo), `script/bootstrap` therefore `COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
installs the linter on the host as an ordinary dependency and `RUN make check` to `script/test` and `script/fmt-check` with the comment
`script/fmt-check` runs it there. That is accepted: the version is pinned in explaining why; add `script/lint` as the first step of `script/cibuild`;
`package.json` and installed into the repo's own `node_modules`, so there is delete any golangci-lint install from `script/bootstrap`; and delete the
no shared content-keyed cache, no host-global lock and nothing to skew `.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
against. What is forbidden is taking a **lint verdict** from it — the per-checkout cache/lock wrapper they served.
`script/lint` stays the only source of one. A repo auditing itself will see
those hits and should leave them; anything else the grep finds is a real
second path to the linter and goes.
**What a consuming repo does to adopt this**, in order: **The separate lint _stage_ is superseded by this and must not survive
1. Add `Dockerfile.lint`. alongside it.** It ran `make lint`, which is now a docker build, so keeping
2. Replace `script/lint` with the form above, with its own native lint it is not a stylistic preference but a recursion. Its purpose — fail-fast
command. feedback before the slow build — is served by `script/cibuild` running
3. Add `ENV LINT_IN_CONTAINER=1` to **every** stage of every Dockerfile that `script/lint` first, and its `COPY --from=lint /src/go.sum /dev/null`
runs checks — the lint stage and the build stage both. ordering trick, along with the warm-cache re-proof that trick required, is
4. Delete any golangci-lint install from `script/bootstrap`, with its no longer needed because the ordering is now sequential in the shell.
version and ref variables and its call site. Nothing on the host lints,
so it can only reintroduce version skew. A JS repo's `yarn install`
stays.
5. Delete the per-checkout lint state: `GOLANGCI_LINT_CACHE` and `TMPDIR`
exports, `--allow-serial-runners`, the retry/VOID wrapper, and
`.lint-cache/` from both `.gitignore` and `.dockerignore`.
6. Verify by running `make lint` twice on an unchanged tree: the lint layer
must be `DONE` both times, never `CACHED`. Then plant a violation,
confirm it fails naming the finding, revert. A bare
`docker build -f Dockerfile.lint .` must fail on the guard.
`script/check`, `script/cibuild`, `script/docker` and the `Dockerfile` are - **The canonical Go repo `Dockerfile`**, which builds and tests but does not
unchanged by this: `make check` still runs inside the image, and lint:
`script/lint` there takes the native path.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash), so lint failures
surface in seconds rather than after a full compile. The build stage declares
an explicit dependency on it via `COPY --from=lint /src/go.sum /dev/null`,
which forces BuildKit — which runs stages in parallel by default — to finish
linting first. The canonical Go repo `Dockerfile`:
```dockerfile ```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
WORKDIR /src WORKDIR /src
ENV LINT_IN_CONTAINER=1
# 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 . .
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# The individual non-lint checks, NOT `make check`: script/lint is a
# docker build (Dockerfile.lint), so `make check` here would nest a
# build inside a build step, where there is no daemon. Lint is not
# skipped — script/cibuild runs it first, in its own container.
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make test
# VERSION comes from the host via --build-arg; see the git-describe rule # VERSION comes from the host via --build-arg; see the git-describe rule
# below. Never run `git describe` here: .dockerignore excludes .git, so # below. Never run `git describe` here: .dockerignore excludes .git, so
@@ -392,48 +426,40 @@ style conventions are in separate documents:
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it has - Tests run in the build stage because they may require compiled artifacts
both Go and the linter), so nothing needs installing. `make lint` there or heavier dependencies.
runs `script/lint`, which sees `LINT_IN_CONTAINER=1` and invokes - `ARG CHECK_EPOCH` must be declared in **every** stage containing a
`golangci-lint` natively instead of building `Dockerfile.lint`. Without check-running `RUN`, because `ARG` is stage-scoped: declaring it in one
that `ENV` the stage would attempt a nested build and fail. stage leaves the others frozen at their last cached result while the fix
- `COPY --from=lint /src/go.sum /dev/null` is a no-op copy that exists only reviews as complete. In each such stage the guard sits immediately below
to create the stage dependency; without it a lint failure might not fail the `ARG`, and the value is expanded into the first check `RUN` so the
the overall build. cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.** lines reference `$CHECK_EPOCH`, so each stage has two independent
The cache-bust turns the no-op `COPY` into a content-cache hit, so an invalidation points. Later `RUN`s in the same stage need no expansion of
ordering guarantee established cold does not automatically carry over. It their own: their parent layer is already busted.
was re-proved in another org repo using the same trick and held, but that - `ARG VERSION=dev` is declared in the build stage, and its value is
result does not transfer by assumption — re-check it warm. supplied on the host by `script/docker` and `script/cibuild` via
- If the project uses `//go:embed` referencing build artifacts, the lint `--build-arg VERSION=...`. The `dev` default is a placeholder for a local
stage must create placeholders so the directives resolve: build, not a source of truth. **No stage may call `git describe`**:
`RUN mkdir -p web/dist && touch web/dist/index.html`. `.dockerignore` excludes `.git`, so it yields an empty version without
- If linting needs CGO or system libraries (e.g. `vips-dev`), `apk add` them failing. See the git-describe rule further down.
in the lint stage.
- Tests run in the build stage, not the lint stage: they may need compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at its last cached result. In each stage the guard sits immediately
below the `ARG` and the value is expanded into the first check `RUN`.
Later `RUN`s in the same stage need no expansion; their parent layer is
already busted.
- `ARG VERSION=dev` is declared in the build stage and supplied by
`script/docker` and `script/cibuild`. **No stage may call
`git describe`**: `.dockerignore` excludes `.git`, so it yields an empty
version without failing. See the git-describe rule further down.
- 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 runs `script/cibuild` on push. `script/cibuild` runs **two** container builds:
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) `script/lint` (`Dockerfile.lint`) first, then
on push. The Dockerfile runs `make check`, so a successful build implies all `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
checks pass — but that implication holds **only** because of the `CHECK_EPOCH` for the main image, which runs the non-lint checks. A successful
cache-bust described above. Without it, an unchanged tree serves the check `script/cibuild` therefore implies all checks pass; **a successful
layer from cache and the build reports a green it never earned. A bare `docker build .` on its own does not, because it never lints.** That is the
`docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; one claim to be careful with when reading these files: the guarantee belongs
always go through `script/cibuild` or `script/docker`. Never accept a pass as to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the because each build passes its own `CHECK_EPOCH` nonce — without it an
check layer, means nothing was executed. unchanged tree serves the layers from cache and the build reports a green it
never earned. A bare `docker build .` or `docker build -f Dockerfile.lint .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go
through `script/cibuild`, `script/docker` or `script/lint`. Never accept a
pass as evidence without confirming it ran: a sub-second wall time, or
`CACHED` on a check or lint layer, means nothing was executed.
- 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
@@ -545,14 +571,22 @@ style conventions are in separate documents:
inflates by a multiple of the repo, and another session's unreviewed, inflates by a multiple of the repo, and another session's unreviewed,
sometimes uncommitted work can be copied into an image layer. The directory is sometimes uncommitted work can be copied into an image layer. The directory is
also created and destroyed constantly, so it invalidates `COPY . .` for also created and destroyed constantly, so it invalidates `COPY . .` for
reasons that have nothing to do with the repo's own content. In `.gitignore` reasons that have nothing to do with the repo's own content. **And because
the entry is `.claude/`, unanchored, which already matches at every depth. In `Dockerfile.lint` and `Dockerfile` run their tooling over the copied context,
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the a worktree that reaches it is linted and tested as though it were the repo.**
directory occurs exactly once **where agents run at the repo root**, and the Nothing else stops that: language toolchains discover files by walking the
prefixed form would also match any nested directory of that name and delete it tree and do not read `.gitignore`, which is how `sneak/quak` saw a nested
from the build. It is not case-folded the way the secret patterns are, because `.claude/` worktree take its discovered test count from 210 to 1050
tooling creates it in exactly one spelling, so a folded pattern would add no (https://git.eeqj.de/sneak/quak/issues/30). This entry is therefore a
coverage. correctness precondition of the containerised-lint rule above and not a size
optimisation — without it the foreign-tree false reds that rule exists to end
simply move inside the container. In `.gitignore` the entry is `.claude/`,
unanchored, which already matches at every depth. In `.dockerignore` it is
`.claude`, anchored and with **no** `**/` prefix: the directory occurs exactly
once **where agents run at the repo root**, and the prefixed form would also
match any nested directory of that name and delete it from the build. It is
not case-folded the way the secret patterns are, because tooling creates it in
exactly one spelling, so a folded pattern would add no coverage.
**Known gap that comes with the anchored form.** The directory is created in **Known gap that comes with the anchored form.** The directory is created in
the agent's working directory, so the "exactly once, at the root" premise is the agent's working directory, so the "exactly once, at the root" premise is
@@ -660,21 +694,23 @@ style conventions are in separate documents:
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports which reports
`golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That `golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That
digest is the only pin: golangci-lint is not installed on the host by any digest is the only pin there is: the linter is not installed on the host, in
repo. Bumping the version means changing that one digest. `script/bootstrap` or anywhere else. Bumping the version means changing that
one digest, and it propagates to every consumer of the image with no host
state able to disagree with it.
- **`script/bootstrap` must not install golangci-lint.** This supersedes the - **`script/bootstrap` must not install a linter at all.** This supersedes the
pinned host install that used to be canonical here. `script/lint` never runs pinned-golangci-lint install that used to be canonical here. Nothing runs a
it on the host — it either builds `Dockerfile.lint` or is already in a linter on the host any more — `script/lint` is a container build — so a host
container that ships the binary — so a host install has no caller, and its install has no caller left, and its only remaining effect is to put a second,
only remaining effect is to put a second, independently-versioned linter where independently-versioned linter on the machine where somebody will eventually
somebody eventually runs it by hand and believes the result. Delete the block, run it by hand and believe the result. The version-skew failures that install
its version and ref variables, and its call site. was written to close (a local `make check` green while `make docker` rejected
the same commit with six `goconst` findings; a container linter surfacing
This is not a ban on host dependency installs generally. A JS or docs repo's thirteen findings the host run missed) are closed more completely by having
`script/bootstrap` runs `yarn install`, which brings its linter along with exactly one linter, pinned by image digest, that no host state can shadow.
every other dependency; that is unavoidable and fine. The rule is about a Repos adopting the containerised lint delete the install block, its version
**dedicated** linter install, and about where a verdict may come from. and ref variables, and its call site from `script/bootstrap`.
**The version-enforcement principle it established still applies to any **The version-enforcement principle it established still applies to any
other tool a repo pins and installs on the host**, and it is the part worth other tool a repo pins and installs on the host**, and it is the part worth
@@ -719,15 +755,28 @@ style conventions are in separate documents:
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
- **Superseded: the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for - **SUPERSEDED, and deleted rather than kept: the per-checkout
`script/lint`.** It existed only to make a host lint run trustworthy, and the `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it
containerised-lint rule above removes the host run. Delete the wrapper, the was about making a linter run on a shared host trustworthy — a private result
`--allow-serial-runners` flag, and `.lint-cache/` from both `.gitignore` and cache so a byte-identical checkout could not serve its findings, a private
`.dockerignore`. Two of its conclusions outlive it: **`GOCACHE` does not need `TMPDIR` so the host-global lock could not collide, retry and VOID handling so
isolating** (measured — content-addressed, no foreign paths in its entries, no a lock collision was never reported as findings. The containerised-lint rule
global lock), and **verifying lint plumbing requires paired controls** run above removes the host run itself, so there is nothing left for that wrapper
against the artifact as a consuming repo would adopt it, since a control that to isolate, and a repo carrying both would carry two contradictory canonical
passes against the broken form proves nothing. `script/lint` forms. Its findings are not lost: they are the evidence for
containerising, and they are recorded in that rule. Repos that adopted it
delete the wrapper, the `.lint-cache/` entries from `.gitignore` and
`.dockerignore`, and the `--allow-serial-runners` flag with them.
Two of its conclusions are kept because they outlive it. **`GOCACHE` does
not need isolating**, measured rather than assumed: it is content-addressed,
its entries are compiled artifacts rather than diagnostics carrying a
foreign tree's paths, and it has no equivalent global lock — the whole fleet
compiles concurrently against one `GOCACHE` all day without a contention
error. And **verifying any change to lint plumbing requires paired
controls**: a control that passes against the broken form proves nothing,
and it must be run against the artifact as a consuming repo would adopt it —
the file executed, not the functions sourced and driven by hand.
- **Interim rule for reading a lint result produced on the host, in a repo that - **Interim rule for reading a lint result produced on the host, in a repo that
has not yet adopted the containerised lint above.** A lint run is **VOID** has not yet adopted the containerised lint above.** A lint run is **VOID**

View File

@@ -1,6 +1,13 @@
#!/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. Must not modify any files.
#
# script/lint is a docker build (see Dockerfile.lint), so this script
# requires a docker daemon. That is deliberate: it is the only way a
# developer and the pre-commit hook get the same linter CI gets. It also
# means this script must never be run from inside a build stage — see
# the comment in Dockerfile, which runs the individual non-lint checks
# for exactly that reason.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,22 +1,34 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but # script/cibuild: run the CI build. Two container builds, in order:
# that only proves anything because CHECK_EPOCH is a fresh nonce on every # script/lint (Dockerfile.lint) and then the main image, which runs the
# invocation: without it Docker serves the check layer from cache and the # non-lint checks. Both only prove anything because each passes its own
# build exits 0 without running the suite. # fresh CHECK_EPOCH nonce: without it Docker serves the check layers
# from cache on an unchanged tree and the build exits 0 without running
# anything.
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"
# Both assignments on their own line: a failing command substitution # Lint first, for fail-fast feedback: it is its own container build
# inside an argument does not trip `set -e`, so the inline form # and computes its own CHECK_EPOCH. It runs here rather than inside
# degrades silently to an empty constant. `$$` because busybox `date` # the main image because a docker build cannot run a docker build.
# drops %N without erroring. VERSION is computed here because "$SCRIPT_DIR/lint"
# .dockerignore excludes .git, so `git describe` in a build stage # Assign on its own line: a failing command substitution inside an
# yields an empty version without failing; the guard below is the # argument does not trip `set -e`, which would silently degrade the
# single place the fallback is applied. # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
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 \ docker build \

View File

@@ -11,13 +11,19 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Both assignments on their own line: a failing command substitution # Assign on its own line: a failing command substitution inside an
# inside an argument does not trip `set -e`, so the inline form # argument does not trip `set -e`, which would silently degrade the
# degrades silently to an empty constant. `$$` because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. VERSION is computed here because # drops %N without erroring.
# .dockerignore excludes .git, so `git describe` in a build stage
# yields an empty version without failing.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
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 \ docker build \

View File

@@ -1,29 +1,22 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Inside a container, run it directly; # script/lint: run the linter. The linter is never installed on the host
# on a host, build Dockerfile.lint so it runs in one anyway. The linter # and never invoked there — it runs in a container, one way, everywhere,
# is never run on a developer host, where a shared result cache, a # so a run cannot inherit another checkout's cache, another process's
# host-global lock and a stale toolchain make its answer untrustworthy. # lock, or a host toolchain that differs from the pinned one. Linting
# # happens as a build step (see Dockerfile.lint), so a successful build
# LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY # is a clean lint, and it works where the docker daemon is remote and
# accepted signal. Do not add a /.dockerenv fallback: it is absent # bind mounts are impossible.
# inside BuildKit RUN steps and present on hosts that are themselves
# containers, so it both misses and false-positives — and a false
# positive silently restores host linting.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then # argument does not trip `set -e`, which would silently degrade the
exec yarn run prettier --check '**/*.md' \ # nonce to an empty constant. `$$` is required because busybox `date`
--tab-width 4 --prose-wrap always # drops %N without erroring. Without a fresh nonce the lint layer is
fi # served from cache and this script exits 0 having linted nothing.
# Own line, and `$$` because busybox `date` drops %N silently.
# Without a fresh nonce the lint layer is cached and this exits 0
# having linted nothing.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build \ docker build \
--build-arg CHECK_EPOCH="$epoch" \ --build-arg CHECK_EPOCH="$epoch" \