1 Commits

Author SHA1 Message Date
cc6a5a00e7 Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 9s
script/lint runs the linter directly when it is already inside a
container and otherwise builds Dockerfile.lint, so the linter never runs
on a developer host. That closes three host-only mechanisms: the result
cache golangci-lint keys on file content rather than location, which
produced a confirmed false green and findings reported against other
checkouts; the host-global $TMPDIR/golangci-lint.lock, which fails a run
in a way no caller can distinguish from findings; and host/container
version skew, which hid thirteen findings on one repo.

Detection is on LINT_IN_CONTAINER=1, set by every Dockerfile, and on
nothing else. The two directions are not symmetric: a false negative
inside a container attempts a nested docker build, finds no daemon and
fails loudly, while a false positive on a host silently lints there,
which is the defect this issue exists to kill. /.dockerenv is therefore
rejected even as a fallback -- measured absent inside BuildKit RUN steps
and present on any host that is itself a container, so it fails in both
directions and one of them is the dangerous one.

Nothing else changes shape. The Dockerfile still runs make check,
script/check still runs test, lint and fmt-check, script/cibuild is
still a single docker build with CHECK_EPOCH and VERSION, and the Go
multistage lint stage and its COPY --from=lint ordering dependency
survive with ENV LINT_IN_CONTAINER=1 added. Dockerfile.lint is the
standalone developer-host path and carries the same CHECK_EPOCH guard,
with the ARG below the dependency layer so only the lint re-runs.

The script/bootstrap golangci-lint install and the per-checkout
GOLANGCI_LINT_CACHE/TMPDIR wrapper are deleted as superseded. Neither
has a caller left. A JS repo's yarn install stays: the rule is that no
lint verdict may come from a host invocation, not that no linter binary
may exist there, and in a repo whose formatter is its linter the
formatter necessarily runs on the host.

golangci-lint config verify is kept, on measurement. Under the pinned
v2.12.2 a bogus top-level key and a bogus key 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. It needs no network: every case reproduced
byte-identically under `docker run --network none`, in a container where
`getent hosts golangci-lint.run` exits 2.

Comment blocks were cut hard across every file this unit touches.
.dockerignore drops from 67 comment lines to 28, script/cibuild from 17
to 12, script/docker from 18 to 12, and prompts/REPO_POLICIES.md from
1182 lines to 907. What remains says why a line is load-bearing; the
discovery narratives are gone.

config verify lives in script/lint's native branch rather than in a
Dockerfile, so every path that lints inherits it: the lint stage of the
main image, which is what CI runs, as well as Dockerfile.lint. Putting
it in one Dockerfile is how the other path silently loses it.
2026-08-10 14:05:54 +00:00
13 changed files with 488 additions and 658 deletions

View File

@@ -1,75 +1,37 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match # .dockerignore does NOT use .gitignore semantics. Docker matches with
# semantics plus a `**` extension, compiled to a regexp. Plain # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# filepath.Match has no `**` at all. What follows from that: `*` does not # `/` and an unprefixed pattern is anchored at the context root. Every
# cross `/`, and a pattern without a leading `**/` is anchored at the # depth-independent pattern therefore needs `**/`, or `config/.env` and
# build-context root. Every depth-independent pattern therefore needs the # `certs/server.key` still ship while the file reads as solved. Only
# `**/` prefix — without it `config/.env` and `certs/server.key` still # genuinely root-anchored entries go unprefixed. Never transplant these
# ship while the file reads as solved. # into .gitignore, where `**/` is wrong.
# #
# Root-anchored entries are for paths that occur exactly once, at the # Matching is case-sensitive, so secrets use character ranges rather
# context root. A host-built binary is the usual case, and it must be # than an ALL-CAPS twin, which would still miss `Server.Key`.
# 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.
# #
# Matching is case-sensitive, so `**/*.key` does not match # Extend with this repo's own host-built artifacts, written anchored:
# `certs/SERVER.KEY`, which is reachable on the case-insensitive # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# filesystems most laptops use. Adding an ALL-CAPS twin per pattern is # deletes the package directory from the context.
# 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.
# Repository metadata: exactly one, at the context root. Excluding it # Excluding .git means `git describe` cannot run in any build stage and
# means `git describe` cannot run in any build stage, and it fails # fails quietly there; pass the version in with --build-arg VERSION.
# 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
# In-repo agent scratch: a directory holding a full additional checkout # Agent scratch: one full checkout of the repo per in-flight agent.
# of the repo for each in-flight agent. Anchored because it occurs # Anchored because it occurs once where agents run at the repo root.
# exactly once *where agents run at the repo root*, which is the # KNOWN GAP: a repo running agents in subdirectories still ships
# convention this file assumes; the `**/` form would also match any # `services/api/.claude/` and must add its own anchored entry.
# 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 both the bare `.env` name (`*` matches # Environment files. `*.env` covers bare `.env` and the `prod.env`
# the empty string) and the `prod.env` convention. # convention. Re-include a committed template with a negation if the
# 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 that carry them. Public certificates # Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are # (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
# sometimes a legitimate build input.
**/*.[pP][eE][mM] **/*.[pP][eE][mM]
**/*.[kK][eE][yY] **/*.[kK][eE][yY]
**/*.[pP]12 **/*.[pP]12
@@ -86,8 +48,7 @@
**/.DS_Store **/.DS_Store
**/Thumbs.db **/Thumbs.db
# Editor state. Never a build input, and it churns under a developer's # Editor state: never a build input, and it churns COPY.
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp **/*.swp
**/*.swo **/*.swo
**/*~ **/*~

View File

@@ -3,35 +3,26 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apk here; node # Makes script/lint run the linter directly rather than building
# and yarn are already in the base image, so those steps are skipped). # Dockerfile.lint, which would need a docker daemon here.
# Dependency manifests are copied first so the bootstrap layer is ENV LINT_IN_CONTAINER=1
# 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 supplied by script/cibuild and # CHECK_EPOCH is a per-invocation nonce from 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 it must be redeclared in every stage that runs checks. The guard # so declare it in every stage that runs checks. The guard fails a bare
# makes a bare `docker build .` fail loudly instead of silently reusing # `docker build .`, which would otherwise reuse the empty (and therefore
# the empty (and therefore stable) cache key. Expand the value into the # stable) cache key. The value is also expanded into the check command,
# command so the cache miss does not depend on BuildKit's handling of an # so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value, # unreferenced ARG; keep both references.
# 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,41 +1,27 @@
# Lint-only image. `script/lint` builds this file and nothing else: the # Lint-only image, built by script/lint when it is not already inside a
# linter runs as a build step, so a successful build IS a clean lint. # container. Linting is a build step, so a successful build is a clean
# Building rather than bind-mounting is what makes it work where the # lint, and nothing is bind-mounted, which matters when the daemon is
# docker daemon is remote and bind mounts are impossible. # remote.
#
# 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
# Dependency layer first, and deliberately above the ARG below, so it # Makes script/lint run the linter directly instead of recursing into
# stays cached and only the lint steps re-run on every invocation. # another docker build, which has no daemon here.
# Without that ordering the cache-bust would reinstall dependencies on ENV LINT_IN_CONTAINER=1
# 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 . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without # ARG sits after the dependency layer so that layer stays cached and
# it an unchanged tree serves the lint layer from cache and the build # only the lint re-runs. The guard fails a bare `docker build
# reports a lint it never ran — a green that proves nothing, which is # -f Dockerfile.lint .`, which would otherwise reuse the empty (stable)
# the whole failure mode this file exists to avoid reintroducing. The # cache key and report a lint it never ran.
# 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}" && \ RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always

View File

@@ -117,25 +117,21 @@ 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, by building `Dockerfile.lint`. The - `script/lint` — lint the markdown files with prettier. Inside a container
linter runs in a container, always: it is never installed on the host and (`LINT_IN_CONTAINER=1`, set by both Dockerfiles) it runs prettier directly; on
never invoked there. Linting happens as a build step, so a successful build is a host it builds `Dockerfile.lint` so the linter still runs in a container
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). Needs a docker daemon, since `script/lint` is a container build extension)
- `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, run `script/lint` first, then assign - `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then
`epoch="$(date +%s%N)$$"` and `docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs). Two build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what
container builds: the lint image, then the main image, which runs stops Docker serving that check from cache on an unchanged tree — a bare
`script/test` and `script/fmt-check` but deliberately not `make check` — that `docker build .` fails closed on purpose)
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,53 +21,27 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Closed three gaps the containerised-lint rule left between the - 2026-08-10: Moved every lint run into a container. `script/lint` now runs the
canonical text and the first repos to implement it. `.dockerignore` excluding linter directly when `LINT_IN_CONTAINER=1` and otherwise builds
the agent scratch directory is now stated as a correctness precondition of `Dockerfile.lint`, so the linter never runs on a developer host — closing the
that rule rather than a context-size measure: the lint image lints whatever content-keyed result cache that produced a confirmed false green, the
`COPY . .` copies, and toolchains discover files by walking the tree instead host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew.
of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds Detection is on that marker alone: a false negative inside a container fails
back inside the container — `sneak/quak` measured the same discovery mechanism loudly on the missing daemon, while a false positive on a host would silently
taking a test count from 210 to 1050. The cache-bust arg is fixed at restore host linting, so `/.dockerenv` is rejected outright — measured absent
`CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is inside BuildKit `RUN` steps and present on hosts that are themselves
invisible to the grep that proves every build is busted, making a renamed containers. Everything else keeps its existing shape: `make check` still runs
guard indistinguishable from a missing one. And the formatting check is now in the image, `script/cibuild` is still one build, and the Go multistage lint
required to run in exactly one of the two images, with either placement stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the
allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check` same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only
gets dropped from both, and running the formatter beside the linters is the the lint re-runs. The `script/bootstrap` golangci-lint install and the
better shape where it is the same pinned dependency. per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and repo's `yarn install` stays, since the rule is about where a verdict comes
made this repo do it rather than merely document it. `script/lint` is now from, not about which binaries exist. `golangci-lint config verify` was kept
`docker build -f Dockerfile.lint .` and nothing else; the linter is never on measurement: a bogus config key passes `golangci-lint run` with `0 issues`
installed on the host and never invoked there, so a run cannot inherit another and fails `config verify`, and every case reproduced byte-identically under
checkout's content-keyed result cache, the host-global `--network none`, so the schema is embedded and the line costs no network.
`$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned Comment blocks across the touched files were cut hard in the same pass.
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,24 +111,21 @@ 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`, which builds `Dockerfile.lint`: `golangci-lint`. Run it with `make lint`, never by invoking the binary: the
the linter runs in a container, always, and is never installed on the host. linter always runs in a container, and `golangci-lint` is not installed on
A `golangci-lint` invoked directly on a shared host reads a result cache the host by any repo. Invoked directly on a shared host it reads a result
keyed on file content rather than location and a host-global lock, so its cache keyed on file content rather than location, and a host-global lock, so
answer may belong to another checkout entirely. its answer may belong to another checkout entirely.
1. Write a `Dockerfile` for every repo, even if it only runs the tests. It runs 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
the non-lint checks; linting lives in `Dockerfile.lint` and is run by linting. `script/cibuild` and `script/docker` should always make sure that
`script/cibuild` before the main build, because `script/lint` is itself a the code is in an able-to-be-compiled state, linted, and any tests run, and
`docker build` and cannot run inside one. So `script/cibuild` is what the build should fail if linting doesn't pass. That guarantee holds only
guarantees the code is in an able-to-be-compiled state, linted, and tested — because those scripts pass a per-invocation `CHECK_EPOCH` build arg that
**a successful `docker build .` on its own does not, because it never busts the check layers out of the Docker cache; without it an unchanged tree
lints.** That guarantee holds only because each build passes a serves those layers from cache and the build reports a green it never ran. A
per-invocation `CHECK_EPOCH` build arg that busts its check layers out of bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]`
the Docker cache; without it an unchanged tree serves those layers from guard — always go through `script/cibuild` or `script/docker`. See
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,31 +36,21 @@ 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 the **non-lint** checks as build steps (`script/test`, Dockerfile runs `make check` as a build step, and every stage containing a
`script/fmt-check`), and every stage containing a check-running `RUN` check-running `RUN` declares `ARG CHECK_EPOCH` with the
declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
guard immediately below it — see the `CHECK_EPOCH` rule in `CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
`REPO_POLICIES.md`. Without them the check layer is served from cache on served from cache on an unchanged tree and the build reports a green it
an unchanged tree and the build reports a green it never ran. never ran.
- [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint` - [ ] **Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`** — the lint
stage or a `COPY --from=lint ... /dev/null` ordering line. This is the stage and the build stage both. This is the item an existing repo most
item an existing repo most often fails: `script/lint` is now a often fails after adopting the containerised lint: without it
`docker build`, so both of those nest a docker build inside a build step. `script/lint` tries to build `Dockerfile.lint` from inside a build step,
Delete the stage; `script/cibuild` running `script/lint` first is what where there is no daemon.
replaces its fail-fast purpose. - [ ] `Dockerfile.lint` exists and `script/lint` builds it when not already in a
- [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the container — see the containerised-lint rule in `REPO_POLICIES.md`. Base
containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its image pinned by sha256 with a version/date comment, `ARG CHECK_EPOCH`
base image is pinned by sha256 with a version/date comment, it carries **after** the dependency layer with the guard below it.
`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
@@ -69,11 +59,8 @@ 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 — and `Dockerfile.lint` then lints that checkout work into an image layer. Confirm by enumerating the image, not by reading
as though it were this one, because toolchains discover files by walking the file — `.gitignore` hides these from `git status` too.
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
@@ -122,24 +109,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 container build and nothing else. No host - [ ] `script/lint` is the canonical detect-and-branch form, and no host
linter invocation survives anywhere in the repo — grep for the linter's invocation anywhere in the repo can produce a lint **verdict** — grep for
own name in `script/`, the `Makefile` and CI config, not just in the linter's own name across `script/`, the `Makefile` and CI config, not
`script/lint`. An existing repo is where a second path to the linter is just `script/lint`. A second path is likeliest here: a `make lint-fast`,
likeliest to exist: a `make lint-fast`, a container-versus-host branch, or an older container-versus-host branch, or a CI step calling the binary
a CI step that calls the binary directly. directly. **Expected hits that are not the defect**: `script/fmt`, and in
- [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install a repo whose formatter is also its linter, `script/fmt-check`. Everything
block, its version and ref variables, and its call site: nothing invokes a else the grep finds is a real second path and goes.
host linter any more, so all it can still do is put a differently - [ ] Detection is on `LINT_IN_CONTAINER` alone. Reject any `/.dockerenv` or
versioned binary where somebody runs it by hand and believes the result. cgroup heuristic: absent in BuildKit `RUN` steps, present on hosts that
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`. A container has its own cache and its `.gitignore` and `.dockerignore`.
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
@@ -188,7 +175,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 and runs both container builds (a bare - [ ] `script/cibuild` succeeds (a bare `docker build .` or
`docker build .` or `docker build -f Dockerfile.lint .` fails closed by `docker build -f Dockerfile.lint .` fails closed by design, on the
design, on the `CHECK_EPOCH` guard) `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -73,36 +73,27 @@ 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.
- The `Dockerfile` runs the **non-lint** checks as build steps — - All Dockerfiles must run `make check` as a build step, and every stage
`script/test` and `script/fmt-check`, never `make check`. `script/lint` is containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the
a `docker build` of `Dockerfile.lint`, so `make check` here nests a build `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
inside a build step, where there is no daemon. Put a comment above those `CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
`RUN` lines saying so. Every stage containing a check-running `RUN` must served from cache on an unchanged tree and the build reports a green it
declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` never ran.
guard immediately below it — see the `CHECK_EPOCH` rule in - Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`, so
`REPO_POLICIES.md`. Without them the check layer is served from cache on `script/lint` runs the linter natively instead of trying to build
an unchanged tree and the build reports a green it never ran. `Dockerfile.lint` where there is no daemon.
- 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 those checks - Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image that `script/lint` builds. Same - [ ] `Dockerfile.lint` — the lint-only image `script/lint` builds when it is
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` placed **after** the dependency layer so only the lint steps re-run. `ARG` **after** the dependency layer so only the lint re-runs. Base image
Base image pinned by sha256 with a version/date comment. Go repos use pinned by sha256 with a version/date comment. Copy from
`golangci/golangci-lint` and run both `golangci-lint config verify` and `REPO_POLICIES.md`.
`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`
@@ -129,26 +120,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`builds `Dockerfile.lint` and nothing else: - [ ] `script/lint` / `make lint`runs the linter directly when
`epoch="$(date +%s%N)$$"` on its own line, then `LINT_IN_CONTAINER=1`, otherwise `epoch="$(date +%s%N)$$"` on its own line
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The then `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`.
linter is never installed on the host and never invoked there. Copy the No lint verdict may come from a host invocation. Copy from
canonical script from `REPO_POLICIES.md`; it is byte-identical across `REPO_POLICIES.md`. Detect on `LINT_IN_CONTAINER` only — never
repos. Without the nonce this script exits 0 on an unchanged tree having `/.dockerenv`, which is absent in BuildKit `RUN` steps and present on
linted nothing. hosts that are themselves containers. Without the nonce this exits 0 on an
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. It needs a docker daemon, because `script/lint` is a modify files
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, run `script/lint` **first** for - [ ] `script/cibuild` — cd to repo root, then, each on its own line:
fail-fast feedback, then, each on its own line:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
@@ -160,17 +151,17 @@ are thin shims calling them. Model scripts:
. .
``` ```
(what CI runs). The `script/lint` call is not optional: the main image does (what CI runs). Both build args are mandatory, and both assignments must be
not lint, so without it CI never lints. Both build args are mandatory, and on their own line: a failing command substitution inside an argument does
both assignments must be on their own line: a failing command substitution not trip `set -e`, so the inline form degrades silently to an empty
inside an argument does not trip `set -e`, so the inline form degrades constant. The `[ -n "$version" ]` line is a live check that fires on an
silently to an empty constant. The `[ -n "$version" ]` line is a live check export with no `.git` and on a repo with no commits — keep it, and do not
that fires on an export with no `.git` and on a repo with no commits — keep collapse it into `|| echo unknown`, which makes it unreachable. See the
it, and do not collapse it into `|| echo unknown`, which makes it `CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each
unreachable. See the `CHECK_EPOCH` and git-describe rules in element is load-bearing. A bare `docker build .` fails closed by design, and
`REPO_POLICIES.md` for why each element is load-bearing. A bare so does a bare `docker build -f Dockerfile.lint .`. The image runs
`docker build .` fails closed by design, and so does a bare `make check`, which includes lint, so `script/cibuild` needs no separate
`docker build -f Dockerfile.lint .`. lint step.
- [ ] `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
@@ -186,7 +177,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 runs both container builds - [ ] `script/cibuild` succeeds and demonstrably executes
- [ ] 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,8 +60,7 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/lint` first for fail-fast feedback (that is itself a repo root and runs
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
@@ -94,29 +93,24 @@ 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`. It must run the repo's checks as build - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
steps so the build fails if the branch is not green — which requires as a build step so the build fails if the branch is not green — the one
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running exception being `Dockerfile.lint`, which runs `make lint` alone because that
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies is its entire purpose — which requires `ARG CHECK_EPOCH` and its guard in
this criterion while its check layers are served from cache, so the build every stage containing a check-running `RUN`, per the `CHECK_EPOCH` rule
cannot fail on a branch that is not green. below. Without them a Dockerfile satisfies this criterion while its check
layers are served from cache, so the build cannot fail on a branch that is not
green.
**It runs the individual non-lint checks — `script/test` and **Every Dockerfile must also set `ENV LINT_IN_CONTAINER=1`**, above the
`script/fmt-check` — and never `make check`.** `script/lint` is itself a checks. `script/lint` builds `Dockerfile.lint` when it is not already in a
`docker build` (of `Dockerfile.lint`, per the containerised-lint rule container; without the marker it would try that from inside a build step,
below), so a `RUN make check` in this file attempts a docker build inside a where there is no daemon. See the containerised-lint rule below.
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 those checks. For server repos, they should run as an environment and run `make check`. For server repos, `make check` should run
early build stage before the final image is assembled. Dockerfiles install as an early build stage before the final image is assembled. Dockerfiles
development prerequisites by running `script/bootstrap` rather than install 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.
@@ -126,19 +120,24 @@ 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, which since linting moved into its own container means build step`Dockerfile` and `Dockerfile.lint` alike; a `Dockerfile.lint`
`Dockerfile` and `Dockerfile.lint` both — a `Dockerfile.lint` without the without the cache-bust is a lint that never ran, reported as a pass. The
cache-bust is a lint that never ran, reported as a pass. The canonical form, canonical form, in **every** stage containing a check-running `RUN`, placed
in **every** stage containing a check-running `RUN`, placed **after** the **after** the dependency-install layer so that layer stays cached:
dependency-install layer so that layer stays cached:
```dockerfile ```dockerfile
ENV LINT_IN_CONTAINER=1
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command> RUN echo "check epoch: ${CHECK_EPOCH}" && make check
``` ```
and in `script/lint`, `script/cibuild` and `script/docker`: `ENV LINT_IN_CONTAINER=1` belongs in every such stage too, and is the line
most often missed: without it `make check` reaches `script/lint`, which
tries to build `Dockerfile.lint` from inside a build step where there is no
daemon. See the containerised-lint rule below.
and in both `script/cibuild` and `script/docker`:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
@@ -150,12 +149,15 @@ 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. `script/lint` passes only `CHECK_EPOCH`, since no document half a command. All four `CHECK_EPOCH` elements are load-bearing;
version is embedded in a lint image. All four `CHECK_EPOCH` elements are none is optional, and each guards a failure mode that otherwise fails green:
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`.
@@ -182,107 +184,70 @@ 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`, `script/bootstrap`, and the pinned toolchain install `go mod download` and `script/bootstrap` cached, so it does not push against
cached, so it does not push against the five-minute Docker build ceiling. the five-minute Docker build ceiling. Blanket `--no-cache` is not an
Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or acceptable substitute: it also busts the dependency layer, so every run
on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every reinstalls dependencies over the network instead of only the first and those
invocation, which makes linting network-dependent and pushes a lint that after a manifest change. Never reach for `docker builder prune` — the build
should take seconds toward the build ceiling. Never reach for cache is shared with every other build on the host.
`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, and `script/lint` is that container - **Every lint run happens in a container.** `script/lint` runs the linter
build.** The linter is never installed on the host and never invoked there. directly when it is already inside one, and otherwise builds `Dockerfile.lint`
Every repo carries a `Dockerfile.lint` next to its `Dockerfile`; the linter so that it is. Either way the linter never runs on a developer host, where its
runs as a **build step**, so a successful build _is_ a clean lint. Building answer is not trustworthy:
rather than bind-mounting is deliberate: it is what makes the pattern work - **Confirmed false green.** golangci-lint keys cached results on file
unchanged where the docker daemon is remote and bind mounts are impossible. **content, not location**, so a second checkout of the same commit serves
Docker is assumed available in every environment. Discarding the linter's its findings. One implementer reported `0 issues` on a branch genuinely
cache on every run is the point of this rule, not a cost it pays. red with a `goconst` finding. Own-clones-instead-of-worktrees does not
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.
This closes a family of defects, every one of them an artefact of running A container has its own cache, its own `TMPDIR` and a binary pinned by
the linter on a shared host, and every one of them observed rather than digest, so none of it is reachable. This supersedes the per-checkout
hypothesised: `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper, which existed only to make a host
- **A confirmed false green.** An implementer reported `0 issues` on a run trustworthy; delete it on adoption.
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.
A container per run has its own cache, its own `TMPDIR` and therefore its The canonical `script/lint`, whose executable lines are the same in every
own lock, and a binary pinned by digest, so none of the above is reachable. repo apart from the native lint command:
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. The linter is never installed on the host # script/lint: run the linter. Inside a container, run it directly; on a
# and never invoked there — it runs in a container, one way, everywhere, # host, build Dockerfile.lint so it runs in one anyway.
# so a run cannot inherit another checkout's cache, another process's #
# lock, or a host toolchain that differs from the pinned one. # LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY
# 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
# not trip `set -e`, and `$$` is required because busybox `date` if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then
# drops %N without erroring. Without a fresh nonce the lint layer is # config verify lives here, not in a Dockerfile, so every path
# served from cache and this script exits 0 having linted nothing. # that lints inherits it — the lint stage of the main image as
# well as Dockerfile.lint. Duplicating it into each Dockerfile
# is how one of them silently loses it.
golangci-lint config verify --config .golangci.yml
exec golangci-lint run --config .golangci.yml ./...
fi
# 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" \
@@ -293,123 +258,138 @@ style conventions are in separate documents:
main "$@" main "$@"
``` ```
Load-bearing properties: and `Dockerfile.lint`, the standalone path for a developer host:
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted
nothing — the same false green the `CHECK_EPOCH` rule above exists to
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`,
`ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
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
commands catch **disjoint** classes of defect, measured under the pinned
v2.12.2 against a config carrying one planted defect at a time: 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 the key; an invalid value type fails
both; an unknown linter name fails `run` and passes `config verify`. So
`run` alone silently ignores an unknown key, which is exactly the mode
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
pinned binary. Re-run that control when bumping the pin rather than
treating the result as permanent.
- **No repo installs a linter on the host, in `script/bootstrap` or anywhere
else.** A host install is now dead weight whose only remaining effect is
to reintroduce the version skew above.
- **`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`.
**What a consuming repo does to adopt this**, in order: add
`Dockerfile.lint`; replace `script/lint` with the build above; delete the
`lint` stage from its `Dockerfile` along with the
`COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
`RUN make check` to `script/test` and `script/fmt-check` with the comment
explaining why; add `script/lint` as the first step of `script/cibuild`;
delete any golangci-lint install from `script/bootstrap`; and delete the
`.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
the per-checkout cache/lock wrapper they served.
**The separate lint _stage_ is superseded by this and must not survive
alongside it.** It ran `make lint`, which is now a docker build, so keeping
it is not a stylistic preference but a recursion. Its purpose — fail-fast
feedback before the slow build — is served by `script/cibuild` running
`script/lint` first, and its `COPY --from=lint /src/go.sum /dev/null`
ordering trick, along with the warm-cache re-proof that trick required, is
no longer needed because the ordering is now sequential in the shell.
- **The canonical Go repo `Dockerfile`**, which builds and tests but does not
lint:
```dockerfile ```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}" && make lint
```
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 .`
on an unchanged tree returns a sub-second cached success having linted
nothing. The `ARG` goes **after** the dependency layer so only the lint
re-runs; `--no-cache` would also reinstall dependencies on every lint.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck`. Only the base image and the native lint
command change.
- **Keep `golangci-lint config verify`, put it in `script/lint`, and it
costs no network.** It goes in the native branch, not in a Dockerfile, so
the lint stage of the main image inherits it along with `Dockerfile.lint`;
putting it in one Dockerfile leaves the other path unverified. The two
commands catch disjoint classes, measured under the pinned v2.12.2: a
bogus top-level key and a bogus key under `linters.settings.lll` both pass
`golangci-lint run` with **exit 0 and `0 issues`** while `config verify`
exits 3 and names them; an invalid value type fails both; an unknown
linter name fails `run` and passes `config verify`. So `run` alone
silently ignores an unknown key — the mode where a threshold reads as
configured and is not applied. It needs no network: 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. Re-run that control when bumping the pin.
- **A failed `script/lint` that names no finding is not a lint result.** On
the host path `docker build` exits 1 both for findings and for a build
that never got there (daemon down, image unpullable, disk full). BuildKit
names the failing step; read it, fix the environment, re-run. Do not
record a verdict from a run that did not lint.
**Scope: this rule is about linters, and a formatter is not one.**
`script/fmt` writes your working tree, so it can only run on the host, and
`script/fmt-check` is its read-only twin. In a repo whose formatter **is**
its linter (prettier over markdown; this repo), `script/bootstrap` therefore
installs the linter on the host as an ordinary dependency and
`script/fmt-check` runs it there. That is accepted: the version is pinned in
`package.json` and installed into the repo's own `node_modules`, so there is
no shared content-keyed cache, no host-global lock and nothing to skew
against. What is forbidden is taking a **lint verdict** from it —
`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:
1. Add `Dockerfile.lint`.
2. Replace `script/lint` with the form above, with its own native lint
command.
3. Add `ENV LINT_IN_CONTAINER=1` to **every** stage of every Dockerfile that
runs checks — the lint stage and the build stage both.
4. Delete any golangci-lint install from `script/bootstrap`, with its
version and ref variables and its call site. No lint verdict comes from
the host any more, 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
unchanged by this: `make check` still runs inside the image, and
`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
# 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
@@ -426,40 +406,48 @@ style conventions are in separate documents:
``` ```
Key points: Key points:
- Tests run in the build stage because they may require compiled artifacts - The lint stage uses the `golangci/golangci-lint` image directly (it has
or heavier dependencies. both Go and the linter), so nothing needs installing. `make lint` there
- `ARG CHECK_EPOCH` must be declared in **every** stage containing a runs `script/lint`, which sees `LINT_IN_CONTAINER=1` and invokes
check-running `RUN`, because `ARG` is stage-scoped: declaring it in one `golangci-lint` natively instead of building `Dockerfile.lint`. Without
stage leaves the others frozen at their last cached result while the fix that `ENV` the stage would attempt a nested build and fail.
reviews as complete. In each such stage the guard sits immediately below - `COPY --from=lint /src/go.sum /dev/null` is a no-op copy that exists only
the `ARG`, and the value is expanded into the first check `RUN` so the to create the stage dependency; without it a lint failure might not fail
cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both the overall build.
lines reference `$CHECK_EPOCH`, so each stage has two independent - **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.**
invalidation points. Later `RUN`s in the same stage need no expansion of The cache-bust turns the no-op `COPY` into a content-cache hit, so an
their own: their parent layer is already busted. ordering guarantee established cold does not automatically carry over. It
- `ARG VERSION=dev` is declared in the build stage, and its value is was re-proved in another org repo using the same trick and held, but that
supplied on the host by `script/docker` and `script/cibuild` via result does not transfer by assumption — re-check it warm.
`--build-arg VERSION=...`. The `dev` default is a placeholder for a local - If the project uses `//go:embed` referencing build artifacts, the lint
build, not a source of truth. **No stage may call `git describe`**: stage must create placeholders so the directives resolve:
`.dockerignore` excludes `.git`, so it yields an empty version without `RUN mkdir -p web/dist && touch web/dist/index.html`.
failing. See the git-describe rule further down. - If linting needs CGO or system libraries (e.g. `vips-dev`), `apk add` them
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` on push. `script/cibuild` runs **two** container builds: runs `script/cibuild` (which runs
`script/lint` (`Dockerfile.lint`) first, then `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`)
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .` on push. The Dockerfile runs `make check`, so a successful build implies all
for the main image, which runs the non-lint checks. A successful checks pass — but that implication holds **only** because of the `CHECK_EPOCH`
`script/cibuild` therefore implies all checks pass; **a successful cache-bust described above. Without it, an unchanged tree serves the check
`docker build .` on its own does not, because it never lints.** That is the layer from cache and the build reports a green it never earned. A bare
one claim to be careful with when reading these files: the guarantee belongs `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard;
to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only always go through `script/cibuild` or `script/docker`. Never accept a pass as
because each build passes its own `CHECK_EPOCH` nonce — without it an evidence without confirming it ran: a sub-second wall time, or `CACHED` on the
unchanged tree serves the layers from cache and the build reports a green it check layer, means nothing was executed.
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
@@ -571,22 +559,14 @@ 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. **And because reasons that have nothing to do with the repo's own content. In `.gitignore`
`Dockerfile.lint` and `Dockerfile` run their tooling over the copied context, the entry is `.claude/`, unanchored, which already matches at every depth. In
a worktree that reaches it is linted and tested as though it were the repo.** `.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the
Nothing else stops that: language toolchains discover files by walking the directory occurs exactly once **where agents run at the repo root**, and the
tree and do not read `.gitignore`, which is how `sneak/quak` saw a nested prefixed form would also match any nested directory of that name and delete it
`.claude/` worktree take its discovered test count from 210 to 1050 from the build. It is not case-folded the way the secret patterns are, because
(https://git.eeqj.de/sneak/quak/issues/30). This entry is therefore a tooling creates it in exactly one spelling, so a folded pattern would add no
correctness precondition of the containerised-lint rule above and not a size coverage.
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
@@ -694,23 +674,21 @@ 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 there is: the linter is not installed on the host, in digest is the only pin: golangci-lint is not installed on the host by any
`script/bootstrap` or anywhere else. Bumping the version means changing that repo. Bumping the version means changing that one digest.
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 a linter at all.** This supersedes the - **`script/bootstrap` must not install golangci-lint.** This supersedes the
pinned-golangci-lint install that used to be canonical here. Nothing runs a pinned host install that used to be canonical here. `script/lint` never runs
linter on the host any more — `script/lint` is a container build — so a host it on the host — it either builds `Dockerfile.lint` or is already in a
install has no caller left, and its only remaining effect is to put a second, container that ships the binary — so a host install has no caller, and its
independently-versioned linter on the machine where somebody will eventually only remaining effect is to put a second, independently-versioned linter where
run it by hand and believe the result. The version-skew failures that install somebody eventually runs it by hand and believes the result. Delete the block,
was written to close (a local `make check` green while `make docker` rejected its version and ref variables, and its call site.
the same commit with six `goconst` findings; a container linter surfacing
thirteen findings the host run missed) are closed more completely by having This is not a ban on host dependency installs generally. A JS or docs repo's
exactly one linter, pinned by image digest, that no host state can shadow. `script/bootstrap` runs `yarn install`, which brings its linter along with
Repos adopting the containerised lint delete the install block, its version every other dependency; that is unavoidable and fine. The rule is about a
and ref variables, and its call site from `script/bootstrap`. **dedicated** linter install, and about where a verdict may come from.
**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
@@ -755,28 +733,15 @@ 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, and deleted rather than kept: the per-checkout - **Superseded: the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it `script/lint`.** It existed only to make a host lint run trustworthy, and the
was about making a linter run on a shared host trustworthy — a private result containerised-lint rule above removes the host run. Delete the wrapper, the
cache so a byte-identical checkout could not serve its findings, a private `--allow-serial-runners` flag, and `.lint-cache/` from both `.gitignore` and
`TMPDIR` so the host-global lock could not collide, retry and VOID handling so `.dockerignore`. Two of its conclusions outlive it: **`GOCACHE` does not need
a lock collision was never reported as findings. The containerised-lint rule isolating** (measured — content-addressed, no foreign paths in its entries, no
above removes the host run itself, so there is nothing left for that wrapper global lock), and **verifying lint plumbing requires paired controls** run
to isolate, and a repo carrying both would carry two contradictory canonical against the artifact as a consuming repo would adopt it, since a control that
`script/lint` forms. Its findings are not lost: they are the evidence for passes against the broken form proves nothing.
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,13 +1,6 @@
#!/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,34 +1,22 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. Two container builds, in order: # script/cibuild: run the CI build. The Dockerfile runs script/check, but
# script/lint (Dockerfile.lint) and then the main image, which runs the # that only proves anything because CHECK_EPOCH is a fresh nonce on every
# non-lint checks. Both only prove anything because each passes its own # invocation: without it Docker serves the check layer from cache and the
# fresh CHECK_EPOCH nonce: without it Docker serves the check layers # build exits 0 without running the suite.
# from cache on an unchanged tree and the build exits 0 without running
# anything.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container build # Both assignments on their own line: a failing command substitution
# and computes its own CHECK_EPOCH. It runs here rather than inside # inside an argument does not trip `set -e`, so the inline form
# the main image because a docker build cannot run a docker build. # degrades silently to an empty constant. `$$` because busybox `date`
"$SCRIPT_DIR/lint" # drops %N without erroring. VERSION is computed here because
# Assign on its own line: a failing command substitution inside an # .dockerignore excludes .git, so `git describe` in a build stage
# argument does not trip `set -e`, which would silently degrade the # yields an empty version without failing; the guard below is the
# nonce to an empty constant. `$$` is required because busybox `date` # single place the fallback is applied.
# 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,19 +11,13 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an # Both assignments on their own line: a failing command substitution
# argument does not trip `set -e`, which would silently degrade the # inside an argument does not trip `set -e`, so the inline form
# nonce to an empty constant. `$$` is required because busybox `date` # degrades silently to an empty constant. `$$` because busybox `date`
# drops %N without erroring. # drops %N without erroring. VERSION is computed here because
# .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,22 +1,29 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. The linter is never installed on the host # script/lint: run the linter. Inside a container, run it directly;
# and never invoked there — it runs in a container, one way, everywhere, # on a host, build Dockerfile.lint so it runs in one anyway. The linter
# so a run cannot inherit another checkout's cache, another process's # is never run on a developer host, where a shared result cache, a
# lock, or a host toolchain that differs from the pinned one. Linting # host-global lock and a stale toolchain make its answer untrustworthy.
# happens as a build step (see Dockerfile.lint), so a successful build #
# is a clean lint, and it works where the docker daemon is remote and # LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY
# bind mounts are impossible. # 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"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then
# nonce to an empty constant. `$$` is required because busybox `date` exec yarn run prettier --check '**/*.md' \
# drops %N without erroring. Without a fresh nonce the lint layer is --tab-width 4 --prose-wrap always
# served from cache and this script exits 0 having linted nothing. fi
# 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" \