4 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Verified in this repo, not only documented: two consecutive script/lint
runs on a byte-identical tree both executed prettier (4.556s and 3.738s,
lint layers DONE with a fresh epoch printed, dependency layers CACHED as
intended); a planted violation failed the build naming the file, and
reverting it went green; a bare `docker build -f Dockerfile.lint .`
failed on the guard; make check, script/docker and script/cibuild all
green with the check layers demonstrably executing; and the main image
build completed without attempting a nested build.
2026-08-10 12:49:34 +00:00
clawbot
0620416869 Give golangci-lint per-checkout cache and lock state (closes #30)
All checks were successful
check / check (push) Successful in 7s
A golangci-lint result on a host running many concurrent workers does not
reliably belong to the tree that asked for it. Two independent mechanisms,
which have repeatedly been mistaken for one:

The result cache is keyed on file content, not location, so two checkouts
of the same commit hold byte-identical files, share cache entries, and one
tree's findings are served for the other under the other tree's path. This
produced a confirmed false green as well as the loud false reds. Moving
workers from shared worktrees to their own clones does not address it —
two clones collide exactly as two worktrees did — and removes only the
foreign-path artefact that made the defect noticeable.

The concurrency lock is $TMPDIR/golangci-lint.lock (pkg/commands/run.go,
acquireFileLock), host-global and independent of GOLANGCI_LINT_CACHE, with
a five-second acquire timeout, so it fails when the host is busiest. A
private cache directory does not isolate it. Setting only the cache closes
the contamination half and leaves runs failing red on a condition that is
not a result at all.

REPO_POLICIES.md now carries the canonical Go script/lint: both variables
scoped into a .lint-cache/ directory inside the checkout, above any
container-versus-host branch so every path reaching the linter gets them;
--allow-serial-runners, which keeps the mutual-exclusion guard and queues
rather than aborting, for the same-checkout overlap TMPDIR scoping cannot
cover, with --allow-parallel-runners rejected because it deletes the
guard; and a bounded retry that treats the lock error as VOID rather than
as findings, exiting 75 on exhaustion so it is neither a pass nor a
failure. Detection is on the stderr stream and never on exit status:
findings go to stdout, so a finding quoting the lock message in source
cannot be retried away, and the exit status is not a stable discriminator
anyway. The interim void rule is recorded with the ../ clause that the
original filter missed, and with its limit stated — it catches
contamination that names foreign files, not contamination that suppresses
findings. Both checklists gained the corresponding items, since a half-fix
that sets only the cache reads as complete.

GOCACHE was measured rather than assumed and does not need isolating: with
the two variables scoped per checkout and GOCACHE shared at the host
default, each checkout reported its own paths.

Verified with the snippet extracted from the committed document and
executed as a consuming repo would adopt it, each control paired against
the pre-fix form: contamination reproduced on the pre-fix script and
absent on the adopted one; a stub linter colliding twice then clearing,
with the retry engaging and succeeding; exhaustion exiting 75 with a VOID
message; a genuine finding whose text quotes the lock message reported as
findings with no retry; and a real held lock failing the pre-fix script
with exit 3 while the adopted script, inheriting the same environment,
completed in one second.
2026-08-09 18:51:22 +00:00
3a218497b8 Keep in-repo agent scratch out of the build context and out of git (closes #27)
All checks were successful
check / check (push) Successful in 17s
The canonical .dockerignore and .gitignore both omitted the in-repo agent
scratch directory. On this fleet that directory holds one worktree per
in-flight agent -- an entire additional checkout of the repo each -- so under
`COPY . .` all of it reached the build context and the image. Measured on this
repo before the change: five planted scratch files, at every depth beneath the
directory, all present inside a probe image built from the real context.

Three consequences, only the first of which is about size. The context inflates
by a multiple of the repo. Another session's unreviewed and sometimes
uncommitted work is copied into a build artifact. And the directory is created
and destroyed constantly by tooling, so it invalidates `COPY . .` for reasons
that have nothing to do with this repo's content -- which is the accidental
cache protection described at length in the issue thread, and the reason this
change was sequenced behind the CHECK_EPOCH bust rather than landed alongside
the rest of the .dockerignore work.

The two entries are deliberately different shapes, because the two files have
different semantics and neither is derived from the other. In .dockerignore the
entry is anchored, `.claude`, with no `**/` prefix: the directory occurs exactly
once, at the context root, and the prefixed form additionally matches any nested
directory of that name. Measured rather than argued -- the `**/`-prefixed
control was built and enumerated too, and it removes prompts/.claude/ from the
context as well, which in a repo with a legitimately named nested directory
would silently delete it from the build. In .gitignore the entry is unanchored,
`.claude/`, because a .gitignore pattern already matches at every depth;
`git check-ignore -v` confirms it covering both .claude/ and prompts/.claude/,
so a `**/` prefix there would be redundant at best, and on an anchored pattern
it would be actively wrong.

Anchoring buys that at the cost of a residual exposure, and the vendored files
now say so rather than only asserting the reason to anchor. "Occurs exactly
once, at the context root" is a property of how agents are run, not of the
tooling: the directory is created in the agent's working directory, so a
monorepo running a per-service agent in services/api/ still ships
services/api/.claude/ into the context and the image -- the exact exposure this
change exists to close, left open in the repo shape where it is likeliest. The
.dockerignore header block, the REPO_POLICIES.md bullet and both checklists
state the gap and the remedy (anchored entries for the subdirectories that have
one, or `**/.claude` once no legitimately named nested directory would be
caught). Consuming repos receive the files and not the tracker, so a caveat that
lives only in a PR body is not a caveat.

It is not case-folded the way the neighbouring secret patterns are: tooling
creates the directory in exactly one spelling, so a folded pattern would add no
coverage. The earlier justification -- that a miss costs bloat rather than
exposure -- is gone, because it contradicted this issue's own framing, in which
the cost of a miss is unreviewed work in an image layer.

The second half of this change is the consequence that ships broken silently.
Excluding .git means `git describe` cannot run in any build stage, and it fails
quietly there rather than erroring: `-X main.Version=` comes out empty, the
binary reports no version, and the build still exits 0. The Go template in
REPO_POLICIES.md had `ARG VERSION=dev` and never said where VERSION came from,
which is precisely the gap a reader fills in with `git describe` inside the
build. It now says: computed on the host, threaded in with `--build-arg
VERSION=...`, shown as a complete command rather than as two rules each
documenting half of one. script/docker and script/cibuild do it, with the same
discipline the epoch already has -- assignment on its own line, because a
failing command substitution inside an argument does not trip `set -e`, plus a
non-empty fallback so a build from an export with no .git reports `unknown`
rather than an empty string that reads as a successful version.

That fallback is applied in exactly one place, and that place can actually
execute. `git describe ... || true` leaves the value empty when it fails, and
the `[ -n "$version" ]` line is what substitutes `unknown`. Folding the fallback
into the substitution as `|| echo unknown` would have left the guard unreachable
-- harmless in itself, but a guard that cannot fire is indistinguishable from
one that works to every repo that copies it, and canonical text should not carry
a check that is decorative. Measured firing in both sh and dash: not a git
repository -> unknown, repository with no commits yet -> unknown, this
repository -> the describe output. The checklist now carries the guard line as
well, so the vendored guidance and the vendored script no longer disagree.

The scripts pass VERSION unconditionally rather than growing a per-repo variant.
This repo's Dockerfile declares no `ARG VERSION`, and BuildKit was measured
accepting the unconsumed arg silently -- no warning, no cache effect, confirmed
by the paired runs below in which the bootstrap layer still caches. The
alternative, leaving it to each repo, reintroduces the trap: a repo that needs a
version and finds no VERSION in its scripts writes `git describe` into the
Dockerfile, which is the failure being closed.

The same correction reaches the two Go documents that carry the GOLDFLAGS
pattern, since a `$(shell git describe)` evaluated inside a build stage is
exactly this empty version. Both are now `?=`, and their comments say precisely
when that matters: where a build stage compiles by invoking make, `ARG VERSION`
puts the value in the environment and `?=` defers to it, whereas the canonical
Go template compiles with `go build` directly and uses the Makefile on the host
only -- still `?=`, so that a repo which later moves its build behind make does
not silently start shipping an empty version. Leaving them as `:=`
would have left the corpus telling a reader one thing in the policy and the
opposite in the styleguide.

Both repo checklists gain the entries too. They are what an agent reads while
writing these files, so they are where the wrong shape actually gets written:
the .gitignore item is the one an existing repo never re-fetches, and it now
names `.claude/` explicitly along with the warning not to prefix it.

Verification, by enumerating a probe image rather than by reading the patterns.
Standalone minimal Dockerfile held outside the context, `--no-cache` scoped to
that one image, no prune of any kind. Before: all five planted scratch files in
the image, 42 files total. After: zero, 37 files total, with README.md,
script/check, prompts/NEW_REPO_CHECKLIST.md and a planted probe_src/app.md all
still present as positive controls, so the exclusion is a real exclusion and not
a COPY that stopped copying. Transferred context fell from 161.86kB to 68.82kB,
recorded as corroboration only: BuildKit reports a delta, not a total, and an
earlier run in this repo transferred 2.18kB while shipping 43 files.

The CHECK_EPOCH verification was re-run under the changed context, because the
context moved underneath the earlier measurement. Two consecutive script/cibuild
runs on an unchanged tree: run 1 in 17.07s, run 2 in 5.73s, both executing the
check layer with a distinct epoch and real prettier output from both lint and
fmt-check. The `RUN script/bootstrap` layer is CACHED in run 2, which is the
validity control -- it proves no concurrent prune landed between the runs and
that no --no-cache path was taken, so the check layer executing is the bust
working rather than a cold cache.

Planted files were removed afterwards and their absence confirmed against the
filesystem with `find`, not against `git status`, which cannot see them once
.gitignore covers the directory -- the same blind spot that made the earlier
secret exposure invisible.
2026-08-09 17:13:10 +00:00
15 changed files with 965 additions and 351 deletions

View File

@@ -10,7 +10,10 @@
# context root. A host-built binary is the usual case, and it must be # context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also # written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the # matches `cmd/myapp/`, which deletes the package directory from the
# context. # 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 # Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive # `certs/SERVER.KEY`, which is reachable on the case-insensitive
@@ -31,9 +34,33 @@
# belong here because a host build otherwise drops them into the # belong here because a host build otherwise drops them into the
# context. # context.
# Repository metadata: exactly one, at the context root. # 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
# In-repo agent scratch: a directory holding a full additional checkout
# 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 # Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention. # the empty string) and the `prod.env` convention.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]

6
.gitignore vendored
View File

@@ -11,6 +11,12 @@ 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

@@ -25,4 +25,13 @@ COPY . .
# here, not one. Keep both. # here, not one. Keep both.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
# The individual non-lint checks, NOT `make check`. Lint is deliberately
# absent here: `script/lint` is itself a `docker build` (of
# Dockerfile.lint), so running `make check` in this image would attempt
# a docker build inside a build step, where there is no daemon. Putting
# `make check` back reintroduces exactly that recursion. Lint is not
# skipped — script/cibuild runs script/lint first, in its own container,
# before this build starts.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

41
Dockerfile.lint Normal file
View File

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

102
TODO.md
View File

@@ -21,6 +21,108 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Closed three gaps the containerised-lint rule left between the
canonical text and the first repos to implement it. `.dockerignore` excluding
the agent scratch directory is now stated as a correctness precondition of
that rule rather than a context-size measure: the lint image lints whatever
`COPY . .` copies, and toolchains discover files by walking the tree instead
of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds
back inside the container — `sneak/quak` measured the same discovery mechanism
taking a test count from 210 to 1050. The cache-bust arg is fixed at
`CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is
invisible to the grep that proves every build is busted, making a renamed
guard indistinguishable from a missing one. And the formatting check is now
required to run in exactly one of the two images, with either placement
allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check`
gets dropped from both, and running the formatter beside the linters is the
better shape where it is the same pinned dependency.
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
made this repo do it rather than merely document it. `script/lint` is now
`docker build -f Dockerfile.lint .` and nothing else; the linter is never
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. 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 - 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because context under `COPY . .`, invisible to every git-based check because

View File

@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
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,7 +49,20 @@ last_modified: 2026-08-09
``` ```
```make ```make
VERSION := $(shell git describe --always --dirty) # ?= rather than := because this `$(shell git describe ...)` is only
# 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)
@@ -98,17 +111,24 @@ last_modified: 2026-08-09
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. `golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`:
the linter runs in a container, always, and is never installed on the host.
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.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and 1. Write a `Dockerfile` for every repo, even if it only runs the tests. It runs
linting. `script/cibuild` and `script/docker` should always make sure that the non-lint checks; linting lives in `Dockerfile.lint` and is run by
the code is in an able-to-be-compiled state, linted, and any tests run, and `script/cibuild` before the main build, because `script/lint` is itself a
the build should fail if linting doesn't pass. That guarantee holds only `docker build` and cannot run inside one. So `script/cibuild` is what
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that guarantees the code is in an able-to-be-compiled state, linted, and tested —
busts the check layers out of the Docker cache; without it an unchanged tree **a successful `docker build .` on its own does not, because it never
serves those layers from cache and the build reports a green it never ran. A lints.** That guarantee holds only because each build passes a
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
guard — always go through `script/cibuild` or `script/docker`. See the Docker cache; without it an unchanged tree serves those layers from
cache and the build reports a green it never ran. A bare `docker build .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard — always go
through `script/cibuild`, `script/docker` or `script/lint`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form. for the canonical form.

View File

@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
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,24 +24,71 @@ 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, language artifacts, secrets) — - [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language
fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` artifacts, secrets) — fetch from
if missing `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` 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 (fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
Dockerfile runs `make check` as a build step, and every stage containing a Dockerfile runs the **non-lint** checks as build steps (`script/test`,
check-running `RUN` declares `ARG CHECK_EPOCH` with the `script/fmt-check`), and every stage containing a check-running `RUN`
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is guard immediately below it — see the `CHECK_EPOCH` rule in
served from cache on an unchanged tree and the build reports a green it `REPO_POLICIES.md`. Without them the check layer is served from cache on
never ran. an unchanged tree and the build reports a green it never ran.
- [ ] 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`. The
arg is named `CHECK_EPOCH` in this file too — a repo that calls it
`LINT_EPOCH` here is missed by the grep that checks every build is
cache-busted.
- [ ] The formatting check runs in exactly one of the two images — either
`script/fmt-check` in the `Dockerfile` or the formatter beside the linters
in `Dockerfile.lint`, whichever puts it on the pinned toolchain. Neither
image running it is the failure to look for here, since moving lint out of
the `Dockerfile` is exactly when it gets dropped.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled - [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored — binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git. 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 — and `Dockerfile.lint` then lints that checkout
as though it were this one, because toolchains discover files by walking
the tree and never read `.gitignore` (`sneak/quak`: 210 discovered tests
became 1050). Confirm by enumerating the image, not by reading the file —
`.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
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; - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified. `.gitignore`'s patterns have not been transplanted unmodified.
@@ -75,6 +122,24 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` is the canonical container build and nothing else. No host
linter invocation survives anywhere in the repo — grep for the linter's
own name in `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.
- [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install
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.
- [ ] 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
@@ -121,6 +186,9 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design, - [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
on the `CHECK_EPOCH` guard) 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-02-22 last_modified: 2026-08-09
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -991,7 +991,14 @@ func main() {
Use ldflags to inject version information at build time: Use ldflags to inject version information at build time:
```makefile ```makefile
VERSION := $(shell git describe --tags --always) # ?= rather than := because this `$(shell git describe ...)` is only 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. 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-09 last_modified: 2026-08-10
--- ---
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,7 +35,11 @@ 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 language-specific artifacts. Extensions are written to `.gitignore`'s own
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
@@ -59,16 +63,46 @@ Template files can be fetched from:
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at `.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in context while reading as solved. See the `.dockerignore` rule in
`REPO_POLICIES.md`. `REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for
- All Dockerfiles must run `make check` as a build step, and every stage the same reason as a repo-root binary; leave it that way, but note it only
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the covers agents running at the repo root — if this repo will run them in
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the subdirectories, `services/api/.claude/` is not excluded and needs its own
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is anchored entry.
served from cache on an unchanged tree and the build reports a green it - If the image embeds a version in a binary, the version is computed on the
never ran. 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 `make check` - Non-server: brings up dev environment and runs those checks
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image 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. The arg keeps the name `CHECK_EPOCH` in
this file as well, so one grep covers both builds.
- The formatting check runs in exactly one of the two images: either
`script/fmt-check` in the `Dockerfile`, or the formatter beside the
linters in `Dockerfile.lint` where that is the same pinned dependency.
Never neither, never both.
- `.dockerignore` must exclude the agent scratch directory before this image
is trusted: it lints whatever is in the build context, and toolchains walk
the tree rather than reading `.gitignore`, so an agent worktree that
reaches the context is linted as though it were the repo.
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs - [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference `script/cibuild` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -95,22 +129,49 @@ are thin shims calling them. Model scripts:
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second - [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout) timeout)
- [ ] `script/lint` / `make lint`runs linter - [ ] `script/lint` / `make lint`builds `Dockerfile.lint` and nothing else:
`epoch="$(date +%s%N)$$"` on its own line, then
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The
linter is never installed on the host and never invoked there. Copy the
canonical script from `REPO_POLICIES.md`; it is byte-identical across
repos. Without the nonce this script exits 0 on an unchanged tree having
linted nothing.
- [ ] `script/fmt` / `make fmt` — formats code (writes) - [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files modify files. It needs a docker daemon, because `script/lint` is a
container build, and it must never be called from inside a build stage
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); assigns `script/projectname` (byte-identical across repos); carries the same three
`epoch="$(date +%s%N)$$"` on its own line and passes version lines as `script/cibuild` below, and passes
`--build-arg CHECK_EPOCH="$epoch"` `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on - [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for
its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .` fail-fast feedback, then, each on its own line:
(what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md` for why each element is load-bearing. A bare ```sh
`docker build .` fails closed by design. 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`
@@ -121,7 +182,11 @@ 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

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,19 +60,22 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where repo root, runs `script/lint` first for fail-fast feedback (that is itself a
`epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the container build — see the containerised-lint rule below), and then runs
Gitea workflow calls it. Four further scripts are our own extensions to the `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
standard: `script/check` runs `script/test`, `script/lint`, and where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
`script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, `version` is computed on the host because `.git` is not in the build context
and it calls `script/check`; `script/install-precommit` installs the git (see the git-describe rule below); the Gitea workflow calls it. Four further
pre-commit hook (the `make hooks` target shims to it); and scripts are our own extensions to the standard: `script/check` runs
`script/projectname` (literally that filename) simply outputs the project's `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
name. Scripts that need the name call `script/projectname` — e.g. what the git pre-commit hook runs, and it calls `script/check`;
`script/docker` assembles its image tag from it — so those scripts stay `script/install-precommit` installs the git pre-commit hook (the `make hooks`
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. target shims to it); and `script/projectname` (literally that filename) simply
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in outputs the project's name. Scripts that need the name call
the hook itself. Model scripts are at `script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -91,42 +94,68 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`. It must run the repo's checks as build
as a build step so the build fails if the branch is not green — which requires steps so the build fails if the branch is not green — which requires
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running `ARG CHECK_EPOCH` and its guard in every stage containing a check-running
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies `RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies
this criterion while its check layers are served from cache, so the build this criterion while its check layers are served from cache, so the build
cannot fail on a branch that is not green. For non-server repos, the cannot fail on a branch that is not green.
Dockerfile should bring up a development environment and run `make check`. For
server repos, `make check` should run as an early build stage before the final **It runs the individual non-lint checks — `script/test` and
image is assembled. Dockerfiles install development prerequisites by running `script/fmt-check` — and never `make check`.** `script/lint` is itself a
`script/bootstrap` rather than duplicating installs inline; COPY `script/` and `docker build` (of `Dockerfile.lint`, per the containerised-lint rule
the dependency manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, below), so a `RUN make check` in this file attempts a docker build inside a
etc.) before running it so the bootstrap layer stays cached until dependencies build step, where there is no daemon. Lint is not skipped by this: it runs
change. in its own container, and `script/cibuild` runs it first. Put a comment to
that effect directly above those `RUN` lines, because `make check` is what
the next person will reach for. Of the two, only `script/test` is fixed
here: a repo may run its formatter in `Dockerfile.lint` beside the linters
instead, and some should — see the containerised-lint rule below. It must
then run in that file and not in this one, and never in neither.
For non-server repos, the Dockerfile should bring up a development
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 - **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 invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the `RUN make check` layer is served from cache, the suite unchanged tree the check layer is served from cache, the suite never runs, and
never runs, and the build still exits 0. A sub-second `docker build` reporting the build still exits 0. A sub-second `docker build` reporting success is a
success is a cache hit, not a result. The canonical form, in **every** stage cache hit, not a result. This applies to **every** file that runs checks in a
containing a check-running `RUN`: 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 ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command>
``` ```
and in both `script/cibuild` and `script/docker`: and in `script/lint`, `script/cibuild` and `script/docker`:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" . 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" \
.
``` ```
All four elements are load-bearing; none is optional, and each guards a The `VERSION` lines are there for a different reason, covered by the
failure mode that otherwise fails green: git-describe rule below; they are shown here so the two rules do not each
document half a command. `script/lint` passes only `CHECK_EPOCH`, since no
version is embedded in a lint image. All four `CHECK_EPOCH` elements are
load-bearing; none is optional, and each guards a failure mode that
otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check - `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`. that runs checks, immediately above the first such `RUN`.
@@ -155,48 +184,236 @@ style conventions are in separate documents:
This invalidates the check layers and everything after them while leaving This invalidates the check layers and everything after them while leaving
`go mod download`, `script/bootstrap`, and the pinned toolchain install `go mod download`, `script/bootstrap`, and the pinned toolchain install
cached, so it does not push against the five-minute Docker build ceiling. cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` also works but is wasteful and can blow that 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, which makes linting network-dependent and pushes a lint that
should take seconds toward the build ceiling. Never reach for
`docker builder prune` to achieve the same end — the build cache is shared
with every other build on the host, including other people's.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **Every lint run happens in a container, and `script/lint` is that container
repos use a multistage build where linting runs in an independent stage based build.** The linter is never installed on the host and never invoked there.
on the `golangci/golangci-lint` image (pinned by hash). This stage runs Every repo carries a `Dockerfile.lint` next to its `Dockerfile`; the linter
`make fmt-check` and `make lint` before the full build begins. The build stage runs as a **build step**, so a successful build _is_ a clean lint. Building
then declares an explicit dependency on the lint stage via rather than bind-mounting is deliberate: it is what makes the pattern work
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete unchanged where the docker daemon is remote and bind mounts are impossible.
linting before proceeding to compilation and tests. This ensures lint failures Docker is assumed available in every environment. Discarding the linter's
surface in seconds rather than minutes, without blocking on dependency cache on every run is the point of this rule, not a cost it pays.
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is: 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 ```dockerfile
# Lint stage — fast feedback on formatting and lint issues # Lint-only image. `script/lint` builds this file and nothing else: the
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # linter runs as a build step, so a successful build IS a clean lint.
FROM golangci/golangci-lint@sha256:... AS 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 . .
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN echo "lint epoch: ${CHECK_EPOCH}" && \
RUN make lint golangci-lint config verify --config .golangci.yml
RUN golangci-lint run --config .golangci.yml ./...
```
and the canonical `script/lint`, identical in every repo:
```sh
#!/bin/sh
# script/lint: run the linter. The linter is never installed on the host
# and never invoked there — it 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.
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, which makes every lint network-dependent.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
not every Go lint run. Only the base image and the lint commands change;
the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
node image, runs `script/bootstrap` as the dependency layer, and lints
with the linter from `node_modules`, which is also how it gets the version
pinned in `package.json` rather than whatever is on the host.
- **The lint container lints whatever is in the build context, so
`.dockerignore` is part of this rule and not merely hygiene.** `COPY . .`
copies an agent scratch worktree — an entire second checkout of the repo —
into the lint image unless `.dockerignore` excludes it, and language
toolchains discover files by walking the tree rather than by reading
`.gitignore`, so `./...`, `eslint .` and `prettier --check .` all descend
into it. `sneak/quak` measured this on the same discovery mechanism in its
test runner: a nested `.claude/` worktree took the discovered test count
from 210 to 1050 (https://git.eeqj.de/sneak/quak/issues/30). Left in the
context it re-creates _inside_ the container the foreign-tree false reds
that moving lint into a container was adopted to end, and it does so in
the convincing form — the findings are real, they simply belong to another
checkout. See the `.dockerignore` rules below, and verify by enumerating
the image rather than by reading the patterns.
- **The build arg is named `CHECK_EPOCH` in `Dockerfile.lint` too**, not
`LINT_EPOCH` or any other per-file name, and `script/lint` passes it under
that name. Both files guard the same failure under the same contract, and
the single name is what lets a reviewer grep a repo for `CHECK_EPOCH` and
see every cache-bust it has. Rename it in one file and that grep silently
misses it, so a renamed guard and an absent guard read identically without
opening both Dockerfiles.
- **The formatting check runs in exactly one of the two images, and either
one is allowed.** The canonical `Dockerfile` above runs `script/fmt-check`
because that is where the non-lint checks live. A repo may instead run its
formatter in `Dockerfile.lint` beside the linters, which is the better
shape wherever the formatter is the same pinned dependency as the linter
(`prettier` out of `node_modules`, say), because it takes the last host
toolchain off the checked path for the same reason the linter came off it.
What is not allowed is running it in neither image, or in both. Whichever
image runs it carries the epoch guard, and `script/check` still runs all
three targets on the developer's side either way.
- **Keep `golangci-lint config verify`, and it costs no network.** The two
commands catch **disjoint** classes of defect, measured under the pinned
v2.12.2 against a config carrying one planted defect at a time: a bogus
top-level key and a bogus key nested under `linters.settings.lll` both
pass `golangci-lint run` with **exit 0 and `0 issues`** while
`config verify` exits 3 and names the key; an invalid value type fails
both; an unknown linter name fails `run` and passes `config verify`. So
`run` alone silently ignores an unknown key, which is exactly the mode
where a threshold reads as configured and is not applied. The earlier
caution that `config verify` resolves its JSON schema over a live HTTPS
fetch does **not** hold for this pinned version: every case above was
re-run under `docker run --network none` and produced byte-identical
diagnostics and exit statuses, in a container where
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin rather than
treating the result as permanent.
- **No repo installs a linter on the host, in `script/bootstrap` or anywhere
else.** A host install is now dead weight whose only remaining effect is
to reintroduce the version skew above.
- **`script/check` still runs `test`, `lint` and `fmt-check`**, so a
developer and the pre-commit hook get all three. It therefore requires a
docker daemon, and it must never be invoked from inside a build stage —
see the `Dockerfile` rule above.
- If the project uses `//go:embed` directives referencing build artifacts
(e.g. a web frontend compiled elsewhere), `Dockerfile.lint` must create
placeholder files so the directives resolve:
`RUN mkdir -p web/dist && touch web/dist/index.html`. It must not depend
on the real build output; it exists to fail fast.
- If linting requires CGO or system libraries (e.g. `vips-dev`), install
them in `Dockerfile.lint`.
**What a consuming repo does to adopt this**, in order: add
`Dockerfile.lint`; replace `script/lint` with the build above; delete the
`lint` stage from its `Dockerfile` along with the
`COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
`RUN make check` to `script/test` and `script/fmt-check` with the comment
explaining why; add `script/lint` as the first step of `script/cibuild`;
delete any golangci-lint install from `script/bootstrap`; and delete the
`.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
the per-checkout cache/lock wrapper they served.
**The separate lint _stage_ is superseded by this and must not survive
alongside it.** It ran `make lint`, which is now a docker build, so keeping
it is not a stylistic preference but a recursion. Its purpose — fail-fast
feedback before the slow build — is served by `script/cibuild` running
`script/lint` first, and its `COPY --from=lint /src/go.sum /dev/null`
ordering trick, along with the warm-cache re-proof that trick required, is
no longer needed because the ordering is now sequential in the shell.
- **The canonical Go repo `Dockerfile`**, which builds and tests but does not
lint:
```dockerfile
# 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 . .
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# The individual non-lint checks, NOT `make check`: script/lint is a
# docker build (Dockerfile.lint), so `make check` here would nest a
# build inside a build step, where there is no daemon. Lint is not
# skipped — script/cibuild runs it first, in its own container.
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make test
# VERSION comes from the host via --build-arg; see the git-describe rule
# 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}" \
@@ -209,56 +426,40 @@ style conventions are in separate documents:
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it - Tests run in the build stage because they may require compiled artifacts
includes both Go and the linter), so there is no need to install the or heavier dependencies.
linter separately. - `ARG CHECK_EPOCH` must be declared in **every** stage containing a
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates check-running `RUN`, because `ARG` is stage-scoped: declaring it in one
a stage dependency. BuildKit runs stages in parallel by default; without stage leaves the others frozen at their last cached result while the fix
this line, the build stage would not wait for lint to finish and a lint reviews as complete. In each such stage the guard sits immediately below
failure might not fail the overall build. the `ARG`, and the value is expanded into the first check `RUN` so the
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.** cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both
The cache-bust turns this no-op `COPY` into a content-cache hit, so an lines reference `$CHECK_EPOCH`, so each stage has two independent
ordering guarantee established on a cold cache does not automatically invalidation points. Later `RUN`s in the same stage need no expansion of
carry over; it has to be re-checked warm. This was re-proved in another their own: their parent layer is already busted.
repo in the org that uses the same file-dependency trick (there with a - `ARG VERSION=dev` is declared in the build stage, and its value is
marker file in place of `go.sum`), and the ordering held. It has **not** supplied on the host by `script/docker` and `script/cibuild` via
been verified in this repo, which is single-stage and has no lint stage to `--build-arg VERSION=...`. The `dev` default is a placeholder for a local
order against. Any repo relying on a file-dependency trick for stage build, not a source of truth. **No stage may call `git describe`**:
ordering should re-check it warm after adopting the bust rather than `.dockerignore` excludes `.git`, so it yields an empty version without
assuming this result transfers. failing. See the git-describe rule further down.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at the last cached result. In each stage the guard sits immediately
below the `ARG` so a bare `docker build .` fails instead of reusing the
empty cache key, and the value is expanded into the first check `RUN` so
the cache miss does not rely on BuildKit's unreferenced-`ARG` handling.
Both of those lines reference `$CHECK_EPOCH`, so both are value-keyed:
each stage is invalidated at two independent points. The later `RUN`s in
the same stage need no expansion of their own: they are already
invalidated by their busted parent layer.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `script/cibuild` on push. `script/cibuild` runs **two** container builds:
`docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile `script/lint` (`Dockerfile.lint`) first, then
runs `make check`, so a successful build implies all checks pass — but that `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
implication holds **only** because of the `CHECK_EPOCH` cache-bust described for the main image, which runs the non-lint checks. A successful
above. Without it, an unchanged tree serves the check layer from cache and the `script/cibuild` therefore implies all checks pass; **a successful
build reports a green it never earned. A bare `docker build .` fails closed by `docker build .` on its own does not, because it never lints.** That is the
design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through one claim to be careful with when reading these files: the guarantee belongs
`script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the because each build passes its own `CHECK_EPOCH` nonce — without it an
check layer, means nothing was executed. unchanged tree serves the layers from cache and the build reports a green it
never earned. A bare `docker build .` or `docker build -f Dockerfile.lint .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go
through `script/cibuild`, `script/docker` or `script/lint`. Never accept a
pass as evidence without confirming it ran: a sub-second wall time, or
`CACHED` on a check or lint layer, means nothing was executed.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -328,8 +529,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`, `*~`), language build artifacts, and `node_modules/`. editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`,
Fetch the standard `.gitignore` from which holds one worktree — an entire additional checkout of the repo — per
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. These patterns are written to `.gitignore`'s own semantics, in
which an unanchored pattern already matches at every depth. They are not a which an unanchored pattern already matches at every depth. They are not a
@@ -349,7 +552,8 @@ style conventions are in separate documents:
depth-independent pattern the `**/` prefix — `**/node_modules`, depth-independent pattern the `**/` prefix — `**/node_modules`,
`**/.DS_Store`, and the secret patterns in the canonical file, which are `**/.DS_Store`, and the secret patterns in the canonical file, which are
additionally case-folded per the rule below — and leave only genuinely additionally case-folded per the rule below — and leave only genuinely
root-anchored entries such as `.git` unprefixed. The inverse move is equally 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 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 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 own semantics; neither is derived from the other. Fetch the standard
@@ -361,6 +565,41 @@ style conventions are in separate documents:
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context. `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. **And because
`Dockerfile.lint` and `Dockerfile` run their tooling over the copied context,
a worktree that reaches it is linted and tested as though it were the repo.**
Nothing else stops that: language toolchains discover files by walking the
tree and do not read `.gitignore`, which is how `sneak/quak` saw a nested
`.claude/` worktree take its discovered test count from 210 to 1050
(https://git.eeqj.de/sneak/quak/issues/30). This entry is therefore a
correctness precondition of the containerised-lint rule above and not a size
optimisation — without it the foreign-tree false reds that rule exists to end
simply move inside the container. In `.gitignore` the entry is `.claude/`,
unanchored, which already matches at every depth. In `.dockerignore` it is
`.claude`, anchored and with **no** `**/` prefix: the directory occurs exactly
once **where agents run at the repo root**, and the prefixed form would also
match any nested directory of that name and delete it from the build. It is
not case-folded the way the secret patterns are, because tooling creates it in
exactly one spelling, so a folded pattern would add no coverage.
**Known gap that comes with the anchored form.** The directory is created in
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 - **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive match `certs/SERVER.KEY`, which is reachable on the case-insensitive
@@ -390,6 +629,49 @@ style conventions are in separate documents:
bytes, and BuildKit transfers only the delta from the previous build, so the 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. 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
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
@@ -407,198 +689,126 @@ 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), installed canonical golangci-lint version is v2.12.2 (released 2026-05-06), pinned as
commit-pinned via the image digest in `Dockerfile.lint`
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
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` in Go repos must install the pinned golangci-lint - **`script/bootstrap` must not install a linter at all.** This supersedes the
whenever the installed version does not match the pin — not merely when the pinned-golangci-lint install that used to be canonical here. Nothing runs a
binary is absent — and must then verify the install took effect by linter on the host any more — `script/lint` is a container build — so a host
re-resolving the binary through `PATH`.** The presence test install has no caller left, and its only remaining effect is to put a second,
`if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi` is wrong: independently-versioned linter on the machine where somebody will eventually
it tests `PATH` presence and never version, so on any already-provisioned run it by hand and believe the result. The version-skew failures that install
machine the pin is inert and a version bump is a no-op. Meanwhile the was written to close (a local `make check` green while `make docker` rejected
Dockerfile installs unconditionally into a clean image, so CI and local the same commit with six `goconst` findings; a container linter surfacing
silently disagree about what the linter even is. Observed consequences: a thirteen findings the host run missed) are closed more completely by having
local `make check` green while `make docker` rejected the same commit with six exactly one linter, pinned by image digest, that no host state can shadow.
`goconst` findings, and a container linter surfacing thirteen findings the Repos adopting the containerised lint delete the install block, its version
host run missed. A stale host linter does not merely fail to prove the tree is and ref variables, and its call site from `script/bootstrap`.
clean — it hides findings only the container can see. This is a deliberate
departure from the node handling described above, which uses whatever node is
installed: the linter version is the specific thing being held equal between
host and container, so for it, presence is not enough.
Comparing versions is necessary but **not sufficient**, because the obvious **The version-enforcement principle it established still applies to any
fix also fails green. `go install` writes to `GOBIN` (or `GOPATH/bin`) while other tool a repo pins and installs on the host**, and it is the part worth
callers resolve `golangci-lint` through `PATH`. If a different binary keeping, because each of its four properties guards a failure that otherwise
shadows it earlier in `PATH`, the install genuinely succeeds and changes reports success:
nothing any caller will ever see: bootstrap prints success and the next - **Compare the installed version against the pin, never test presence.** A
`make lint` still runs the stale linter. That is worse than no fix, because `if missing <tool>; then install; fi` guard tests `PATH` presence and
it converts a known-stale toolchain into one everyone believes is pinned. never version, so on any already-provisioned machine the pin is inert and
The canonical form, placed in `script/bootstrap` after Go itself is present: 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
```sh running `2.12.2-rc1` and skips the install — the original defect,
# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly reintroduced through the comparison meant to fix it.
# what `golangci-lint --version` prints for this ref; update both together.
GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints
# something unparseable: all of those must read as "does not match".
# The capture is the whole version token, not just its numeric prefix.
# Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2
# and skip the install, which is the defect this whole rule exists to close.
# The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
# a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint --version 2>/dev/null | head -n 1 |
sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true
}
ensure_golangci_lint() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
return 0
fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
go install "$GOLANGCI_LINT_REF"
# go install writes to GOBIN (or GOPATH/bin); callers resolve through
# PATH. Re-resolve through PATH and assert the install took effect.
# `hash -r` is load-bearing: without it a shell that already resolved
# a stale golangci-lint answers from its own lookup cache, and this
# check false-fails with the shadowing message below.
hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
"and PATH resolves it"
return 0
fi
gcl_bin="$(go env GOBIN)"
[ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
# Strip a trailing slash: GOBIN=/x/ would otherwise make the
# "$gcl_bin"/* test below miss and misreport shadowing.
while :; do
case "$gcl_bin" in
*/) gcl_bin="${gcl_bin%/}" ;;
*) break ;;
esac
done
gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2
case "$gcl_found" in
"")
echo "bootstrap: PATH resolves no golangci-lint at all." \
"Add $gcl_bin to PATH, then re-run bootstrap." >&2
;;
"$gcl_bin"/*)
echo "bootstrap: PATH resolves $gcl_found, inside that same" \
"directory, reporting version ${gcl_got:-unparseable}." \
"Nothing is shadowing it, so the install itself did not" \
"produce the pinned version: check that" \
"GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2
;;
*)
echo "bootstrap: PATH resolves $gcl_found instead, reporting" \
"version ${gcl_got:-unparseable}. Remove that binary or" \
"put $gcl_bin earlier in PATH, then re-run bootstrap." >&2
;;
esac
exit 1
}
# The definitions above are inert on their own; the call site is part of
# the canonical form. In a script/bootstrap that follows the "define all
# functions, then call main" convention, this line belongs inside main()
# next to the other ensure_* steps.
ensure_golangci_lint
```
Four properties are load-bearing; each guards a failure mode that otherwise
fails green:
- **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. 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`, which compares equal to a `2.12.2` pin 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** — - **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the path `go install` wrote to — and assert through `PATH`, not the directory the installer wrote to — and assert the
`--version` reports the pin. When it does not, fail non-zero and name the reported version is the pin. An installer that writes to `GOBIN` while a
path `command -v` actually found, the version it reports, and the different binary shadows it earlier in `PATH` genuinely succeeds and
directory the install wrote to. That is a condition a human has to fix by changes nothing any caller sees, which is worse than no fix: it converts a
hand, so bootstrap must not print success in it. Use `hash -r` first so known-stale tool into one everyone believes is pinned. Run `hash -r` first
the shell does not answer from its own lookup cache. Diagnose the cause so the shell does not answer from its own lookup cache, and when the
from the resolved path rather than asserting one: only a path **outside** assertion fails, name the path `command -v` found, the version it reports,
the install directory is shadowing. When the resolved path is inside it, and the directory the install wrote to. Diagnose from the resolved path
nothing is shadowing and telling the operator to delete that binary or rather than asserting a cause: only a path **outside** the install
reorder `PATH` sends them after a fault that does not exist. directory is shadowing.
- **A mis-parse must fall through to reinstall, never to a false match.** - **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output, and unrecognised output all Absent binary, non-zero exit, empty output and unrecognised output should
yield an empty string, which compares unequal to the pin. The failure all yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one. direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** Two function definitions with no call - **Call it, and say so on success.** A function defined and never called is
site are a silent no-op that reproduces the original defect exactly: exit a silent no-op indistinguishable from success: exit 0, nothing installed,
0, nothing installed, no output, stale linter still resolved. A success no output. Both success branches must print a line naming the version.
path that prints nothing is byte-identical to that no-op — same exit
status, same empty output — so both success branches must print a Verifying such logic requires a negative control in an environment where a
confirmation naming the version. In a change about undetectable no-ops, shadowing binary exists earlier in `PATH` than the install target — without
"it printed nothing and exited 0" must not be the healthy signal. 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`. Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
**On the hash-pinning rule.** `@c0d3ddc9cf3faa61a4e378e879ece580256d76e5` is - **SUPERSEDED, and deleted rather than kept: the per-checkout
a commit hash, not a server-mutable version tag, and the go command verifies `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it
the fetched module against the checksum database — the mechanism the was about making a linter run on a shared host trustworthy — a private result
hash-pinning rule at the top of this document already names as acceptable cache so a byte-identical checkout could not serve its findings, a private
for Go modules. Note that `go install pkg@version` runs in module-aware mode `TMPDIR` so the host-global lock could not collide, retry and VOID handling so
ignoring the `go.mod` in the current directory or any parent, so no repo a lock collision was never reported as findings. The containerised-lint rule
`go.sum` is consulted for this install; the checksum database is what above removes the host run itself, so there is nothing left for that wrapper
verifies it. The linter is a bootstrap prerequisite rather than part of any to isolate, and a repo carrying both would carry two contradictory canonical
repo's module graph, which is why the canonical form installs it by `script/lint` forms. Its findings are not lost: they are the evidence for
commit-pinned ref instead of declaring it in `go.mod`. Whether a `go.mod` containerising, and they are recorded in that rule. Repos that adopted it
tool dependency — which would pin the hash in a committed, reviewable file delete the wrapper, the `.lint-cache/` entries from `.gitignore` and
instead — should replace this is an open decision, tracked at `.dockerignore`, and the `--allow-serial-runners` flag with them.
[prompts#37](https://git.eeqj.de/sneak/prompts/issues/37).
**Keep `GOLANGCI_LINT_VERSION` and the ref in sync.** The ref is a hash and Two of its conclusions are kept because they outlive it. **`GOCACHE` does
carries no readable version, so the expected version is a separate string, not need isolating**, measured rather than assumed: it is content-addressed,
and it must be exactly what `--version` prints for that ref — the comparison its entries are compiled artifacts rather than diagnostics carrying a
is an exact match on the whole version token. When the pinned commit carries foreign tree's paths, and it has no equivalent global lock — the whole fleet
a release tag the go command resolves the hash to that tag, so the string is compiles concurrently against one `GOCACHE` all day without a contention
simply the release number, `2.12.2` here. When it does not, the go command error. And **verifying any change to lint plumbing requires paired
falls back to a pseudo-version and the binary reports something like controls**: a control that passes against the broken form proves nothing,
`2.12.3-0.20260506110758-c0d3ddc9cf3f`; that compares exactly like any other and it must be run against the artifact as a consuming repo would adopt it —
string, so it works, but it cannot be known without building the binary once the file executed, not the functions sourced and driven by hand.
and reading `--version` off it. Prefer pins on tagged releases for that
reason — the expected string is then derivable from the ref — not because
the comparison cannot handle the alternative.
Because the comparison covers the whole token, a pre-release is never - **Interim rule for reading a lint result produced on the host, in a repo that
confused with its release: a host carrying `2.12.2-rc1` against a `2.12.2` has not yet adopted the containerised lint above.** A lint run is **VOID**
pin compares unequal and gets reinstalled. This matters more than it looks, unless both hold:
because a pre-release tag is still a tag, so a rule requiring merely that - the output contains no `parallel golangci-lint is running`, and
the pin be tagged would not catch it. - no reported file path begins with `../`, and none is an absolute path
outside the tree the run was launched from.
**Verifying a change to this logic requires a negative control run in an Do not record a verdict from a void run, and do not "fix" findings in files
environment where a shadowing binary exists earlier in `PATH` than the the change does not touch — chasing phantom findings across untouched files
install target.** Without that, the control passes against the naive puts unrelated edits into a reviewed diff, which is more expensive than the
compare-then-install form as well and therefore proves nothing. Also check wasted rework.
the mis-parse direction by feeding it unparseable `--version` output and
confirming it reinstalls rather than reporting a match.
**Run those controls against the block as a consuming repo would adopt it** The `../` clause is the one that actually bites, and it is why a filter
— pasted into a `script/bootstrap`-shaped file that is then executed — not keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports
by sourcing it and invoking the function yourself. Driving the function paths relative to its own resolved root rather than yours, and three of the
directly tests something the artifact does not do, and it is exactly how a org's reported sightings had relative paths and would have passed such a
missing call site passes every control while the adopted snippet does filter. Both clauses are needed and neither alone is sufficient — one
nothing. 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,6 +1,13 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. Must not modify any files.
#
# script/lint is a docker build (see Dockerfile.lint), so this script
# requires a docker daemon. That is deliberate: it is the only way a
# developer and the pre-commit hook get the same linter CI gets. It also
# means this script must never be run from inside a build stage — see
# the comment in Dockerfile, which runs the individual non-lint checks
# for exactly that reason.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,20 +1,40 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but # script/cibuild: run the CI build. Two container builds, in order:
# that only proves anything because CHECK_EPOCH is a fresh nonce on every # script/lint (Dockerfile.lint) and then the main image, which runs the
# invocation: without it Docker serves the check layer from cache on an # non-lint checks. Both only prove anything because each passes its own
# unchanged tree and the build exits 0 without running the suite. # fresh CHECK_EPOCH nonce: without it Docker serves the check layers
# from cache on an unchanged tree and the build exits 0 without running
# anything.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container 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 # Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the # argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. # drops %N without erroring.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" . # 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

@@ -16,7 +16,19 @@ main() {
# nonce to an empty constant. `$$` is required because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. # drops %N without erroring.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \ # 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")" . -t "$("$SCRIPT_DIR/projectname")" .
} }

View File

@@ -1,13 +1,27 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run the linter. The linter is never installed on the host
# and never invoked there — it 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.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Linting markdown files..." # Assign on its own line: a failing command substitution inside an
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always # 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. 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 "$@"