1 Commits

Author SHA1 Message Date
35858dab66 Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 13s
The linter is no longer installed on the host and no longer invoked
there. script/lint is now `docker build -f Dockerfile.lint .` and
nothing else, with the linter running as a build step, so a successful
build of that file is a clean lint — and it works unchanged where the
docker daemon is remote and bind mounts are impossible.

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

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

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

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

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

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

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

Rework, from independent review of this commit. The canonical text is
the deliverable here, so a false sentence is a fleet-wide defect: the
Dockerfile rule still said the build "fails if the branch is not green",
which stopped being true when lint left that image, and the earlier
sweep grepped one phrasing rather than the claim. Re-swept on the claim
itself — green/red-branch wording, build-fails wording, entailment verbs
near build/lint/check, and "linted" asserted as covered — across
prompts/, README.md, TODO.md, both Dockerfiles and every script.

Two absolutes are narrowed to what is actually true, because seventeen
repos adopt this literally. The rule is that no lint VERDICT may come
from a host invocation, not that the binary never exists on the host: a
JS repo's `yarn install` puts its linter in node_modules on the host
unavoidably, and in a repo whose formatter is its linter — this one —
`script/fmt-check` runs the same command that Dockerfile.lint runs. That
gap is now stated with its bound (the version is pinned in the repo's
own node_modules, so no shared cache, no host lock, nothing to skew) and
the audit grep keeps its reach, gaining a note on which two hits are
expected rather than being weakened.

script/lint conflates "found issues" with "could not run": docker build
exits 1 for both. The exit-75 VOID machinery is deliberately not
restored, and the reasoning is now recorded where a reader looking for
it lands. The dangerous direction is already closed, since a build that
cannot run fails closed and can never read as clean; BuildKit already
names the failing step, where the old lock error went to stderr while
findings went to stdout and was easy to lose; and the failure is not
transient, so the retry that justified the old machinery would be wrong
here. Rebuilding the distinction would mean per-invocation capture files
and traps again plus matching on BuildKit's message format, which is not
a stable interface, and a mis-match in the "treat as infrastructure"
direction would be the false green this rule exists to prevent. What
survives is binding as a reading rule: a run that did not reach the lint
step is not a verdict.

Also corrected: script/lint was listed above a CHECK_EPOCH snippet that
does a bare `docker build .` with no -f, which would have built the main
image and linted nothing; the canonical Go Dockerfile template used
`make fmt-check` / `make test` where every prose rule in the same
document says script/, one Makefile edit away from re-entering the
recursion; README omitted the mandatory VERSION build arg; script/docker
did not say lint had left its image, a comment that propagates
fleet-wide; the "byte-identical across repos" claim for script/lint is
narrowed to its executable lines; and "--no-cache makes linting
network-dependent" is softened to the measured comparative claim.
2026-08-10 13:14:57 +00:00
13 changed files with 682 additions and 460 deletions

View File

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

View File

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

View File

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

View File

@@ -117,21 +117,32 @@ alpine. We provide:
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here) - `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files with prettier. Inside a container - `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The
(`LINT_IN_CONTAINER=1`, set by both Dockerfiles) it runs prettier directly; on lint verdict comes only from the container; nothing on the host produces one.
a host it builds `Dockerfile.lint` so the linter still runs in a container (The prettier in `node_modules` that `script/fmt` and `script/fmt-check` use
is the same binary, which is why this says "verdict" rather than "never on the
host" — see the scope note in `prompts/REPO_POLICIES.md`.) Linting happens as
a build step, so a successful build is a clean lint, and the same
per-invocation `CHECK_EPOCH` nonce used elsewhere is what stops Docker serving
that lint from cache on an unchanged tree. A failure that names no finding —
daemon down, image unpullable — is not a lint result: read which build step
failed, fix that, and re-run
- `script/fmt` — format all markdown files with prettier (writes) - `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension). Needs a docker daemon, since `script/lint` is a container build
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as (byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild` `script/cibuild`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then - `script/cibuild` — cd to the repo root, run `script/lint` first, then assign
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image `epoch="$(date +%s%N)$$"` and `version="$(git describe ...)"` on their own
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what lines and
stops Docker serving that check from cache on an unchanged tree — a bare `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
`docker build .` fails closed on purpose) (what CI runs; both build args are mandatory). Two container builds: the lint
image, then the main image, which runs `script/test` and `script/fmt-check`
but deliberately not `make check` — that would nest a docker build inside a
build step. So `script/cibuild` is what proves the branch green; a bare
`docker build .` never lints, and 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

63
TODO.md
View File

@@ -21,27 +21,48 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Moved every lint run into a container. `script/lint` now runs the - 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
linter directly when `LINT_IN_CONTAINER=1` and otherwise builds made this repo do it rather than merely document it. `script/lint` is now
`Dockerfile.lint`, so the linter never runs on a developer host — closing the `docker build -f Dockerfile.lint .` and nothing else; the linter is never
content-keyed result cache that produced a confirmed false green, the installed on the host and never invoked there, so a run cannot inherit another
host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew. checkout's content-keyed result cache, the host-global
Detection is on that marker alone: a false negative inside a container fails `$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned
loudly on the missing daemon, while a false positive on a host would silently one — the three mechanisms behind a confirmed false green, a string of
restore host linting, so `/.dockerenv` is rejected outright — measured absent findings reported against other agents' checkouts, and a container that saw
inside BuildKit `RUN` steps and present on hosts that are themselves thirteen findings the host missed. Linting runs as a build step, so a
containers. Everything else keeps its existing shape: `make check` still runs successful build is a clean lint, which also works where the docker daemon is
in the image, `script/cibuild` is still one build, and the Go multistage lint remote and bind mounts are impossible. The recursion this creates is resolved
stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the by direction rather than by detection: the main `Dockerfile` runs the
same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only individual non-lint checks instead of `make check`, and `script/cibuild` runs
the lint re-runs. The `script/bootstrap` golangci-lint install and the `script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries
per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the
repo's `yarn install` stays, since the rule is about where a verdict comes dependency layer so only the lint steps re-run — blanket `--no-cache` was
from, not about which binaries exist. `golangci-lint config verify` was kept rejected because it makes every lint reinstall its dependencies over the
on measurement: a bogus config key passes `golangci-lint run` with `0 issues` network. Two canonical forms were superseded rather than left standing beside
and fails `config verify`, and every case reproduced byte-identically under the new one, since consuming repos read this document literally: the
`--network none`, so the schema is embedded and the line costs no network. `script/bootstrap` golangci-lint install (nothing runs a host linter now, so
Comment blocks across the touched files were cut hard in the same pass. 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. Two positions are stated
rather than left as gaps, because canon that omits them gets re-derived
wrongly: `docker build` returns 1 both for findings and for a build that never
reached the lint step, and the exit-75 VOID machinery is deliberately not
restored — the dangerous direction is closed since an unrunnable lint fails
closed, BuildKit already names the failing step, and the failure is not
transient, so what survives is the reading rule that a run which did not lint
is not a verdict. And the rule is about linters: `script/fmt` and
`script/fmt-check` run on the host by necessity, which in a repo whose
formatter is its linter — this one — means that exact command does run there,
recorded as a known and bounded gap rather than papered over. Verified with
two consecutive runs on an unchanged tree both executing the linter, a planted
violation caught and reverted, the bare-build guard firing, and the main image
building without attempting a nested build.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it. - 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two

View File

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

View File

@@ -36,21 +36,23 @@ with your task.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
Dockerfile runs `make check` as a build step, and every stage containing a Dockerfile runs the **non-lint** checks as build steps (`script/test`,
check-running `RUN` declares `ARG CHECK_EPOCH` with the `script/fmt-check`), and every stage containing a check-running `RUN`
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is guard immediately below it — see the `CHECK_EPOCH` rule in
served from cache on an unchanged tree and the build reports a green it `REPO_POLICIES.md`. Without them the check layer is served from cache on
never ran. an unchanged tree and the build reports a green it never ran.
- [ ] **Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`** — the lint - [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint`
stage and the build stage both. This is the item an existing repo most stage or a `COPY --from=lint ... /dev/null` ordering line. This is the
often fails after adopting the containerised lint: without it item an existing repo most often fails: `script/lint` is now a
`script/lint` tries to build `Dockerfile.lint` from inside a build step, `docker build`, so both of those nest a docker build inside a build step.
where there is no daemon. Delete the stage; `script/cibuild` running `script/lint` first is what
- [ ] `Dockerfile.lint` exists and `script/lint` builds it when not already in a replaces its fail-fast purpose.
container — see the containerised-lint rule in `REPO_POLICIES.md`. Base - [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the
image pinned by sha256 with a version/date comment, `ARG CHECK_EPOCH` containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
**after** the dependency layer with the guard below it. base image is pinned by sha256 with a version/date comment, it carries
`ARG CHECK_EPOCH` **after** the dependency layer with the guard below it,
and it invokes the linter directly rather than through `make lint`.
- [ ] `.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
@@ -109,24 +111,33 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` is the canonical detect-and-branch form, and no host - [ ] `script/lint` is the canonical container build and nothing else, and no
invocation anywhere in the repo can produce a lint **verdict** — grep for host invocation anywhere in the repo can produce a lint **verdict** — grep
the linter's own name across `script/`, the `Makefile` and CI config, not for the linter's own name across `script/`, the `Makefile` and CI config,
just `script/lint`. A second path is likeliest here: a `make lint-fast`, not just in `script/lint`. An existing repo is where a second path to the
an older container-versus-host branch, or a CI step calling the binary linter is likeliest to exist: a `make lint-fast`, a container-versus-host
directly. **Expected hits that are not the defect**: `script/fmt`, and in branch, or a CI step that calls the binary directly. **Two hits are
a repo whose formatter is also its linter, `script/fmt-check`. Everything expected and are not the defect**, so triage rather than delete: any
else the grep finds is a real second path and goes. `script/fmt` (a formatter must run on the host — that is its job), and, in
- [ ] Detection is on `LINT_IN_CONTAINER` alone. Reject any `/.dockerenv` or a repo whose formatter is also its linter, `script/fmt-check`, whose
cgroup heuristic: absent in BuildKit `RUN` steps, present on hosts that command will be the same string as the one in `Dockerfile.lint`. See the
are themselves containers, and a false positive lints on the host. scope note in the containerised-lint rule. Everything else the grep finds
- [ ] `script/bootstrap` installs no golangci-lint. Delete the block, its is a real second path and goes.
version and ref variables, and its call site. A JS repo's `yarn install` - [ ] `script/bootstrap` installs no linter **for the purpose of linting**.
stays — it brings a linter along with every other dependency, which is Delete a dedicated install — the golangci-lint block, its version and ref
fine as long as no verdict is taken from it. variables, and its call site: nothing invokes a host linter any more, so
all it can still do is put a differently versioned binary where somebody
runs it by hand and believes the result. A JS repo's `yarn install` stays;
it brings a linter along with every other dependency, which is unavoidable
and harmless as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR` - [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`. `.gitignore` and `.dockerignore`. A container has its own cache and its
own lock, so keeping the wrapper leaves two contradictory `script/lint`
forms in the fleet.
- [ ] `script/cibuild` runs `script/lint` before the main `docker build`.
Without that line CI never lints at all, because the main image
deliberately does not.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 30-second timeout - [ ] `make test` has a 30-second timeout
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile - [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
@@ -175,7 +186,7 @@ with your task.
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE` - [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
both times, never `CACHED` and never sub-second both times, never `CACHED` and never sub-second
- [ ] `script/cibuild` succeeds (a bare `docker build .` or - [ ] `script/cibuild` succeeds and runs both container builds (a bare
`docker build -f Dockerfile.lint .` fails closed by design, on the `docker build .` or `docker build -f Dockerfile.lint .` fails closed by
`CHECK_EPOCH` guard) design, on the `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

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

View File

@@ -60,7 +60,8 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs repo root, runs `script/lint` first for fail-fast feedback (that is itself a
container build — see the containerised-lint rule below), and then runs
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
`version` is computed on the host because `.git` is not in the build context `version` is computed on the host because `.git` is not in the build context
@@ -93,24 +94,31 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`. It must run the repo's **non-lint**
as a build step so the build fails if the branch is not green — the one checks as build steps, so the build fails on a branch those checks reject —
exception being `Dockerfile.lint`, which runs `make lint` alone because that which requires `ARG CHECK_EPOCH` and its guard in every stage containing a
is its entire purpose — which requires `ARG CHECK_EPOCH` and its guard in check-running `RUN`, per the `CHECK_EPOCH` rule below. Without them a
every stage containing a check-running `RUN`, per the `CHECK_EPOCH` rule Dockerfile satisfies this criterion while its check layers are served from
below. Without them a Dockerfile satisfies this criterion while its check cache, so it cannot fail on a branch those checks would have rejected.
layers are served from cache, so the build cannot fail on a branch that is not
green.
**Every Dockerfile must also set `ENV LINT_IN_CONTAINER=1`**, above the **A green `docker build .` does not mean the branch is green**, because this
checks. `script/lint` builds `Dockerfile.lint` when it is not already in a file does not lint. Only `script/cibuild` carries that meaning: it runs
container; without the marker it would try that from inside a build step, `script/lint` and then this build. Do not restate the older, stronger claim
where there is no daemon. See the containerised-lint rule below. anywhere — it was true only while lint ran inside this image.
**It runs the individual non-lint checks — `script/test` and
`script/fmt-check` — and never `make check`.** `script/lint` is itself a
`docker build` (of `Dockerfile.lint`, per the containerised-lint rule
below), so a `RUN make check` in this file attempts a docker build inside a
build step, where there is no daemon. Lint is not skipped by this: it runs
in its own container, and `script/cibuild` runs it first. Put a comment to
that effect directly above those `RUN` lines, because `make check` is what
the next person will reach for.
For non-server repos, the Dockerfile should bring up a development For non-server repos, the Dockerfile should bring up a development
environment and run `make check`. For server repos, `make check` should run environment and run those checks. For server repos, they should run as an
as an early build stage before the final image is assembled. Dockerfiles early build stage before the final image is assembled. Dockerfiles install
install development prerequisites by running `script/bootstrap` rather than development prerequisites by running `script/bootstrap` rather than
duplicating installs inline; COPY `script/` and the dependency manifests duplicating installs inline; COPY `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it
so the bootstrap layer stays cached until dependencies change. so the bootstrap layer stays cached until dependencies change.
@@ -120,23 +128,18 @@ style conventions are in separate documents:
unchanged tree the check layer is served from cache, the suite never runs, and unchanged tree the check layer is served from cache, the suite never runs, and
the build still exits 0. A sub-second `docker build` reporting success is a the build still exits 0. A sub-second `docker build` reporting success is a
cache hit, not a result. This applies to **every** file that runs checks in a cache hit, not a result. This applies to **every** file that runs checks in a
build step`Dockerfile` and `Dockerfile.lint` alike; a `Dockerfile.lint` build step, which since linting moved into its own container means
without the cache-bust is a lint that never ran, reported as a pass. The `Dockerfile` and `Dockerfile.lint` both — a `Dockerfile.lint` without the
canonical form, in **every** stage containing a check-running `RUN`, placed cache-bust is a lint that never ran, reported as a pass. The canonical form,
**after** the dependency-install layer so that layer stays cached: in **every** stage containing a check-running `RUN`, placed **after** the
dependency-install layer so that layer stays cached:
```dockerfile ```dockerfile
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}" && make check RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command>
``` ```
`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`: and in both `script/cibuild` and `script/docker`:
```sh ```sh
@@ -150,9 +153,10 @@ style conventions are in separate documents:
``` ```
`script/lint` needs the same nonce but is **not** this command: it builds a `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 different file and passes no version. Do not copy the block above into it —
form from the containerised-lint rule below, not this block — a a `docker build` with no `-f Dockerfile.lint` builds the main image instead,
`docker build` with no `-f` builds the main image and lints nothing. which is a lint that silently lints nothing. Its canonical form is in the
containerised-lint rule below; copy that one.
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
@@ -184,70 +188,123 @@ style conventions are in separate documents:
concurrent invocations would collide. concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving This invalidates the check layers and everything after them while leaving
`go mod download` and `script/bootstrap` cached, so it does not push against `go mod download`, `script/bootstrap`, and the pinned toolchain install
the five-minute Docker build ceiling. Blanket `--no-cache` is not an cached, so it does not push against the five-minute Docker build ceiling.
acceptable substitute: it also busts the dependency layer, so every run Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or
reinstalls dependencies over the network instead of only the first and those on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every
after a manifest change. Never reach for `docker builder prune` — the build invocation, so a lint that should take seconds pays a dependency install
cache is shared with every other build on the host. each time and reaches the network on **every** run rather than only on the
first and after a manifest change. It is a difference of degree, not an
absolute — a container build is never fully offline-independent — but it is
the difference between a lint that usually needs nothing and one that always
does. Never reach for `docker builder prune` to achieve the same end: the
build cache is shared with every other build on the host, including other
people's.
- **Every lint run happens in a container.** `script/lint` runs the linter - **Every lint run happens in a container, and `script/lint` is that container
directly when it is already inside one, and otherwise builds `Dockerfile.lint` build.** The precise claim, because it is the one that has to survive contact
so that it is. Either way the linter never runs on a developer host, where its with a repo whose formatter and linter are the same binary: **no lint verdict
answer is not trustworthy: may come from a host invocation.** No `script/`, no `Makefile` target and no
- **Confirmed false green.** golangci-lint keys cached results on file CI step may produce a lint result by running a linter on the host. That is
**content, not location**, so a second checkout of the same commit serves stronger than it sounds and weaker than "the binary is never on the host" —
its findings. One implementer reported `0 issues` on a branch genuinely see the scope note at the end of this rule, which says exactly which host
red with a `goconst` finding. Own-clones-instead-of-worktrees does not invocations remain legitimate and why. Every repo carries a `Dockerfile.lint`
help; two clones are byte-identical exactly as two worktrees were. next to its `Dockerfile`; the linter runs as a **build step**, so a successful
- **False reds**: findings reported against other checkouts and against build _is_ a clean lint. Building rather than bind-mounting is deliberate: it
worktrees already deleted; in one case 399 issues returned to a clean is what makes the pattern work unchanged where the docker daemon is remote and
clone that genuinely lints 0. bind mounts are impossible. Docker is assumed available in every environment.
- **Lock contention indistinguishable from findings.** golangci-lint flocks Discarding the linter's cache on every run is the point of this rule, not a
`$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`, `acquireFileLock()`), cost it pays.
host-global and independent of `GOLANGCI_LINT_CACHE`, 5-second timeout. It
prints `parallel golangci-lint is running`, analyzes nothing, exits
non-zero. Not fixed by per-cache isolation — measured.
- **Version skew**: a host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed.
A container has its own cache, its own `TMPDIR` and a binary pinned by This closes a family of defects, every one of them an artefact of running
digest, so none of it is reachable. This supersedes the per-checkout the linter on a shared host, and every one of them observed rather than
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper, which existed only to make a host hypothesised:
run trustworthy; delete it on adoption. - **A confirmed false green.** An implementer reported `0 issues` on a
branch that was genuinely red with a `goconst` finding. golangci-lint keys
cached results on file **content, not location**, so a second checkout of
the same commit holds byte-identical files and serves its result. Note
what content-keying implies: moving agents from worktrees into their own
clones does **not** help, because two clones are byte-identical exactly as
two worktrees were. It removes the foreign-path symptom and leaves the
mechanism live, which makes the defect quieter rather than rarer.
- **False reds**, repeatedly: findings reported against `../wt82-lint/...`,
against another agent's checkout, and against a worktree that had already
been deleted; in one case 399 issues returned to a clean clone that
genuinely lints 0.
- **Lock contention that cannot be distinguished from findings.**
golangci-lint flocks `$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`,
`acquireFileLock()`) — host-global, keyed on the temp directory, entirely
independent of `GOLANGCI_LINT_CACHE`, with a 5-second acquire timeout, so
it fails precisely when the host is busiest. On failure it prints
`parallel golangci-lint is running`, analyzes nothing, and exits non-zero.
**Proven not fixed by per-cache isolation**: two concurrent runs with
entirely separate cache directories still collided.
- **Version skew.** A host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed on one repo, and a
local `make check` green against a `make docker` that rejected the same
commit with six `goconst` findings.
The canonical `script/lint`, whose executable lines are the same in every A container per run has its own cache, its own `TMPDIR` and therefore its
repo apart from the native lint command: own lock, and a binary pinned by digest, so none of the above is reachable.
That is also why the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper
that used to be canonical here is **gone rather than kept alongside this**:
its entire subject was making a host run trustworthy, and there are no host
runs. Consuming repos delete it when they adopt this; see the adoption list
at the end of this rule.
The canonical `Dockerfile.lint` for a Go repo:
```dockerfile
# Lint-only image. `script/lint` builds this file and nothing else: the
# linter runs as a build step, so a successful build IS a clean lint.
#
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
golangci-lint config verify --config .golangci.yml
RUN golangci-lint run --config .golangci.yml ./...
```
and the canonical `script/lint`, whose executable lines are identical in
every repo (only the comment wording is a repo's own):
```sh ```sh
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Inside a container, run it directly; on a # script/lint: run the linter. This is the ONLY source of a lint verdict
# host, build Dockerfile.lint so it runs in one anyway. # in this repo — the linter runs in a container, one way, everywhere, 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 # A failure here that names no finding is NOT a lint result: docker
# accepted signal. Do not add a /.dockerenv fallback: it is absent inside # build exits 1 both for findings and for a build that never reached
# BuildKit RUN steps and present on hosts that are themselves containers, # the lint step. BuildKit names the failing step; read it, fix the
# so it both misses and false-positives — and a false positive silently # environment, and re-run.
# restores host linting.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then # not trip `set -e`, and `$$` is required because busybox `date`
# config verify lives here, not in a Dockerfile, so every path # drops %N without erroring. Without a fresh nonce the lint layer is
# that lints inherits it — the lint stage of the main image as # served from cache and this script exits 0 having linted nothing.
# 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" \
@@ -258,138 +315,154 @@ style conventions are in separate documents:
main "$@" main "$@"
``` ```
and `Dockerfile.lint`, the standalone path for a developer host:
```dockerfile
# Lint-only image, built by script/lint when not already in a container.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# ARG after the dependency layer so only the lint re-runs.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint
```
Load-bearing properties: Load-bearing properties:
- **Detection rests on `LINT_IN_CONTAINER=1` and nothing else.** Every
Dockerfile in the repo sets it; a host does not. The asymmetry is the
whole design: a **false negative** inside a container tries a nested
`docker build`, finds no daemon and fails loudly, while a **false
positive** on a host silently lints there — the exact defect this rule
exists to kill. So the signal must be one only our own images can produce.
`/.dockerenv` is not such a signal and must not be used, even as a
fallback: measured, it is **absent** inside BuildKit `RUN` steps and
**present** on any host that is itself a container, which is the common
case for CI runners and agent sandboxes. It fails in both directions, and
one of them is the dangerous one.
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .` - **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted on an unchanged tree returns a sub-second cached success having linted
nothing. The `ARG` goes **after** the dependency layer so only the lint nothing — the same false green the `CHECK_EPOCH` rule above exists to
re-runs; `--no-cache` would also reinstall dependencies on every lint. close, arriving through a new file. The `ARG` goes **after** the
dependency layer so `go mod download` / `yarn install` stay cached and
only the lint steps re-run. Blanket `--no-cache` also busts the dependency
layer, so every lint reaches the network instead of only the first one.
- **Non-Go repos get the same pattern around their own linter** — `eslint`, - **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck`. Only the base image and the native lint `ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
command change. not every Go lint run. Only the base image and the lint commands change;
- **Keep `golangci-lint config verify`, put it in `script/lint`, and it the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
costs no network.** It goes in the native branch, not in a Dockerfile, so expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
the lint stage of the main image inherits it along with `Dockerfile.lint`; node image, runs `script/bootstrap` as the dependency layer **inside the
putting it in one Dockerfile leaves the other path unverified. The two image**, and lints with the linter from that image's `node_modules`. Note
commands catch disjoint classes, measured under the pinned v2.12.2: a what this does not claim: a developer also runs `script/bootstrap` on the
bogus top-level key and a bogus key under `linters.settings.lll` both pass host, so a copy of that linter exists there too. What the rule forbids is
`golangci-lint run` with **exit 0 and `0 issues`** while `config verify` taking a **verdict** from it.
exits 3 and names them; an invalid value type fails both; an unknown - **Keep `golangci-lint config verify`, and it costs no network.** The two
linter name fails `run` and passes `config verify`. So `run` alone commands catch **disjoint** classes of defect, measured under the pinned
silently ignores an unknown key — the mode where a threshold reads as v2.12.2 against a config carrying one planted defect at a time: a bogus
configured and is not applied. It needs no network: every case reproduced top-level key and a bogus key nested under `linters.settings.lll` both
byte-identically under `docker run --network none`, in a container where 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 `getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin. pinned binary. Re-run that control when bumping the pin rather than
- **A failed `script/lint` that names no finding is not a lint result.** On treating the result as permanent.
the host path `docker build` exits 1 both for findings and for a build - **No repo installs a linter on the host _in order to lint_ — remove any
that never got there (daemon down, image unpullable, disk full). BuildKit such install from `script/bootstrap`.** A dedicated linter install (the
names the failing step; read it, fix the environment, re-run. Do not `go install` of golangci-lint is the case that existed) is now dead weight
record a verdict from a run that did not lint. whose only remaining effect is to reintroduce the version skew above. This
does not forbid a host dependency install that happens to bring a linter
along with everything else, which is unavoidable in a JS repo and is fine
as long as no verdict is taken from it.
- **A failed `script/lint` that names no finding is not a lint result.** The
exit status alone cannot tell you which happened: `docker build` returns 1
both when the lint step fails on findings and when the build never got
that far — daemon unreachable, base image unpullable, disk full,
dependency layer failing. The per-checkout wrapper this rule replaced drew
that line explicitly, exiting 75 for a run that analysed nothing, and that
machinery is deliberately **not** restored. Three reasons, and they are a
position rather than an omission:
- **The dangerous direction is already closed.** The wrapper's exit 75
existed because a lock collision could be read as a _result_ on a run
that analysed nothing. A build that cannot run fails **closed**: it
can never read as clean. What is lost is diagnosability, not safety.
- **The failure is self-describing, where the lock collision was not.**
BuildKit prints the failing step verbatim —
`ERROR: failed to solve: process "/bin/sh -c <the lint command>"` for
a genuine finding, against a named earlier step or a daemon error for
anything else. The discriminator is already in the output and needs no
code. The lock message, by contrast, went to stderr while findings
went to stdout and was easy to lose.
- **It is not transient, so retrying is wrong.** The lock collision
cleared on a retry, which is what made an automatic retry worth
building. A dead daemon or a full disk does not, and a retry loop over
a failing dependency fetch hides a real reproducibility problem.
Rebuilding the distinction in the script would also mean reintroducing
per-invocation capture files and traps to get the exit status out from
under a pipe, plus matching on BuildKit's message format, which is not
a stable interface — and a mis-match in the "treat it as
infrastructure" direction would be the false green this whole rule
exists to prevent.
The substance of the VOID rule survives as a reading rule, and it is
binding: **do not record a lint verdict from a run that did not reach
the lint step, and do not "fix" anything on the strength of one.** Read
which step failed, fix the environment, and re-run.
- **`script/check` still runs `test`, `lint` and `fmt-check`**, so a
developer and the pre-commit hook get all three. It therefore requires a
docker daemon, and it must never be invoked from inside a build stage —
see the `Dockerfile` rule above.
- If the project uses `//go:embed` directives referencing build artifacts
(e.g. a web frontend compiled elsewhere), `Dockerfile.lint` must create
placeholder files so the directives resolve:
`RUN mkdir -p web/dist && touch web/dist/index.html`. It must not depend
on the real build output; it exists to fail fast.
- If linting requires CGO or system libraries (e.g. `vips-dev`), install
them in `Dockerfile.lint`.
**Scope: this rule is about linters, and a formatter is not one.** **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` writes to your working tree, so it can only run on the host;
`script/fmt-check` is its read-only twin. In a repo whose formatter **is** `script/fmt-check` is its read-only twin and runs on the host too, as well
its linter (prettier over markdown; this repo), `script/bootstrap` therefore as inside the main image. Neither is a lint run and neither is in scope.
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 **The awkward case, stated rather than left for an adopter to trip over: in
a repo whose formatter _is_ its linter** — prettier over a markdown or docs
repo is the standard shape, and this repo is one — the command in
`Dockerfile.lint` and the command in `script/fmt-check` are the same string,
so that exact command does still run on the host. That is a real gap in the
absolute reading and it is accepted for two reasons: the mechanisms this
rule exists to close do not reach it (prettier's version is pinned in
`package.json` and installed into the repo's own `node_modules`, so there is `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 no shared content-keyed cache, no host-global lock, and no version to skew
against. What is forbidden is taking a **lint verdict** from it — against), and CI's verdict is containerised regardless, because
`script/lint` stays the only source of one. A repo auditing itself will see `script/fmt-check` also runs inside the main image build. What is **not**
those hits and should leave them; anything else the grep finds is a real acceptable is taking a lint verdict from the host copy: `script/lint` stays
second path to the linter and goes. the only source of one. If a repo's linter and formatter ever diverge in
version or configuration, this gap becomes a real defect and the check-mode
invocation has to move into the container too.
**What a consuming repo does to adopt this**, in order: **What a consuming repo does to adopt this**, in order: add
1. Add `Dockerfile.lint`. `Dockerfile.lint`; replace `script/lint` with the build above; delete the
2. Replace `script/lint` with the form above, with its own native lint `lint` stage from its `Dockerfile` along with the
command. `COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
3. Add `ENV LINT_IN_CONTAINER=1` to **every** stage of every Dockerfile that `RUN make check` to `script/test` and `script/fmt-check` with the comment
runs checks — the lint stage and the build stage both. explaining why; add `script/lint` as the first step of `script/cibuild`;
4. Delete any golangci-lint install from `script/bootstrap`, with its delete any golangci-lint install from `script/bootstrap`; and delete the
version and ref variables and its call site. No lint verdict comes from `.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
the host any more, so it can only reintroduce version skew. A JS repo's the per-checkout cache/lock wrapper they served.
`yarn install` stays.
5. Delete the per-checkout lint state: `GOLANGCI_LINT_CACHE` and `TMPDIR`
exports, `--allow-serial-runners`, the retry/VOID wrapper, and
`.lint-cache/` from both `.gitignore` and `.dockerignore`.
6. Verify by running `make lint` twice on an unchanged tree: the lint layer
must be `DONE` both times, never `CACHED`. Then plant a violation,
confirm it fails naming the finding, revert. A bare
`docker build -f Dockerfile.lint .` must fail on the guard.
`script/check`, `script/cibuild`, `script/docker` and the `Dockerfile` are **The separate lint _stage_ is superseded by this and must not survive
unchanged by this: `make check` still runs inside the image, and alongside it.** It ran `make lint`, which is now a docker build, so keeping
`script/lint` there takes the native path. 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.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **The canonical Go repo `Dockerfile`**, which builds and tests but does not
repos use a multistage build where linting runs in an independent stage based lint:
on the `golangci/golangci-lint` image (pinned by hash), so lint failures
surface in seconds rather than after a full compile. The build stage declares
an explicit dependency on it via `COPY --from=lint /src/go.sum /dev/null`,
which forces BuildKit — which runs stages in parallel by default — to finish
linting first. The canonical Go repo `Dockerfile`:
```dockerfile ```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
WORKDIR /src WORKDIR /src
ENV LINT_IN_CONTAINER=1
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# The individual non-lint checks, NOT `make check`: script/lint is a
# docker build (Dockerfile.lint), so `make check` here would nest a
# build inside a build step, where there is no daemon. Lint is not
# skipped — script/cibuild runs it first, in its own container.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/fmt-check
RUN script/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
@@ -406,48 +479,40 @@ style conventions are in separate documents:
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it has - Tests run in the build stage because they may require compiled artifacts
both Go and the linter), so nothing needs installing. `make lint` there or heavier dependencies.
runs `script/lint`, which sees `LINT_IN_CONTAINER=1` and invokes - `ARG CHECK_EPOCH` must be declared in **every** stage containing a
`golangci-lint` natively instead of building `Dockerfile.lint`. Without check-running `RUN`, because `ARG` is stage-scoped: declaring it in one
that `ENV` the stage would attempt a nested build and fail. stage leaves the others frozen at their last cached result while the fix
- `COPY --from=lint /src/go.sum /dev/null` is a no-op copy that exists only reviews as complete. In each such stage the guard sits immediately below
to create the stage dependency; without it a lint failure might not fail the `ARG`, and the value is expanded into the first check `RUN` so the
the overall build. cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.** lines reference `$CHECK_EPOCH`, so each stage has two independent
The cache-bust turns the no-op `COPY` into a content-cache hit, so an invalidation points. Later `RUN`s in the same stage need no expansion of
ordering guarantee established cold does not automatically carry over. It their own: their parent layer is already busted.
was re-proved in another org repo using the same trick and held, but that - `ARG VERSION=dev` is declared in the build stage, and its value is
result does not transfer by assumption — re-check it warm. supplied on the host by `script/docker` and `script/cibuild` via
- If the project uses `//go:embed` referencing build artifacts, the lint `--build-arg VERSION=...`. The `dev` default is a placeholder for a local
stage must create placeholders so the directives resolve: build, not a source of truth. **No stage may call `git describe`**:
`RUN mkdir -p web/dist && touch web/dist/index.html`. `.dockerignore` excludes `.git`, so it yields an empty version without
- If linting needs CGO or system libraries (e.g. `vips-dev`), `apk add` them failing. See the git-describe rule further down.
in the lint stage.
- Tests run in the build stage, not the lint stage: they may need compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at its last cached result. In each stage the guard sits immediately
below the `ARG` and the value is expanded into the first check `RUN`.
Later `RUN`s in the same stage need no expansion; their parent layer is
already busted.
- `ARG VERSION=dev` is declared in the build stage and supplied by
`script/docker` and `script/cibuild`. **No stage may call
`git describe`**: `.dockerignore` excludes `.git`, so it yields an empty
version without failing. See the git-describe rule further down.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `script/cibuild` on push. `script/cibuild` runs **two** container builds:
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) `script/lint` (`Dockerfile.lint`) first, then
on push. The Dockerfile runs `make check`, so a successful build implies all `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
checks pass — but that implication holds **only** because of the `CHECK_EPOCH` for the main image, which runs the non-lint checks. A successful
cache-bust described above. Without it, an unchanged tree serves the check `script/cibuild` therefore implies all checks pass; **a successful
layer from cache and the build reports a green it never earned. A bare `docker build .` on its own does not, because it never lints.** That is the
`docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; one claim to be careful with when reading these files: the guarantee belongs
always go through `script/cibuild` or `script/docker`. Never accept a pass as to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the because each build passes its own `CHECK_EPOCH` nonce — without it an
check layer, means nothing was executed. unchanged tree serves the layers from cache and the build reports a green it
never earned. A bare `docker build .` or `docker build -f Dockerfile.lint .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go
through `script/cibuild`, `script/docker` or `script/lint`. Never accept a
pass as evidence without confirming it ran: a sub-second wall time, or
`CACHED` on a check or lint layer, means nothing was executed.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -674,21 +739,23 @@ style conventions are in separate documents:
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports which reports
`golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That `golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That
digest is the only pin: golangci-lint is not installed on the host by any digest is the only pin there is: the linter is not installed on the host, in
repo. Bumping the version means changing that one digest. `script/bootstrap` or anywhere else. Bumping the version means changing that
one digest, and it propagates to every consumer of the image with no host
state able to disagree with it.
- **`script/bootstrap` must not install golangci-lint.** This supersedes the - **`script/bootstrap` must not install a linter at all.** This supersedes the
pinned host install that used to be canonical here. `script/lint` never runs pinned-golangci-lint install that used to be canonical here. Nothing runs a
it on the host — it either builds `Dockerfile.lint` or is already in a linter on the host any more — `script/lint` is a container build — so a host
container that ships the binary — so a host install has no caller, and its install has no caller left, and its only remaining effect is to put a second,
only remaining effect is to put a second, independently-versioned linter where independently-versioned linter on the machine where somebody will eventually
somebody eventually runs it by hand and believes the result. Delete the block, run it by hand and believe the result. The version-skew failures that install
its version and ref variables, and its call site. was written to close (a local `make check` green while `make docker` rejected
the same commit with six `goconst` findings; a container linter surfacing
This is not a ban on host dependency installs generally. A JS or docs repo's thirteen findings the host run missed) are closed more completely by having
`script/bootstrap` runs `yarn install`, which brings its linter along with exactly one linter, pinned by image digest, that no host state can shadow.
every other dependency; that is unavoidable and fine. The rule is about a Repos adopting the containerised lint delete the install block, its version
**dedicated** linter install, and about where a verdict may come from. and ref variables, and its call site from `script/bootstrap`.
**The version-enforcement principle it established still applies to any **The version-enforcement principle it established still applies to any
other tool a repo pins and installs on the host**, and it is the part worth other tool a repo pins and installs on the host**, and it is the part worth
@@ -733,15 +800,28 @@ style conventions are in separate documents:
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
- **Superseded: the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for - **SUPERSEDED, and deleted rather than kept: the per-checkout
`script/lint`.** It existed only to make a host lint run trustworthy, and the `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it
containerised-lint rule above removes the host run. Delete the wrapper, the was about making a linter run on a shared host trustworthy — a private result
`--allow-serial-runners` flag, and `.lint-cache/` from both `.gitignore` and cache so a byte-identical checkout could not serve its findings, a private
`.dockerignore`. Two of its conclusions outlive it: **`GOCACHE` does not need `TMPDIR` so the host-global lock could not collide, retry and VOID handling so
isolating** (measured — content-addressed, no foreign paths in its entries, no a lock collision was never reported as findings. The containerised-lint rule
global lock), and **verifying lint plumbing requires paired controls** run above removes the host run itself, so there is nothing left for that wrapper
against the artifact as a consuming repo would adopt it, since a control that to isolate, and a repo carrying both would carry two contradictory canonical
passes against the broken form proves nothing. `script/lint` forms. Its findings are not lost: they are the evidence for
containerising, and they are recorded in that rule. Repos that adopted it
delete the wrapper, the `.lint-cache/` entries from `.gitignore` and
`.dockerignore`, and the `--allow-serial-runners` flag with them.
Two of its conclusions are kept because they outlive it. **`GOCACHE` does
not need isolating**, measured rather than assumed: it is content-addressed,
its entries are compiled artifacts rather than diagnostics carrying a
foreign tree's paths, and it has no equivalent global lock — the whole fleet
compiles concurrently against one `GOCACHE` all day without a contention
error. And **verifying any change to lint plumbing requires paired
controls**: a control that passes against the broken form proves nothing,
and it must be run against the artifact as a consuming repo would adopt it —
the file executed, not the functions sourced and driven by hand.
- **Interim rule for reading a lint result produced on the host, in a repo that - **Interim rule for reading a lint result produced on the host, in a repo that
has not yet adopted the containerised lint above.** A lint run is **VOID** has not yet adopted the containerised lint above.** A lint run is **VOID**

View File

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

View File

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

View File

@@ -4,6 +4,12 @@
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh # Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a # nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing. # green that proves nothing.
#
# Those checks are the NON-LINT ones. Linting left this image: it runs
# in its own container, built by script/lint, because script/lint is a
# docker build and cannot run inside one. So a green here does not mean
# the branch is green — script/cibuild, which runs script/lint first, is
# what means that.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,13 +17,19 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Both assignments on their own line: a failing command substitution # Assign on its own line: a failing command substitution inside an
# inside an argument does not trip `set -e`, so the inline form # argument does not trip `set -e`, which would silently degrade the
# degrades silently to an empty constant. `$$` because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. VERSION is computed here because # drops %N without erroring.
# .dockerignore excludes .git, so `git describe` in a build stage
# yields an empty version without failing.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build \ docker build \

View File

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