1 Commits

Author SHA1 Message Date
3933e6bdfa Add -count=1 to the canonical Go make test example (closes #44)
All checks were successful
check / check (push) Successful in 13s
The canonical Go `test` target in `REPO_POLICIES.md` omitted `-count=1`, so
Go replayed cached successful results and the target could exit 0 having
executed no test. Every repo that copied it inherited the false green.

Both invocations get the flag; the rerun needs it so a failure is
reproduced rather than replayed.
2026-08-10 13:51:34 +00:00
15 changed files with 110 additions and 1279 deletions

View File

@@ -1,97 +1,3 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match
# semantics plus a `**` extension, compiled to a regexp. Plain
# filepath.Match has no `**` at all. What follows from that: `*` does not
# cross `/`, and a pattern without a leading `**/` is anchored at the
# build-context root. Every depth-independent pattern therefore needs the
# `**/` prefix — without it `config/.env` and `certs/server.key` still
# ship while the file reads as solved.
#
# Root-anchored entries are for paths that occur exactly once, at the
# 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.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive
# 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.
# Repository metadata: exactly one, at the context root. Excluding it
# 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
node_modules
# In-repo agent scratch: a directory holding a full additional checkout .DS_Store
# of the repo for each in-flight agent. Anchored because it occurs
# exactly once *where agents run at the repo root*, which is the
# 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
# Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][dD]25519
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# 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
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*

6
.gitignore vendored
View File

@@ -11,12 +11,6 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/

View File

@@ -12,26 +12,4 @@ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
RUN make check
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# 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,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# The individual non-lint checks, NOT `make check`. Lint is deliberately
# absent here: `script/lint` is itself a `docker build` (of
# Dockerfile.lint), so running `make check` in this image would attempt
# a docker build inside a build step, where there is no daemon. Putting
# `make check` back reintroduces exactly that recursion. Lint is not
# skipped — script/cibuild runs script/lint first, in its own container,
# before this build starts.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

View File

@@ -1,41 +0,0 @@
# 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.
# Building rather than bind-mounting is what makes it work where the
# 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
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
# Without that ordering the cache-bust would reinstall dependencies on
# every lint and make linting network-dependent.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# it an unchanged tree serves the lint layer from cache and the build
# reports a lint it never ran — a green that proves nothing, which is
# 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
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always

View File

@@ -117,32 +117,15 @@ alpine. We provide:
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here) - `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The - `script/lint` — lint the markdown files with prettier
lint verdict comes only from the container; nothing on the host produces one.
(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). Needs a docker daemon, since `script/lint` is a container build extension)
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as (byte-identical across repos)
`script/cibuild` - `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
- `script/cibuild` — cd to the repo root, run `script/lint` first, then assign image build runs `script/check`)
`epoch="$(date +%s%N)$$"` and `version="$(git describe ...)"` on their own
lines and
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
(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

132
TODO.md
View File

@@ -21,135 +21,9 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and - 2026-08-10: Added `-count=1` to both `go test` invocations in the canonical Go
made this repo do it rather than merely document it. `script/lint` is now `make test` example in `REPO_POLICIES.md`, so the target cannot report a
`docker build -f Dockerfile.lint .` and nothing else; the linter is never cached pass it did not earn.
installed on the host and never invoked there, so a run cannot inherit another
checkout's content-keyed result cache, the host-global
`$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned
one — the three mechanisms behind a confirmed false green, a string of
findings reported against other agents' checkouts, and a container that saw
thirteen findings the host missed. Linting runs as a build step, so a
successful build is a clean lint, which also works where the docker daemon is
remote and bind mounts are impossible. The recursion this creates is resolved
by direction rather than by detection: the main `Dockerfile` runs the
individual non-lint checks instead of `make check`, and `script/cibuild` runs
`script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries
the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the
dependency layer so only the lint steps re-run — blanket `--no-cache` was
rejected because it makes every lint reinstall its dependencies over the
network. Two canonical forms were superseded rather than left standing beside
the new one, since consuming repos read this document literally: the
`script/bootstrap` golangci-lint install (nothing runs a host linter now, so
it can only reintroduce skew; the version-enforcement principle stays
documented for other pinned host tools) and the per-checkout
cache/lock/`.lint-cache` wrapper (its whole subject was making a host run
trustworthy). The Go multistage lint stage goes with them: it ran `make lint`,
which is now a docker build. `golangci-lint config verify` was kept on
measurement, not preference — a bogus config key passes `golangci-lint run`
with `0 issues` and fails `config verify`, and every case reproduced
byte-identically under `docker run --network none`, so the schema is embedded
in the pinned binary and the line costs no network. 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.
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
are separate defects and the second is the one that gets dropped: the result
cache is keyed on file content rather than location, so checkouts holding
identical files serve each other's findings under the other's path, while the
concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of
the cache, and unaffected by isolating it. Moving workers from worktrees to
their own clones does not help either half; it only removes the foreign-path
artefact that made the defect visible. The lock error is retried rather than
surfaced, because it is not a result: it exits non-zero exactly as findings
do, and reporting it as findings sends a correct branch back for rework.
Detection is on the stderr stream and never on exit status, so a finding
quoting the lock message in source cannot be retried away, and exhaustion
exits 75 with a VOID message rather than passing or failing quietly.
`--allow-serial-runners` (which keeps the guard and queues) covers the
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners`
is rejected outright. The stdout and stderr capture files are per invocation
rather than per checkout, because serialising the linter does not serialise
the shell's redirections: two runs in one checkout — the overlap the flag
exists to support — would otherwise truncate and read each other's output,
which is the same defect one layer above where it was fixed. Both checklists
gained the corresponding items, since a half-fix that sets only the cache
reads as complete. `GOCACHE` was measured and does not need isolating.
Verified with the snippet extracted from the committed document and executed
as a consuming repo would adopt it, against paired controls: contamination
reproduced on the pre-fix form and absent on the adopted one, retry engaged,
exhaustion loud, a genuine finding still reported, and a held host lock
failing the pre-fix script while leaving the adopted one untouched.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own
`moby/patternmatcher` semantics — `**/`-prefixed so they hold at every depth,
which also fixes nested `node_modules` — rather than transplanted from
`.gitignore`, whose unprefixed form protects only the repository root while
reading as solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the
`prod.env` convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys,
every one of them case-folded with character ranges because matching is
case-sensitive and an ALL-CAPS twin per pattern still misses `Server.Key`.
`REPO_POLICIES.md` and both repo checklists now state that asymmetry and
require verification by enumerating the image rather than by reading the
patterns. Verified with a probe image before, against three naive forms
(unprefixed, lowercase-only, ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema - 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually

View File

@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-10 last_modified: 2026-03-18
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -49,20 +49,7 @@ last_modified: 2026-08-10
``` ```
```make ```make
# ?= rather than := because this `$(shell git describe ...)` is only VERSION := $(shell git describe --always --dirty)
# correct on the host. `.dockerignore` excludes `.git`, so evaluated
# inside a build stage it expands to the empty string without failing
# and the binary reports no version at all. The version is computed on
# the host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the
# value in the environment and `?=` defers to it. The canonical Go
# template in REPO_POLICIES.md instead runs `go build` directly with
# `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is
# a host-only path — but it is still `?=`, because a repo that later
# moves the build behind make must not silently start shipping an empty
# version. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
@@ -111,29 +98,12 @@ last_modified: 2026-08-10
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`: `golangci-lint`.
the linter runs in a container, always, and `golangci-lint` is not installed
on the host at all — no repo's `script/bootstrap` installs it any more. A
`golangci-lint` invoked directly on a shared host reads a result cache keyed
on file content rather than location and a host-global lock, so its answer
may belong to another checkout entirely, 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. It runs 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
the non-lint checks; linting lives in `Dockerfile.lint` and is run by linting. `docker build .` should always make sure that the code is in an
`script/cibuild` before the main build, because `script/lint` is itself a able-to-be-compiled state, linted, and any tests run. The Docker build
`docker build` and cannot run inside one. So `script/cibuild` is what should fail if linting doesn't pass.
guarantees the code is in an able-to-be-compiled state, linted, and tested —
**a successful `docker build .` on its own does not, because it never
lints.** That guarantee holds only because each build passes a
per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
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)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. 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)

View File

@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-07-06
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -24,68 +24,15 @@ with your task.
- [ ] `LICENSE` file exists and matches the README - [ ] `LICENSE` file exists and matches the README
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from - [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language - [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
artifacts, secrets) — fetch from fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing. if missing
An existing repo usually has a hand-written one that is never re-fetched,
so check the entries rather than the file's presence: `.claude/` in
particular, unanchored, so agent worktrees cannot be committed by
accident. Do not give it a `**/` prefix — that is a `.dockerignore` form
and is wrong here.
- [ ] `.editorconfig` exists — fetch from - [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); build step — fetch `.dockerignore` from
Dockerfile runs the **non-lint** checks as build steps (`script/test`, `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
`script/fmt-check`), and every stage containing a check-running `RUN` - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on
declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
guard immediately below it — see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md`. Without them the check layer is served from cache on
an unchanged tree and the build reports a green it never ran.
- [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint`
stage or a `COPY --from=lint ... /dev/null` ordering line. This is the
item an existing repo most often fails: `script/lint` is now a
`docker build`, so both of those nest a docker build inside a build step.
Delete the stage; `script/cibuild` running `script/lint` first is what
replaces its fail-fast purpose.
- [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the
containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
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
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git.
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/`
prefix. Agent worktrees are entire checkouts of the repo, so they inflate
the context by a multiple of it and can copy another session's unreviewed
work into an image layer. Confirm by enumerating the image, not by reading
the file — `.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with
a per-service agent — it still reaches the build context and the image. An
existing repo is where such a layout already exists, so check it here
rather than assuming the canonical entry covers you: add anchored entries
for the subdirectories that have one (`/services/api/.claude`), or
`**/.claude` once you have confirmed no legitimately named nested
directory would be caught.
- [ ] If the repo embeds a version in a binary, that version is computed on the
host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes
`.git`, so it yields an empty version without failing the build. A
tag-derived version additionally needs `fetch-depth: 0` on the CI checkout
step, which clones shallow and fetches no tags by default.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified.
`.dockerignore` anchors an unprefixed pattern at the context root, so the
transplanted form leaves `config/.env` and `certs/server.key` in the build
context while reading as solved — see the `.dockerignore` rule in
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference 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`
- [ ] Language-specific config: - [ ] Language-specific config:
@@ -111,33 +58,6 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` is the canonical container build and nothing else, and no
host invocation anywhere in the repo can produce a lint **verdict** — grep
for the linter's own name across `script/`, the `Makefile` and CI config,
not just in `script/lint`. An existing repo is where a second path to the
linter is likeliest to exist: a `make lint-fast`, a container-versus-host
branch, or a CI step that calls the binary directly. **Two hits are
expected and are not the defect**, so triage rather than delete: any
`script/fmt` (a formatter must run on the host — that is its job), and, in
a repo whose formatter is also its linter, `script/fmt-check`, whose
command will be the same string as the one in `Dockerfile.lint`. See the
scope note in the containerised-lint rule. Everything else the grep finds
is a real second path and goes.
- [ ] `script/bootstrap` installs no linter **for the purpose of linting**.
Delete a dedicated install — the golangci-lint block, its version and ref
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`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.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
@@ -184,9 +104,5 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE` - [ ] `docker build` succeeds
both times, never `CACHED` and never sub-second
- [ ] `script/cibuild` succeeds and runs both container builds (a bare
`docker build .` or `docker build -f Dockerfile.lint .` fails closed by
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

@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-08-09 last_modified: 2026-02-22
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -991,14 +991,7 @@ func main() {
Use ldflags to inject version information at build time: Use ldflags to inject version information at build time:
```makefile ```makefile
# ?= rather than := because this `$(shell git describe ...)` is only correct VERSION := $(shell git describe --tags --always)
# on the host: `.dockerignore` excludes `.git`, so evaluated inside a build
# stage it expands to the empty string without failing and the binary reports
# no version. The version is computed on the host by `script/docker` /
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH) BUILDARCH := $(shell go env GOARCH)
build: build:

View File

@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-07-06
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -35,11 +35,7 @@ Template files can be fetched from:
- [ ] `.gitignore` — fetch from - [ ] `.gitignore` — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for
language-specific artifacts. Extensions are written to `.gitignore`'s own language-specific artifacts
semantics, where an unanchored pattern already matches at every depth:
never add a `**/` prefix here, which is a `.dockerignore` form. The
canonical file already carries `.claude/` so agent worktrees cannot be
committed by accident.
- [ ] `.editorconfig` — fetch from - [ ] `.editorconfig` — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Makefile` — fetch from - [ ] `Makefile` — fetch from
@@ -56,44 +52,10 @@ Template files can be fetched from:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- Extend `.dockerignore` with the repo's own host-built artifacts, giving - All Dockerfiles must run `make check` as a build step
every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory. Do not transplant
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in
`REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for
the same reason as a repo-root binary; leave it that way, but note it only
covers agents running at the repo root — if this repo will run them in
subdirectories, `services/api/.claude/` is not excluded and needs its own
anchored entry.
- If the image embeds a version in a binary, the version is computed on the
host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is
declared in the stage that compiles, and **no stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without
failing the build.
- The `Dockerfile` runs the **non-lint** checks as build steps —
`script/test` and `script/fmt-check`, never `make check`. `script/lint` is
a `docker build` of `Dockerfile.lint`, so `make check` here nests a build
inside a build step, where there is no daemon. Put a comment above those
`RUN` lines saying so. Every stage containing a check-running `RUN` must
declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
guard immediately below it — see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md`. Without them the check layer is served from cache on
an unchanged tree and the build reports a green it never ran.
- Server: also builds and runs the application - Server: also builds and runs the application
- Non-server: brings up dev environment and runs those checks - Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image that `script/lint` builds. Same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` placed **after** the dependency layer so only the lint steps re-run.
Base image pinned by sha256 with a version/date comment. Go repos use
`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,50 +82,16 @@ are thin shims calling them. Model scripts:
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second - [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout) timeout)
- [ ] `script/lint` / `make lint`builds `Dockerfile.lint` and nothing else: - [ ] `script/lint` / `make lint`runs linter
`epoch="$(date +%s%N)$$"` on its own line, then
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. No
lint verdict may come from a host invocation. Copy the canonical script
from `REPO_POLICIES.md`; its executable lines are identical across repos.
Without the nonce this script exits 0 on an unchanged tree having linted
nothing, and without `-f Dockerfile.lint` it builds the main image and
lints nothing at all.
- [ ] `script/fmt` / `make fmt` — formats code (writes) - [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files. It needs a docker daemon, because `script/lint` is a modify files
container build, and it must never be called from inside a build stage
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); carries the same three `script/projectname` (byte-identical across repos)
version lines as `script/cibuild` below, and passes - [ ] `script/cibuild` — cd to repo root, `docker build .` (what CI runs)
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for
fail-fast feedback, then, each on its own line:
```sh
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
(what CI runs). The `script/lint` call is not optional: the main image does
not lint, so without it CI never lints. Both build args are mandatory, and
both assignments must be on their own line: a failing command substitution
inside an argument does not trip `set -e`, so the inline form degrades
silently to an empty constant. The `[ -n "$version" ]` line is a live check
that fires on an export with no `.git` and on a repo with no commits — keep
it, and do not collapse it into `|| echo unknown`, which makes it
unreachable. See the `CHECK_EPOCH` and git-describe rules in
`REPO_POLICIES.md` for why each element is load-bearing. A bare
`docker build .` fails closed by design, and so does a bare
`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
`script/precommit` `script/precommit`
@@ -174,11 +102,7 @@ are thin shims calling them. Model scripts:
# 4. Verify # 4. Verify
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` demonstrably runs the linter rather than returning a cached
build: run it twice on an unchanged tree and confirm the lint layer says
`DONE`, never `CACHED`, both times
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `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,12 +60,7 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/lint` first for fail-fast feedback (that is itself a repo root and runs `docker build .`; the Gitea workflow calls it. Four further
container build — see the containerised-lint rule below), and then runs
`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
`version` is computed on the host because `.git` is not in the build context
(see the git-describe rule below); the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`; what the git pre-commit hook runs, and it calls `script/check`;
@@ -94,379 +89,52 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. It must run the repo's **non-lint** - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
checks as build steps, so the build fails on a branch those checks reject — as a build step so the build fails if the branch is not green. For non-server
which requires `ARG CHECK_EPOCH` and its guard in every stage containing a repos, the Dockerfile should bring up a development environment and run
check-running `RUN`, per the `CHECK_EPOCH` rule below. Without them a `make check`. For server repos, `make check` should run as an early build
Dockerfile satisfies this criterion while its check layers are served from stage before the final image is assembled. Dockerfiles install development
cache, so it cannot fail on a branch those checks would have rejected. prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
**A green `docker build .` does not mean the branch is green**, because this - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
file does not lint. Only `script/cibuild` carries that meaning: it runs repos use a multistage build where linting runs in an independent stage based
`script/lint` and then this build. Do not restate the older, stronger claim on the `golangci/golangci-lint` image (pinned by hash). This stage runs
anywhere — it was true only while lint ran inside this image. `make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
**It runs the individual non-lint checks — `script/test` and The standard pattern for a Go repo Dockerfile is:
`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
environment and run those checks. For server repos, they should run as an
early build stage before the final image is assembled. Dockerfiles install
development prerequisites by running `script/bootstrap` rather than
duplicating installs inline; COPY `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it
so the bootstrap layer stays cached until dependencies change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
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
cache hit, not a result. This applies to **every** file that runs checks in a
build step, which since linting moved into its own container means
`Dockerfile` and `Dockerfile.lint` both — a `Dockerfile.lint` without the
cache-bust is a lint that never ran, reported as a pass. The canonical form,
in **every** stage containing a check-running `RUN`, placed **after** the
dependency-install layer so that layer stays cached:
```dockerfile ```dockerfile
ARG CHECK_EPOCH # Lint stage — fast feedback on formatting and lint issues
RUN [ -n "$CHECK_EPOCH" ] || exit 1 # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command> FROM golangci/golangci-lint@sha256:... AS lint
```
and in both `script/cibuild` and `script/docker`:
```sh
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
`script/lint` needs the same nonce but is **not** this command: it builds a
different file and passes no version. Do not copy the block above into it —
a `docker build` with no `-f Dockerfile.lint` builds the main image instead,
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
git-describe rule below; they are shown here so the two rules do not each
document half a command. All four `CHECK_EPOCH` elements are load-bearing;
none is optional, and each guards a failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (rendered as
`RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build
output.
- The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is
a stable cache key, so without it a bare `docker build .` still produces
the false green. Failed steps are never cached, so the guard fails on
every such invocation, loudly. A bare `docker build .` failing is by
design.
- Assign `epoch=` on its own line, never inline in the `--build-arg`
argument: a failing command substitution inside an argument does not trip
`set -e`, so the inline form silently degrades to an empty constant. The
`$$` suffix is required because busybox `date` drops `%N` and exits 0, so
on an alpine host the epoch would degrade to second granularity and
concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving
`go mod download`, `script/bootstrap`, and the pinned toolchain install
cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or
on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every
invocation, so a lint that should take seconds pays a dependency install
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, and `script/lint` is that container
build.** The precise claim, because it is the one that has to survive contact
with a repo whose formatter and linter are the same binary: **no lint verdict
may come from a host invocation.** No `script/`, no `Makefile` target and no
CI step may produce a lint result by running a linter on the host. That is
stronger than it sounds and weaker than "the binary is never on the host" —
see the scope note at the end of this rule, which says exactly which host
invocations remain legitimate and why. Every repo carries a `Dockerfile.lint`
next to its `Dockerfile`; the linter runs as a **build step**, so a successful
build _is_ a clean lint. Building rather than bind-mounting is deliberate: it
is what makes the pattern work unchanged where the docker daemon is remote and
bind mounts are impossible. Docker is assumed available in every environment.
Discarding the linter's cache on every run is the point of this rule, not a
cost it pays.
This closes a family of defects, every one of them an artefact of running
the linter on a shared host, and every one of them observed rather than
hypothesised:
- **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.
A container per run has its own cache, its own `TMPDIR` and therefore its
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 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 ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make fmt-check
RUN make lint
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
#!/bin/sh
# script/lint: run the linter. This is the ONLY source of a lint verdict
# 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.
#
# A failure here that names no finding is NOT a lint result: docker
# build exits 1 both for findings and for a build that never reached
# the lint step. BuildKit names the failing step; read it, fix the
# environment, and re-run.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, and `$$` is required because busybox `date`
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
}
main "$@"
```
Load-bearing properties:
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted
nothing — the same false green the `CHECK_EPOCH` rule above exists to
close, arriving through a new file. The `ARG` goes **after** the
dependency layer so `go mod download` / `yarn install` stay cached and
only the lint steps re-run. Blanket `--no-cache` also busts the dependency
layer, so every lint reaches the network instead of only the first one.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
not every Go lint run. Only the base image and the lint commands change;
the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
node image, runs `script/bootstrap` as the dependency layer **inside the
image**, and lints with the linter from that image's `node_modules`. Note
what this does not claim: a developer also runs `script/bootstrap` on the
host, so a copy of that linter exists there too. What the rule forbids is
taking a **verdict** from it.
- **Keep `golangci-lint config verify`, and it costs no network.** The two
commands catch **disjoint** classes of defect, measured under the pinned
v2.12.2 against a config carrying one planted defect at a time: a bogus
top-level key and a bogus key nested under `linters.settings.lll` both
pass `golangci-lint run` with **exit 0 and `0 issues`** while
`config verify` exits 3 and names the key; an invalid value type fails
both; an unknown linter name fails `run` and passes `config verify`. So
`run` alone silently ignores an unknown key, which is exactly the mode
where a threshold reads as configured and is not applied. The earlier
caution that `config verify` resolves its JSON schema over a live HTTPS
fetch does **not** hold for this pinned version: every case above was
re-run under `docker run --network none` and produced byte-identical
diagnostics and exit statuses, in a container where
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin rather than
treating the result as permanent.
- **No repo installs a linter on the host _in order to lint_ — remove any
such install from `script/bootstrap`.** A dedicated linter install (the
`go install` of golangci-lint is the case that existed) is now dead weight
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.**
`script/fmt` writes to your working tree, so it can only run on the host;
`script/fmt-check` is its read-only twin and runs on the host too, as well
as inside the main image. Neither is a lint run and neither is in scope.
**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
no shared content-keyed cache, no host-global lock, and no version to skew
against), and CI's verdict is containerised regardless, because
`script/fmt-check` also runs inside the main image build. What is **not**
acceptable is taking a lint verdict from the host copy: `script/lint` stays
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: add
`Dockerfile.lint`; replace `script/lint` with the build above; delete the
`lint` stage from its `Dockerfile` along with the
`COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
`RUN make check` to `script/test` and `script/fmt-check` with the comment
explaining why; add `script/lint` as the first step of `script/cibuild`;
delete any golangci-lint install from `script/bootstrap`; and delete the
`.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
the per-checkout cache/lock wrapper they served.
**The separate lint _stage_ is superseded by this and must not survive
alongside it.** It ran `make lint`, which is now a docker build, so keeping
it is not a stylistic preference but a recursion. Its purpose — fail-fast
feedback before the slow build — is served by `script/cibuild` running
`script/lint` first, and its `COPY --from=lint /src/go.sum /dev/null`
ordering trick, along with the warm-cache re-proof that trick required, is
no longer needed because the ordering is now sequential in the shell.
- **The canonical Go repo `Dockerfile`**, which builds and tests but does not
lint:
```dockerfile
# 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
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# 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
# below. Never run `git describe` here: .dockerignore excludes .git, so
# it yields an empty version without failing the build.
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
@@ -479,40 +147,29 @@ style conventions are in separate documents:
``` ```
Key points: Key points:
- Tests run in the build stage because they may require compiled artifacts - The lint stage uses the `golangci/golangci-lint` image directly (it
or heavier dependencies. includes both Go and the linter), so there is no need to install the
- `ARG CHECK_EPOCH` must be declared in **every** stage containing a linter separately.
check-running `RUN`, because `ARG` is stage-scoped: declaring it in one - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
stage leaves the others frozen at their last cached result while the fix a stage dependency. BuildKit runs stages in parallel by default; without
reviews as complete. In each such stage the guard sits immediately below this line, the build stage would not wait for lint to finish and a lint
the `ARG`, and the value is expanded into the first check `RUN` so the failure might not fail the overall build.
cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both - If the project uses `//go:embed` directives that reference build artifacts
lines reference `$CHECK_EPOCH`, so each stage has two independent (e.g. a web frontend compiled in a separate stage), the lint stage must
invalidation points. Later `RUN`s in the same stage need no expansion of create placeholder files so the embed directives resolve. Example:
their own: their parent layer is already busted. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- `ARG VERSION=dev` is declared in the build stage, and its value is The lint stage should not depend on the actual build output — it exists to
supplied on the host by `script/docker` and `script/cibuild` via fail fast.
`--build-arg VERSION=...`. The `dev` default is a placeholder for a local - If the project requires CGO or system libraries for linting (e.g.
build, not a source of truth. **No stage may call `git describe`**: `vips-dev`), install them in the lint stage with `apk add`.
`.dockerignore` excludes `.git`, so it yields an empty version without - The build stage runs `make test` after compilation setup. Tests run in the
failing. See the git-describe rule further down. build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push. `script/cibuild` runs **two** container builds: runs `script/cibuild` (which runs `docker build .`) on push. Since the
`script/lint` (`Dockerfile.lint`) first, then Dockerfile already runs `make check`, a successful build implies all checks
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .` pass.
for the main image, which runs the non-lint checks. A successful
`script/cibuild` therefore implies all checks pass; **a successful
`docker build .` on its own does not, because it never lints.** That is the
one claim to be careful with when reading these files: the guarantee belongs
to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only
because each build passes its own `CHECK_EPOCH` nonce — without it an
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
@@ -552,11 +209,16 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -count=1 -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; } go test -count=1 -timeout 30s -race -v ./...; exit 1; }
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun
reproduces a failure instead of replaying it. It leaves the build cache
alone, so it costs the runtime of the suite and no recompilation.
Python example: Python example:
```makefile ```makefile
@@ -582,140 +244,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`, editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
which holds one worktree — an entire additional checkout of the repo — per Fetch the standard `.gitignore` from
in-flight agent), language build artifacts, and `node_modules/`. Fetch the
standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. These patterns are written to `.gitignore`'s own semantics, in a new repo.
which an unanchored pattern already matches at every depth. They are not a
`.dockerignore` and must not be transplanted into one unmodified — see the
next rule.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: Go `filepath.Match` semantics plus a `**` extension,
compiled to a regexp — plain `filepath.Match` has no `**` at all. So `*` does
not cross `/`, and a pattern without a leading `**/` is anchored at the
build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root; `config/.env` and
`certs/server.key` still reach the context and can land in an image layer.
That file is more dangerous than a short one with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix — `**/node_modules`,
`**/.DS_Store`, and the secret patterns in the canonical file, which are
additionally case-folded per the rule below — and leave only genuinely
root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory
`.claude`, and the repo's own host-built binary. The inverse move is equally
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a
file that is wrong in a way that looks careful. Each file is written to its
own semantics; neither is derived from the other. Fetch the standard
`.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own host-built artifacts — a host `make build` that leaves
a compiled binary in the repo root puts that binary in the build context,
where `.gitignore` hides it from every git-based check. Write that binary
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so with `COPY . .` the build context
inflates by a multiple of the repo, and another session's unreviewed,
sometimes uncommitted work can be copied into an image layer. The directory is
also created and destroyed constantly, so it invalidates `COPY . .` for
reasons that have nothing to do with the repo's own content. In `.gitignore`
the entry is `.claude/`, unanchored, which already matches at every depth. In
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the
directory occurs exactly once **where agents run at the repo root**, and the
prefixed form would also match any nested directory of that name and delete it
from the build. It is not case-folded the way the secret patterns are, because
tooling creates it in exactly one spelling, so a folded pattern would add no
coverage.
**Known gap that comes with the anchored form.** The directory is created in
the agent's working directory, so the "exactly once, at the root" premise is
a property of how agents are run and not of the tooling. Where agents run in
subdirectories — a monorepo with a per-service agent is the ordinary case —
`services/api/.claude/` is **not** excluded by the canonical entry and still
reaches the build context and the image, which is the exposure the entry
exists to close. A repo in that shape adds its own anchored entries
(`/services/api/.claude`), or `**/.claude` once it has confirmed no
legitimately named nested directory would be caught. This is stated in the
canonical `.dockerignore` itself, since that file is what consuming repos
receive.
- **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive
filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not
the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though
case were handled — the same manufactured confidence as the root-anchored
form. The matcher supports character ranges, so one line covers every
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret
name, not only to extensions: the extensionless SSH keys and `.envrc` need it
for the same reason, since on the very filesystems that make `SERVER.KEY`
reachable, direnv reads `.ENVRC` and ssh reads `ID_RSA`. Note that `*` matches
the empty string, so `**/*.[eE][nN][vV]` already covers a bare `.ENV` and no
separate literal `.env` entry is needed.
- **A pattern that also catches something the build needs is re-included with a
negation, not deleted.** The canonical `**/*.[eE][nN][vV]` excludes a
committed env template such as `example.env`; a repo whose build genuinely
reads one adds `!docs/example.env` after the pattern. Deleting the pattern
instead reopens the exposure for every other file it covers.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). Reading the patterns and
agreeing they look right is exactly what lets the root-only form through. The
`transferring context` size is not a substitute: a nested secret is a few
bytes, and BuildKit transfers only the delta from the previous build, so the
reported size describes the transfer and not the contents of the image.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes
`.git` is present; in a build stage there is no repository, so `git describe`
writes nothing to stdout and the `-X main.Version=` value comes out **empty**
rather than erroring. The binary then reports no version at all and the build
still exits 0. Compute the version **on the host** and thread it in as a build
arg. `script/docker` and `script/cibuild` do this, byte-identically across
repos:
```sh
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, so the inline form degrades to an
# empty constant — the same silent-empty failure this rule is about.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
`--always` makes an untagged repo yield the abbreviated commit hash instead
of failing. `|| true` keeps a failing `git describe` from tripping `set -e`
and leaves the value empty, so the `[ -n "$version" ]` line is the single
place the fallback is applied — and it is a **live** check, not defence in
depth: it fires on a build from an export with no `.git`, and on a
repository with no commits yet. Do not fold the fallback into the
substitution as `|| echo unknown`; that makes the guard unreachable, and a
guard that cannot fire is indistinguishable from one that works to everyone
who copies it. The result is non-empty by construction either way, which is
the point: an empty version reads as a successful one, while `unknown` is
visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that
compiles, declared there and not inherited, because `ARG` is stage-scoped
exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile
declares no such `ARG` is silently ignored by BuildKit and costs nothing,
which is why the scripts stay byte-identical rather than growing a per-repo
variant.
One consequence for CI: the standard checkout action clones shallow and
fetches no tags, so `git describe --tags` there falls back to a bare commit
hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on
its checkout step; a repo that does not embed a version needs no change.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -734,126 +266,9 @@ style conventions are in separate documents:
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
canonical golangci-lint version is v2.12.2 (released 2026-05-06), pinned as canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
the image digest in `Dockerfile.lint` commit-pinned via
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
which reports
`golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That
digest is the only pin there is: the linter is not installed on the host, in
`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 a linter at all.** This supersedes the
pinned-golangci-lint install that used to be canonical here. Nothing runs a
linter on the host any more — `script/lint` is a container build — so a host
install has no caller left, and its only remaining effect is to put a second,
independently-versioned linter on the machine where somebody will eventually
run it by hand and believe the result. The version-skew failures that install
was written to close (a local `make check` green while `make docker` rejected
the same commit with six `goconst` findings; a container linter surfacing
thirteen findings the host run missed) are closed more completely by having
exactly one linter, pinned by image digest, that no host state can shadow.
Repos adopting the containerised lint delete the install block, its version
and ref variables, and its call site from `script/bootstrap`.
**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
keeping, because each of its four properties guards a failure that otherwise
reports success:
- **Compare the installed version against the pin, never test presence.** A
`if missing <tool>; then install; fi` guard tests `PATH` presence and
never version, so on any already-provisioned machine the pin is inert and
a version bump is a silent no-op. Compare the **whole** version token,
exactly: a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install — the original defect,
reintroduced through the comparison meant to fix it.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the directory the installer wrote to — and assert the
reported version is the pin. An installer that writes to `GOBIN` while a
different binary shadows it earlier in `PATH` genuinely succeeds and
changes nothing any caller sees, which is worse than no fix: it converts a
known-stale tool into one everyone believes is pinned. Run `hash -r` first
so the shell does not answer from its own lookup cache, and when the
assertion fails, name the path `command -v` found, the version it reports,
and the directory the install wrote to. Diagnose from the resolved path
rather than asserting a cause: only a path **outside** the install
directory is shadowing.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output and unrecognised output should
all yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** A function defined and never called is
a silent no-op indistinguishable from success: exit 0, nothing installed,
no output. Both success branches must print a line naming the version.
Verifying such logic requires a negative control in an environment where a
shadowing binary exists earlier in `PATH` than the install target — without
it the control passes against the naive compare-then-install form too and
proves nothing — plus a mis-parse control that feeds unparseable `--version`
output and confirms a reinstall. Run those controls against the block as a
consuming repo would adopt it: pasted into a `script/bootstrap`-shaped file
that is then executed, never by sourcing it and invoking the function
yourself. Driving the function directly tests something the artifact does
not do, and it is exactly how a missing call site passes every control while
the adopted snippet does nothing.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
- **SUPERSEDED, and deleted rather than kept: the per-checkout
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it
was about making a linter run on a shared host trustworthy — a private result
cache so a byte-identical checkout could not serve its findings, a private
`TMPDIR` so the host-global lock could not collide, retry and VOID handling so
a lock collision was never reported as findings. The containerised-lint rule
above removes the host run itself, so there is nothing left for that wrapper
to isolate, and a repo carrying both would carry two contradictory canonical
`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
has not yet adopted the containerised lint above.** A lint run is **VOID**
unless both hold:
- the output contains no `parallel golangci-lint is running`, and
- no reported file path begins with `../`, and none is an absolute path
outside the tree the run was launched from.
Do not record a verdict from a void run, and do not "fix" findings in files
the change does not touch — chasing phantom findings across untouched files
puts unrelated edits into a reviewed diff, which is more expensive than the
wasted rework.
The `../` clause is the one that actually bites, and it is why a filter
keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports
paths relative to its own resolved root rather than yours, and three of the
org's reported sightings had relative paths and would have passed such a
filter. Both clauses are needed and neither alone is sufficient — one
reproduction exited non-zero with the lock error and no foreign paths at
all, and another reported 34 well-formed findings, every one of them against
another checkout.
**State the limit of these tests rather than treating them as a guarantee.**
They catch contamination that **names** foreign files. They cannot catch
contamination that **suppresses** findings through a poisoned entry for
colliding content, which has no wall-clock tell either — **no evidence of
that mode has been observed, and nobody should go chasing it**; the point is
the reach of the tests, not a claim that the mode exists. They are a filter
for the loud mode, not a proof of soundness — which is the whole argument
for containerising the linter instead of documenting a discipline that
depends on every agent remembering to apply it. Adopt the rule above and
this one stops applying to the repo entirely.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).

View File

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

View File

@@ -1,40 +1,13 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. Two container builds, in order: # script/cibuild: run the CI build. The Dockerfile runs script/check, so
# script/lint (Dockerfile.lint) and then the main image, which runs the # a successful build implies all checks pass.
# non-lint checks. Both only prove anything because each passes its own
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container build docker build .
# and computes its own CHECK_EPOCH. It runs here rather than inside
# the main image because a docker build cannot run a docker build.
"$SCRIPT_DIR/lint"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
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)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
} }
main "$@" main "$@"

View File

@@ -1,15 +1,6 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. The # Identical in all repos; the tag comes from script/projectname.
# 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
# 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)"
@@ -17,25 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an docker build -t "$("$SCRIPT_DIR/projectname")" .
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
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)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"

View File

@@ -1,33 +1,13 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. This is the ONLY source of a lint verdict # script/lint: run the linter.
# 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. 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.
#
# A failure here that names no finding is NOT a lint result: docker
# build exits 1 both for findings and for a build that never reached the
# lint step (daemon down, image unpullable, disk full). BuildKit names
# the failing step; read it, fix the environment, and re-run. Do not
# 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 echo "Linting markdown files..."
# argument does not trip `set -e`, which would silently degrade the yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"