5 Commits
Author SHA1 Message Date
sneak 7f4ef15610 Gate the build on Docker lint and test phases (closes #40, closes #30)
check / check (push) Failing after 15s
Per the owner ruling on issue 40, linting and testing are phases of the
main Dockerfile rather than a separate lint file. script/lint and
script/test each build one phase by name with caching disabled, and the
final stage copies a harmless file from each so the image cannot be built
unless both passed — the ordering trick already in template-app-go's
Dockerfile, extended to the test phase. A phase that is not the last
stage is built only when something depends on it or --target names it, so
the gates are always invoked by name and the edges are kept. Every build
in script/ is tagged. No config verify step; fmt stays on the host. This
closes issue 30 too: a container has its own result cache and lock.

Model: opus-5
2026-09-08 04:58:45 +00:00
sneak 51df10e1f7 Keep in-repo agent scratch out of the build context and out of git (closes #27)
The canonical .dockerignore and .gitignore both omitted the in-repo agent
scratch directory, which holds one worktree per in-flight agent, so under
`COPY . .` an entire extra checkout of the repo reached the image. The
two entries are deliberately different shapes: anchored in .dockerignore,
where the `**/` form would also delete a legitimately named nested
directory, and unanchored in .gitignore, where a pattern already matches
at every depth. Anchoring leaves a gap where agents run in
subdirectories, stated in the vendored file itself. The second half is
the consequence of excluding .git: `git describe` in a build stage yields
an empty version without erroring, so the version is now computed on the
host and passed in.

Model: opus-5
2026-09-08 04:58:31 +00:00
sneak a8905f5fe7 Keep secrets out of the Docker build context at every depth (closes #29)
The canonical .dockerignore was three lines while the canonical
Dockerfile does `COPY . .`, so a local .env, *.pem or *.key shipped into
the build context and could land in an image layer, invisible to every
git-based check. Copying .gitignore's patterns across is not the repair:
.dockerignore anchors an unprefixed pattern at the context root, so that
form protects only the repository root while reading as solved. Every
depth-independent pattern here carries `**/`, and secret names are
character ranges because matching is case-sensitive and an ALL-CAPS twin
still misses `Server.Key`. Public certificates are deliberately left in
as a legitimate build input. Verified by enumerating a probe image.

Model: opus-5
2026-09-08 04:58:31 +00:00
sneak cb450f7de3 Compare versions when bootstrap installs a pinned tool (closes #28)
The canonical `if missing <tool>; then install; fi` guard tests PATH
presence and never version, so on any already-provisioned machine a pin
is inert and a version bump is a no-op, while the Dockerfile installs the
pinned version into a clean image and CI then disagrees with local about
what the tool is. Comparing versions alone is not enough either: an
installer writes to its own directory while callers resolve through PATH,
so a shadowing binary lets the install succeed and change nothing anyone
sees. REPO_POLICIES.md now states the whole form — exact whole-token
comparison, mis-parse falling through to a reinstall, re-resolution
through PATH after installing, and a call site that prints the version.

Model: opus-5
2026-09-08 04:58:31 +00:00
sneak 9c4edd611b Build with --no-cache so the check layer actually runs (closes #26)
script/cibuild was a plain `docker build .` and the Dockerfile does
`COPY . .` followed by `RUN make check`, so on an unchanged tree Docker
served the check layer from cache: the suite never ran and the build
still exited 0. Measured here before the change, a second run on a
byte-identical tree returned in 0.286s with `RUN make check` CACHED.
script/cibuild and script/docker now pass --no-cache. The canonical text
asserting that a bare `docker build .` proves the checks ran was wrong in
REPO_POLICIES.md, both checklists and the Go styleguide, and is corrected
in all of them.

Model: opus-5
2026-09-08 04:58:31 +00:00
15 changed files with 451 additions and 958 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every # `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and # depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while the file reads as solved. Only # `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these # genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong. # into .gitignore, where `**/` is wrong.
# #
+44 -14
View File
@@ -1,11 +1,48 @@
# Lint phase. The linter is invoked directly rather than through `make
# lint` or `script/lint`, which are themselves a docker build and would
# recurse into a daemon that does not exist in a build step.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Test phase, same shape and for the same reason.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN echo "No tests defined."
# Development environment, and the last stage: a plain `docker build .`
# names no target and so builds this one. Nothing is wanted from the two
# phases above; the copies are what make BuildKit build them first, so
# this image cannot be produced unless lint and test passed. A stage
# appended after this one would drop all three out of a plain build.
#
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app WORKDIR /app
# Makes script/lint run the linter directly rather than building COPY --from=lint /app/package.json /dev/null
# Dockerfile.lint, which would need a docker daemon here. COPY --from=test /app/package.json /dev/null
ENV LINT_IN_CONTAINER=1
# script/bootstrap installs all prerequisites. Manifests are copied # script/bootstrap installs all prerequisites. Manifests are copied
# first so that layer stays cached until dependencies change. # first so that layer stays cached until dependencies change.
@@ -15,14 +52,7 @@ RUN script/bootstrap
COPY . . COPY . .
# CHECK_EPOCH is a per-invocation nonce from script/cibuild and # The version is computed on the host and passed in, because
# script/docker; without it an unchanged tree serves this layer from # .dockerignore excludes .git.
# cache and the build reports a green it never ran. ARG is stage-scoped, ARG VERSION=dev
# so declare it in every stage that runs checks. The guard fails a bare LABEL org.opencontainers.image.version="${VERSION}"
# `docker build .`, which would otherwise reuse the empty (and therefore
# stable) cache key. The value is also expanded into the check command,
# so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG; keep both references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
-27
View File
@@ -1,27 +0,0 @@
# Lint-only image, built by script/lint when it is not already inside a
# container. Linting is a build step, so a successful build is a clean
# lint, and nothing is bind-mounted, which matters when the daemon is
# remote.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
# Makes script/lint run the linter directly instead of recursing into
# another docker build, which has no daemon here.
ENV LINT_IN_CONTAINER=1
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# ARG sits after the dependency layer so that layer stays cached and
# only the lint re-runs. The guard fails a bare `docker build
# -f Dockerfile.lint .`, which would otherwise reuse the empty (stable)
# cache key and report a lint it never ran.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint
+17 -16
View File
@@ -116,22 +116,23 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `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``docker build --no-cache --target test -t prompts-test .`,
- `script/lint` — lint the markdown files with prettier. Inside a container building the `test` phase of the `Dockerfile` (no tests defined here)
(`LINT_IN_CONTAINER=1`, set by both Dockerfiles) it runs prettier directly; on - `script/lint``docker build --no-cache --target lint -t prompts-lint .`,
a host it builds `Dockerfile.lint` so the linter still runs in a container building the `lint` phase, which runs prettier over the markdown files
- `script/fmt` — format all markdown files with prettier (writes) - `script/fmt` — format all markdown files with prettier (writes; native, not in
- `script/fmt-check` — check formatting (read-only) a container)
- `script/check`run all checks: `test`, `lint`, `fmt-check` (our own - `script/fmt-check`check formatting (read-only; native)
extension) - `script/check` — run all checks: `test`, `lint`, `fmt-check`, then
- `script/docker` build the Docker image, tagged via `script/projectname` `docker build -t prompts .` (our own extension; no `--no-cache` there, because
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as the two phases that build depends on were just built without it)
`script/cibuild` - `script/docker`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then `docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image coming from `script/projectname` (byte-identical across repos)
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what - `script/cibuild` — cd to the repo root, compute `version` from `git describe`,
stops Docker serving that check from cache on an unchanged tree — a bare run `script/check`, then
`docker build .` fails closed on purpose) `docker build --build-arg VERSION="$version" -t prompts .` (what CI runs; the
version is computed on the host because `.dockerignore` excludes `.git`)
- `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
+30 -108
View File
@@ -21,114 +21,36 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Moved every lint run into a container. `script/lint` now runs the - 2026-09-08: Moved linting and testing into Docker as phases of the main
linter directly when `LINT_IN_CONTAINER=1` and otherwise builds `Dockerfile`, per the owner ruling on issue 40. `script/lint` and
`Dockerfile.lint`, so the linter never runs on a developer host — closing the `script/test` build one phase each by name with `--no-cache` — the same answer
content-keyed result cache that produced a confirmed false green, the issue 26 got, so no separate cache-busting mechanism survives — and the final
host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew. stage copies a harmless file from both, so the image cannot be built unless
Detection is on that marker alone: a false negative inside a container fails they pass. This also closes issue 30: a container has its own result cache and
loudly on the missing daemon, while a false positive on a host would silently its own lock, so a lint verdict can no longer belong to another checkout. No
restore host linting, so `/.dockerenv` is rejected outright — measured absent separate lint Dockerfile, and no `golangci-lint config verify` step.
inside BuildKit `RUN` steps and present on hosts that are themselves - 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
containers. Everything else keeps its existing shape: `make check` still runs of version control: `.claude/` is one full checkout of the repo per in-flight
in the image, `script/cibuild` is still one build, and the Go multistage lint agent, and under `COPY . .` all of it was reaching the image. Also closed the
stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the consequence of excluding `.git``git describe` yields an empty version
same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only inside a build stage without failing, so `script/docker` and `script/cibuild`
the lint re-runs. The `script/bootstrap` golangci-lint install and the now compute the version on the host and pass `--build-arg VERSION`.
per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS - 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a
repo's `yarn install` stays, since the rule is about where a verdict comes local `.env`, `*.pem` or `*.key` was reaching the build context under
from, not about which binaries exist. `golangci-lint config verify` was kept `COPY . .`, invisible to every git-based check. The patterns are now written
on measurement: a bogus config key passes `golangci-lint run` with `0 issues` to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every
and fails `config verify`, and every case reproduced byte-identically under depth, case-folded with character ranges — and `REPO_POLICIES.md` requires
`--network none`, so the schema is embedded and the line costs no network. verifying by enumerating the image rather than by reading the file.
Comment blocks across the touched files were cut hard in the same pass. - 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it. `REPO_POLICIES.md` now requires comparing the installed version against the
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the pin rather than testing `PATH` presence, and re-resolving the binary through
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two `PATH` after installing, so a version bump cannot be a silent no-op and a
are separate defects and the second is the one that gets dropped: the result shadowed install cannot report success.
cache is keyed on file content rather than location, so checkouts holding - 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild`
identical files serve each other's findings under the other's path, while the and `script/docker` now build with `--no-cache`, so the Dockerfile's check
concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of layers cannot be served from cache on an unchanged tree, and the text claiming
the cache, and unaffected by isolating it. Moving workers from worktrees to a bare `docker build .` proves the checks ran is corrected in
their own clones does not help either half; it only removes the foreign-path `REPO_POLICIES.md`, both checklists and the Go styleguide.
artefact that made the defect visible. The lock error is retried rather than
surfaced, because it is not a result: it exits non-zero exactly as findings
do, and reporting it as findings sends a correct branch back for rework.
Detection is on the stderr stream and never on exit status, so a finding
quoting the lock message in source cannot be retried away, and exhaustion
exits 75 with a VOID message rather than passing or failing quietly.
`--allow-serial-runners` (which keeps the guard and queues) covers the
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners`
is rejected outright. The stdout and stderr capture files are per invocation
rather than per checkout, because serialising the linter does not serialise
the shell's redirections: two runs in one checkout — the overlap the flag
exists to support — would otherwise truncate and read each other's output,
which is the same defect one layer above where it was fixed. Both checklists
gained the corresponding items, since a half-fix that sets only the cache
reads as complete. `GOCACHE` was measured and does not need isolating.
Verified with the snippet extracted from the committed document and executed
as a consuming repo would adopt it, against paired controls: contamination
reproduced on the pre-fix form and absent on the adopted one, retry engaged,
exhaustion loud, a genuine finding still reported, and a held host lock
failing the pre-fix script while leaving the adopted one untouched.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own
`moby/patternmatcher` semantics — `**/`-prefixed so they hold at every depth,
which also fixes nested `node_modules` — rather than transplanted from
`.gitignore`, whose unprefixed form protects only the repository root while
reading as solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the
`prod.env` convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys,
every one of them case-folded with character ranges because matching is
case-sensitive and an ALL-CAPS twin per pattern still misses `Server.Key`.
`REPO_POLICIES.md` and both repo checklists now state that asymmetry and
require verification by enumerating the image rather than by reading the
patterns. Verified with a probe image before, against three naive forms
(unprefixed, lowercase-only, ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema - 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually
+16 -26
View File
@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-10 last_modified: 2026-09-08
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -50,18 +50,12 @@ last_modified: 2026-08-10
```make ```make
# ?= rather than := because this `$(shell git describe ...)` is only # ?= rather than := because this `$(shell git describe ...)` is only
# correct on the host. `.dockerignore` excludes `.git`, so evaluated # correct on the host: `.dockerignore` excludes `.git`, so evaluated
# inside a build stage it expands to the empty string without failing # 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 # and the binary reports no version. The version is computed on the
# the host by `script/docker` / `script/cibuild` and passed with # host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by # `--build-arg VERSION=...`; where a build stage invokes make,
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the # `ARG VERSION` puts it in the environment and `?=` defers to it.
# 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) VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
@@ -112,22 +106,18 @@ last_modified: 2026-08-10
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. Run it with `make lint`, never by invoking the binary: the `golangci-lint`. Run it with `make lint`, never by invoking the binary: the
linter always runs in a container, and `golangci-lint` is not installed on linter runs as a phase of the `Dockerfile` and is not installed on the host
the host by any repo. Invoked directly on a shared host it reads a result by any repo. Invoked directly on a shared host it reads a result cache keyed
cache keyed on file content rather than location, and a host-global lock, so on file content rather than location, and a host-global lock, so its answer
its answer may belong to another checkout entirely. 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 and
linting. `script/cibuild` and `script/docker` should always make sure that linting. It carries the lint and test phases, and the final stage depends on
the code is in an able-to-be-compiled state, linted, and any tests run, and both, so a build makes sure the code is in an able-to-be-compiled state,
the build should fail if linting doesn't pass. That guarantee holds only linted, and its tests run. Go through `script/cibuild` or `script/docker`
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that rather than a bare `docker build .`: they pass `--no-cache`, without which
busts the check layers out of the Docker cache; without it an unchanged tree an unchanged tree serves the gate layers from cache and the build reports a
serves those layers from cache and the build reports a green it never ran. A green it never ran.
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]`
guard — always go through `script/cibuild` or `script/docker`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
+46 -71
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-09-08
--- ---
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
@@ -28,61 +28,41 @@ with your task.
artifacts, secrets) — fetch from artifacts, secrets) — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` 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, 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 so check the entries rather than the file's presence.
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; the Dockerfile carries a `lint`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); phase and a `test` phase, and the final stage carries a `COPY --from=` of
Dockerfile runs `make check` as a build step, and every stage containing a a harmless file from each — fetch `.dockerignore` from
check-running `RUN` declares `ARG CHECK_EPOCH` with the `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the - [ ] Nothing has been appended after the final stage, and the gate phases are
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is reachable from it. A stage nothing depends on is built only when
served from cache on an unchanged tree and the build reports a green it `--target` names it, so a lost `COPY --from=` edge leaves `docker build .`
never ran. passing while the gate never runs. Confirm by planting a violation, not by
- [ ] **Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`** — the lint reading the file.
stage and the build stage both. This is the item an existing repo most - [ ] The gate phases invoke their tools directly, never through `make lint` or
often fails after adopting the containerised lint: without it `script/test` — those are themselves a `docker build` and would recurse
`script/lint` tries to build `Dockerfile.lint` from inside a build step, inside a build step
where there is no daemon. - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix,
- [ ] `Dockerfile.lint` exists and `script/lint` builds it when not already in a only genuinely root-anchored entries such as `.git` are unprefixed, and
container — see the containerised-lint rule in `REPO_POLICIES.md`. Base `.gitignore`'s patterns have not been transplanted unmodified — the
image pinned by sha256 with a version/date comment, `ARG CHECK_EPOCH` transplanted form leaves `config/.env` and `certs/server.key` in the build
**after** the dependency layer with the guard below it. context while reading as solved
- [ ] `.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`. An existing repo is where such a binary is
existing repo is where such a binary is likeliest to already be sitting in likeliest to already be sitting in the build context, invisible to git.
the build context, invisible to git. - [ ] `.claude/` is in `.gitignore` (unanchored) and `.claude` in
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/` `.dockerignore` (anchored, no `**/` prefix). Agent worktrees are entire
prefix. Agent worktrees are entire checkouts of the repo, so they inflate checkouts of the repo, so they inflate the context by a multiple of it and
the context by a multiple of it and can copy another session's unreviewed can copy another session's unreviewed work into an image layer. If agents
work into an image layer. Confirm by enumerating the image, not by reading here run anywhere other than the repo root, the anchored entry misses
the file — `.gitignore` hides these from `git status` too. `services/api/.claude/`: add anchored entries for those directories.
- [ ] **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 - [ ] 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 host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes `script/cibuild`, and no stage calls `git describe`. A tag-derived version
`.git`, so it yields an empty version without failing the build. A additionally needs `fetch-depth: 0` on the CI checkout step, which clones
tag-derived version additionally needs `fetch-depth: 0` on the CI checkout shallow and fetches no tags by default.
step, which clones shallow and fetches no tags by default.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified.
`.dockerignore` anchors an unprefixed pattern at the context root, so the
transplanted form leaves `config/.env` and `certs/server.key` in the build
context while reading as solved — see the `.dockerignore` rule in
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -109,24 +89,20 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` is the canonical detect-and-branch form, and no host - [ ] `script/lint` and `script/test` build their phase by name
invocation anywhere in the repo can produce a lint **verdict** — grep for (`docker build --no-cache --target <phase> -t <name>-<phase> .`), and no
host invocation anywhere in the repo can produce a lint verdict — grep for
the linter's own name across `script/`, the `Makefile` and CI config, not the linter's own name across `script/`, the `Makefile` and CI config, not
just `script/lint`. A second path is likeliest here: a `make lint-fast`, just `script/lint`. A second path is likeliest here: a `make lint-fast`,
an older container-versus-host branch, or a CI step calling the binary an older host-versus-container branch, or a CI step calling the binary
directly. **Expected hits that are not the defect**: `script/fmt`, and in directly. `script/fmt` and `script/fmt-check` are expected hits and stay
a repo whose formatter is also its linter, `script/fmt-check`. Everything on the host.
else the grep finds is a real second path and goes. - [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
- [ ] Detection is on `LINT_IN_CONTAINER` alone. Reject any `/.dockerenv` or dangling image behind on every run, on every host and CI runner
cgroup heuristic: absent in BuildKit `RUN` steps, present on hosts that - [ ] `script/bootstrap` installs no linter of its own — delete the block, its
are themselves containers, and a false positive lints on the host. version variables and its call site. A JS repo's `yarn install` stays; it
- [ ] `script/bootstrap` installs no golangci-lint. Delete the block, its brings a linter along with every other dependency, and no verdict is taken
version and ref variables, and its call site. A JS repo's `yarn install` from it.
stays — it brings a linter along with every other dependency, which is
fine as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 90-second timeout and completes within the 60-second - [ ] `make test` has a 90-second timeout and completes within the 60-second
hard cap (over 20 seconds is green but must be filed as an improvement hard cap (over 20 seconds is green but must be filed as an improvement
@@ -175,9 +151,8 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE` - [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
both times, never `CACHED` and never sub-second sub-second build, or `CACHED` on a gate layer, means nothing ran
- [ ] `script/cibuild` succeeds (a bare `docker build .` or - [ ] A planted lint violation fails both `make lint` and a plain
`docker build -f Dockerfile.lint .` fails closed by design, on the `docker build .`; revert it afterwards
`CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-08-09 last_modified: 2026-09-08
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -997,7 +997,7 @@ Use ldflags to inject version information at build time:
# no version. The version is computed on the host by `script/docker` / # no version. The version is computed on the host by `script/docker` /
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build # `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers # stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md. # to it.
VERSION ?= $(shell git describe --tags --always) VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH) BUILDARCH := $(shell go env GOARCH)
+39 -76
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-09-08
--- ---
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
@@ -62,38 +62,24 @@ Template files can be fetched from:
`cmd/myapp/` and delete the package directory. Do not transplant `cmd/myapp/` and delete the package directory. Do not transplant
`.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. The canonical file's `.claude` entry is
`REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for anchored for the same reason as a repo-root binary; leave it that way, but
the same reason as a repo-root binary; leave it that way, but note it only note that it only covers agents running at the repo root — if this repo
covers agents running at the repo root — if this repo will run them in will run them in subdirectories, `services/api/.claude/` needs its own
subdirectories, `services/api/.claude/` is not excluded and needs its own
anchored entry. anchored entry.
- If the image embeds a version in a binary, the version is computed on the - If the image embeds a version in a binary, the version is computed on the
host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is host and passed with `--build-arg VERSION=...`, and `ARG VERSION=dev` is
declared in the stage that compiles, and **no stage calls `git describe`** declared in the stage that compiles. **No stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without `.dockerignore` excludes `.git`, so it yields an empty version without
failing the build. failing the build.
- All Dockerfiles must run `make check` as a build step, and every stage - The Dockerfile carries a `lint` phase and a `test` phase, each invoking
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the its tool directly rather than through `make` or `script/`, and the final
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the stage carries a `COPY --from=` of a harmless file from each so the image
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is cannot be built unless both passed. Keep the final stage last: a stage
served from cache on an unchanged tree and the build reports a green it nothing depends on is built only when `--target` names it.
never ran. - Server: the final stage builds and runs the application
- Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`, so - Non-server: the final stage brings up the dev environment
`script/lint` runs the linter natively instead of trying to build
`Dockerfile.lint` where there is no daemon.
- Go repos: separate `lint` stage on the `golangci/golangci-lint` image,
with `COPY --from=lint /src/go.sum /dev/null` in the build stage to force
the ordering. Re-prove that ordering warm after adopting `CHECK_EPOCH`.
- Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image `script/lint` builds when it is
not already inside a container. Sets `ENV LINT_IN_CONTAINER=1`; same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` **after** the dependency layer so only the lint re-runs. Base image
pinned by sha256 with a version/date comment. Copy from
`REPO_POLICIES.md`.
- [ ] 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`
@@ -118,51 +104,26 @@ are thin shims calling them. Model scripts:
installs installs
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`, - [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
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 (90-second - [ ] `script/test` / `make test``docker build --no-cache --target test .`,
timeout, 60-second hard cap on wall time) tagged; the phase runs real tests, not a no-op (90-second timeout,
- [ ] `script/lint` / `make lint` — runs the linter directly when 60-second hard cap on wall time)
`LINT_IN_CONTAINER=1`, otherwise `epoch="$(date +%s%N)$$"` on its own line - [ ] `script/lint` / `make lint``docker build --no-cache --target lint .`,
then `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. tagged. No lint verdict may come from a host invocation of the linter.
No lint verdict may come from a host invocation. Copy from - [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a
`REPO_POLICIES.md`. Detect on `LINT_IN_CONTAINER` only — never container)
`/.dockerenv`, which is absent in BuildKit `RUN` steps and present on - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
hosts that are themselves containers. Without the nonce this exits 0 on an native)
unchanged tree having linted nothing; without `-f Dockerfile.lint` it - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`, then
builds the main image and lints nothing at all. builds the image; must not modify files
- [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); carries the same three `script/projectname` (byte-identical across repos); `--no-cache`, plus the
version lines as `script/cibuild` below, and passes version as a build arg
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"` - [ ] `script/cibuild` — cd to repo root, run `script/check`, then
- [ ] `script/cibuild` — cd to repo root, then, each on its own line: `docker build --build-arg VERSION="$version" .` (what CI runs)
- [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a
```sh dangling image behind
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). 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 .`. The image runs
`make check`, which includes lint, so `script/cibuild` needs no separate
lint step.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit` `script/precommit`
@@ -173,12 +134,14 @@ 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 demonstrably executes - [ ] `script/cibuild` succeeds and demonstrably executed the checks — a
- [ ] No secrets in repo sub-second build, or `CACHED` on a gate layer, means nothing ran
- [ ] Plant a lint violation and confirm both `make lint` and a plain
`docker build .` fail on it; revert. A plain build that passes proves the
final stage is missing its `COPY --from=` edge to the gate phases.
- [ ] No secrets in repo, and none in the build context: enumerate a probe image
rather than reading `.dockerignore`
- [ ] No mutable image/package references - [ ] No mutable image/package references
- [ ] No unnecessary files in repo root - [ ] No unnecessary files in repo root
- [ ] All dates written as YYYY-MM-DD - [ ] All dates written as YYYY-MM-DD
+201 -560
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-19 last_modified: 2026-09-08
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,21 +60,18 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs repo root, runs `script/check`, and builds the image with the version; the
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, Gitea workflow calls it. Four further scripts are our own extensions to the
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and standard: `script/check` runs `script/test`, `script/lint` and
`version` is computed on the host because `.git` is not in the build context `script/fmt-check`, then builds the image; `script/precommit` is what the git
(see the git-describe rule below); the Gitea workflow calls it. Four further pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
scripts are our own extensions to the standard: `script/check` runs installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is `script/projectname` (literally that filename) simply outputs the project's
what the git pre-commit hook runs, and it calls `script/check`; name. Scripts that need the name call `script/projectname` — e.g.
`script/install-precommit` installs the git pre-commit hook (the `make hooks` `script/docker` assembles its image tag from it — so those scripts stay
target shims to it); and `script/projectname` (literally that filename) simply byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
outputs the project's name. Scripts that need the name call `go mod tidy` verification in Go repos) belong in `script/precommit`, not in
`script/projectname` — e.g. `script/docker` assembles its image tag from it — the hook itself. Model scripts are at
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).
@@ -93,361 +90,138 @@ 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`, and it carries the repo's gates: a
as a build step so the build fails if the branch is not green — the one `lint` phase and a `test` phase, with the final stage depending on both so the
exception being `Dockerfile.lint`, which runs `make lint` alone because that image cannot be built unless they pass. For non-server repos the final stage
is its entire purpose — which requires `ARG CHECK_EPOCH` and its guard in brings up a development environment; for server repos it is the runtime image.
every stage containing a check-running `RUN`, per the `CHECK_EPOCH` rule Dockerfiles install development prerequisites by running `script/bootstrap`
below. Without them a Dockerfile satisfies this criterion while its check rather than duplicating installs inline; COPY `script/` and the dependency
layers are served from cache, so the build cannot fail on a branch that is not manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
green. running it.
**Every Dockerfile must also set `ENV LINT_IN_CONTAINER=1`**, above the - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
checks. `script/lint` builds `Dockerfile.lint` when it is not already in a no separate lint file. `script/lint` and `script/test` each build one phase
container; without the marker it would try that from inside a build step, and nothing else:
where there is no daemon. See the containerised-lint rule below.
For non-server repos, the 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 image is assembled. Dockerfiles
install development prerequisites by running `script/bootstrap` rather than
duplicating installs inline; COPY `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it
so the bootstrap layer stays cached until dependencies change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the check layer is served from cache, the suite never runs, and
the build still exits 0. A sub-second `docker build` reporting success is a
cache hit, not a result. This applies to **every** file that runs checks in a
build step — `Dockerfile` and `Dockerfile.lint` alike; 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
ENV LINT_IN_CONTAINER=1
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
```
`ENV LINT_IN_CONTAINER=1` belongs in every such stage too, and is the line
most often missed: without it `make check` reaches `script/lint`, which
tries to build `Dockerfile.lint` from inside a build step where there is no
daemon. See the containerised-lint rule below.
and in both `script/cibuild` and `script/docker`:
```sh ```sh
epoch="$(date +%s%N)$$" docker build --no-cache --target lint -t "$(script/projectname)-lint" .
version="$(git describe --tags --always --dirty 2>/dev/null || true)" docker build --no-cache --target test -t "$(script/projectname)-test" .
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
``` ```
`script/lint` needs the same nonce but is **not** this command: it builds a **A stage that is not the last one in the file is built only when the final
different file with `-f Dockerfile.lint` and passes no version. Copy its stage's chain depends on it, or when `--target` names it.** That is why the
form from the containerised-lint rule below, not this block — a two gates are always invoked by name here, and why the final stage carries a
`docker build` with no `-f` builds the main image and lints nothing. `COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
The `VERSION` lines are there for a different reason, covered by the **Every `docker build` in `script/` is tagged**, here and in `script/check`,
git-describe rule below; they are shown here so the two rules do not each `script/cibuild` and `script/docker`. An untagged build leaves a dangling
document half a command. All four `CHECK_EPOCH` elements are load-bearing; image behind on every invocation, on every developer host and every CI
none is optional, and each guards a failure mode that otherwise fails green: runner; a tagged one replaces the previous image.
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (rendered as
`RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build
output.
- The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is
a stable cache key, so without it a bare `docker build .` still produces
the false green. Failed steps are never cached, so the guard fails on
every such invocation, loudly. A bare `docker build .` failing is by
design.
- Assign `epoch=` on its own line, never inline in the `--build-arg`
argument: a failing command substitution inside an argument does not trip
`set -e`, so the inline form silently degrades to an empty constant. The
`$$` suffix is required because busybox `date` drops `%N` and exits 0, so
on an alpine host the epoch would degrade to second granularity and
concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`go mod download` and `script/bootstrap` cached, so it does not push against `eslint`, `prettier` — never through `make lint` or `script/test`, which are
the five-minute Docker build ceiling. Blanket `--no-cache` is not an themselves a `docker build` and would recurse into a daemon that does not
acceptable substitute: it also busts the dependency layer, so every run exist in a build step. Formatting is the exception and stays on the host:
reinstalls dependencies over the network instead of only the first and those `script/fmt` writes the working tree, and `script/fmt-check` is its
after a manifest change. Never reach for `docker builder prune` — the build read-only twin.
cache is shared with every other build on the host.
- **Every lint run happens in a container.** `script/lint` runs the linter **No lint verdict may come from a host invocation of the linter.** On a
directly when it is already inside one, and otherwise builds `Dockerfile.lint` shared host golangci-lint reads a result cache keyed on file content rather
so that it is. Either way the linter never runs on a developer host, where its than location, so a second checkout of the same content is served the first
answer is not trustworthy: one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
- **Confirmed false green.** golangci-lint keys cached results on file exit non-zero with `parallel golangci-lint is running` — a status a caller
**content, not location**, so a second checkout of the same commit serves cannot tell from real findings. Both have produced wrong verdicts in this
its findings. One implementer reported `0 issues` on a branch genuinely org, in both directions. A container has its own cache, its own `TMPDIR` and
red with a `goconst` finding. Own-clones-instead-of-worktrees does not a digest-pinned binary, so neither is reachable.
help; two clones are byte-identical exactly as two worktrees were.
- **False reds**: findings reported against other checkouts and against
worktrees already deleted; in one case 399 issues returned to a clean
clone that genuinely lints 0.
- **Lock contention indistinguishable from findings.** golangci-lint flocks
`$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`, `acquireFileLock()`),
host-global and independent of `GOLANGCI_LINT_CACHE`, 5-second timeout. It
prints `parallel golangci-lint is running`, analyzes nothing, exits
non-zero. Not fixed by per-cache isolation — measured.
- **Version skew**: a host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed.
A container has its own cache, its own `TMPDIR` and a binary pinned by - **Any build that runs checks is built with `--no-cache`.** Docker invalidates
digest, so none of it is reachable. This supersedes the per-checkout a `COPY` layer only when the copied content changes, so on an unchanged tree
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper, which existed only to make a host the check `RUN` is served from cache, nothing executes, and the build still
run trustworthy; delete it on adoption. exits 0. `script/lint`, `script/test`, `script/cibuild` and `script/docker`
therefore pass `--no-cache`, and a bare `docker build .` is not evidence that
anything ran: a sub-second build reporting success is a cache hit, not a
result. The one build that may go through the cache is the image build inside
`script/check`, which runs immediately after the two gate phases were built
uncached and so reuses that run. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host.
The canonical `script/lint`, whose executable lines are the same in every - **The gate phases are separate stages, and the build stage depends on both.**
repo apart from the native lint command: The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
```sh and the test phase is based on the Go image. The canonical Go repo
#!/bin/sh `Dockerfile`:
# script/lint: run the linter. Inside a container, run it directly; on a
# host, build Dockerfile.lint so it runs in one anyway.
#
# LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY
# accepted signal. Do not add a /.dockerenv fallback: it is absent inside
# BuildKit RUN steps and present on hosts that are themselves containers,
# so it both misses and false-positives — and a false positive silently
# restores host linting.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then
# config verify lives here, not in a Dockerfile, so every path
# that lints inherits it — the lint stage of the main image as
# well as Dockerfile.lint. Duplicating it into each Dockerfile
# is how one of them silently loses it.
golangci-lint config verify --config .golangci.yml
exec golangci-lint run --config .golangci.yml ./...
fi
# Own line, and `$$` because busybox `date` drops %N silently.
# Without a fresh nonce the lint layer is cached and this exits 0
# having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
}
main "$@"
```
and `Dockerfile.lint`, the standalone path for a developer host:
```dockerfile ```dockerfile
# Lint-only image, built by script/lint when not already in a container. # Lint phase
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# ARG after the dependency layer so only the lint re-runs.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint
```
Load-bearing properties:
- **Detection rests on `LINT_IN_CONTAINER=1` and nothing else.** Every
Dockerfile in the repo sets it; a host does not. The asymmetry is the
whole design: a **false negative** inside a container tries a nested
`docker build`, finds no daemon and fails loudly, while a **false
positive** on a host silently lints there — the exact defect this rule
exists to kill. So the signal must be one only our own images can produce.
`/.dockerenv` is not such a signal and must not be used, even as a
fallback: measured, it is **absent** inside BuildKit `RUN` steps and
**present** on any host that is itself a container, which is the common
case for CI runners and agent sandboxes. It fails in both directions, and
one of them is the dangerous one.
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted
nothing. The `ARG` goes **after** the dependency layer so only the lint
re-runs; `--no-cache` would also reinstall dependencies on every lint.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck`. Only the base image and the native lint
command change.
- **Keep `golangci-lint config verify`, put it in `script/lint`, and it
costs no network.** It goes in the native branch, not in a Dockerfile, so
the lint stage of the main image inherits it along with `Dockerfile.lint`;
putting it in one Dockerfile leaves the other path unverified. The two
commands catch disjoint classes, measured under the pinned v2.12.2: a
bogus top-level key and a bogus key under `linters.settings.lll` both pass
`golangci-lint run` with **exit 0 and `0 issues`** while `config verify`
exits 3 and names them; an invalid value type fails both; an unknown
linter name fails `run` and passes `config verify`. So `run` alone
silently ignores an unknown key — the mode where a threshold reads as
configured and is not applied. It needs no network: every case reproduced
byte-identically under `docker run --network none`, in a container where
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin.
- **A failed `script/lint` that names no finding is not a lint result.** On
the host path `docker build` exits 1 both for findings and for a build
that never got there (daemon down, image unpullable, disk full). BuildKit
names the failing step; read it, fix the environment, re-run. Do not
record a verdict from a run that did not lint.
**Scope: this rule is about linters, and a formatter is not one.**
`script/fmt` writes your working tree, so it can only run on the host, and
`script/fmt-check` is its read-only twin. In a repo whose formatter **is**
its linter (prettier over markdown; this repo), `script/bootstrap` therefore
installs the linter on the host as an ordinary dependency and
`script/fmt-check` runs it there. That is accepted: the version is pinned in
`package.json` and installed into the repo's own `node_modules`, so there is
no shared content-keyed cache, no host-global lock and nothing to skew
against. What is forbidden is taking a **lint verdict** from it —
`script/lint` stays the only source of one. A repo auditing itself will see
those hits and should leave them; anything else the grep finds is a real
second path to the linter and goes.
**What a consuming repo does to adopt this**, in order:
1. Add `Dockerfile.lint`.
2. Replace `script/lint` with the form above, with its own native lint
command.
3. Add `ENV LINT_IN_CONTAINER=1` to **every** stage of every Dockerfile that
runs checks — the lint stage and the build stage both.
4. Delete any golangci-lint install from `script/bootstrap`, with its
version and ref variables and its call site. No lint verdict comes from
the host any more, so it can only reintroduce version skew. A JS repo's
`yarn install` stays.
5. Delete the per-checkout lint state: `GOLANGCI_LINT_CACHE` and `TMPDIR`
exports, `--allow-serial-runners`, the retry/VOID wrapper, and
`.lint-cache/` from both `.gitignore` and `.dockerignore`.
6. Verify by running `make lint` twice on an unchanged tree: the lint layer
must be `DONE` both times, never `CACHED`. Then plant a violation,
confirm it fails naming the finding, revert. A bare
`docker build -f Dockerfile.lint .` must fail on the guard.
`script/check`, `script/cibuild`, `script/docker` and the `Dockerfile` are
unchanged by this: `make check` still runs inside the image, and
`script/lint` there takes the native path.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash), so lint failures
surface in seconds rather than after a full compile. The build stage declares
an explicit dependency on it via `COPY --from=lint /src/go.sum /dev/null`,
which forces BuildKit — which runs stages in parallel by default — to finish
linting first. The canonical Go repo `Dockerfile`:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
ENV LINT_IN_CONTAINER=1
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH RUN golangci-lint run --config .golangci.yml ./...
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make lint
# Build stage # Test phase
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# 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
ENV LINT_IN_CONTAINER=1
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && 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}" \
-o /app ./cmd/app/ -o /app ./cmd/app/
# Runtime stage # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it has - The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing. `make lint` there both Go and the linter), so nothing needs installing.
runs `script/lint`, which sees `LINT_IN_CONTAINER=1` and invokes - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
`golangci-lint` natively instead of building `Dockerfile.lint`. Without purpose is the ordering edge. BuildKit runs stages in parallel by default,
that `ENV` the stage would attempt a nested build and fail. and a stage nothing depends on is not built at all, so without these two
- `COPY --from=lint /src/go.sum /dev/null` is a no-op copy that exists only lines a red gate would not fail the build.
to create the stage dependency; without it a lint failure might not fail - Keep the runtime stage last, and if you add a stage after it, give it the
the overall build. same two copies. A plain `docker build .` builds the last stage's chain
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.** and nothing else.
The cache-bust turns the no-op `COPY` into a content-cache hit, so an - If the project uses `//go:embed` directives that reference build artifacts
ordering guarantee established cold does not automatically carry over. It (e.g. a web frontend compiled in a separate stage), the lint phase must
was re-proved in another org repo using the same trick and held, but that create placeholder files so the embed directives resolve. Example:
result does not transfer by assumption — re-check it warm. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project uses `//go:embed` referencing build artifacts, the lint - If the project requires CGO or system libraries for linting (e.g.
stage must create placeholders so the directives resolve: `vips-dev`), install them in the lint phase with `apk add`.
`RUN mkdir -p web/dist && touch web/dist/index.html`. - `ARG VERSION=dev` is declared in the stage that compiles and supplied by
- If linting needs CGO or system libraries (e.g. `vips-dev`), `apk add` them `script/docker` and `script/cibuild`; no stage may call `git describe`.
in the lint stage.
- Tests run in the build stage, not the lint stage: they may need compiled
artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at its last cached result. In each stage the guard sits immediately
below the `ARG` and the value is expanded into the first check `RUN`.
Later `RUN`s in the same stage need no expansion; their parent layer is
already busted.
- `ARG VERSION=dev` is declared in the build stage and supplied by
`script/docker` and `script/cibuild`. **No stage may call
`git describe`**: `.dockerignore` excludes `.git`, so it yields an empty
version without failing. See the git-describe rule further down.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `script/cibuild` on push. That script runs the gate phases and then the
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) image build, so a successful run means every check passed; a bare
on push. The Dockerfile runs `make check`, so a successful build implies all `docker build .` does not carry the same guarantee, because its gate phases
checks pass — but that implication holds **only** because of the `CHECK_EPOCH` may come from the cache.
cache-bust described above. Without it, an unchanged tree serves the check
layer from cache and the build reports a green it never earned. A bare
`docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard;
always go through `script/cibuild` or `script/docker`. Never accept a pass as
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the
check 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
@@ -471,15 +245,17 @@ style conventions are in separate documents:
suite that exceeds it fails. Under 20 seconds is the target. A suite between suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an 20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test improvement bug against that repo. Add a 90-second timeout to the test
invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately invocation (`go test -timeout 90s`). The backstop deliberately sits above the
sits above the hard cap so that it catches a genuinely hung test rather than a hard cap so that it catches a genuinely hung test rather than a merely slow
merely slow one. one.
- **`make test` should use the conditional verbose rerun pattern.** Run tests - **The test command should use the conditional verbose rerun pattern.** Run
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to tests without `-v` (verbose) first. If tests fail, automatically rerun with
show full output. This keeps CI logs and `docker build` output clean on `-v` to show full output. This keeps CI logs and `docker build` output clean
success (just package/suite summaries) while providing full diagnostic detail on success (just package/suite summaries) while providing full diagnostic
on failure (every test case, every assertion). The general shell pattern: detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile ```makefile
test: test:
@@ -522,140 +298,81 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`, editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
which holds one worktree — an entire additional checkout of the repo — per language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
in-flight agent), language build artifacts, and `node_modules/`. Fetch the from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
standard `.gitignore` from setting up a new repo. These patterns are written to `.gitignore`'s own
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up semantics, in which an unanchored pattern already matches at every depth; they
a new repo. These patterns are written to `.gitignore`'s own semantics, in are not a `.dockerignore` and must not be transplanted into one unmodified.
which an unanchored pattern already matches at every depth. They are not a
`.dockerignore` and must not be transplanted into one unmodified — see the
next rule.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns - **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: Go `filepath.Match` semantics plus a `**` extension, `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
compiled to a regexp — plain `filepath.Match` has no `**` at all. So `*` does `*` does not cross `/` and a pattern without a leading `**/` is anchored at
not cross `/`, and a pattern without a leading `**/` is anchored at the the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` therefore excludes only the copies at the repository root, while `config/.env`
therefore excludes only the copies at the repository root; `config/.env` and and `certs/server.key` still reach the context and can land in an image layer
`certs/server.key` still reach the context and can land in an image layer. — which is more dangerous than a short file with no secret patterns at all,
That file is more dangerous than a short one with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix — `**/node_modules`, depth-independent pattern the `**/` prefix and leave only genuinely
`**/.DS_Store`, and the secret patterns in the canonical file, which are root-anchored entries unprefixed: `.git`, and the repo's own host-built
additionally case-folded per the rule below — and leave only genuinely binary, written `/myapp` and never `**/myapp`, which would also match
root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory `cmd/myapp/` and delete the package directory from the context. Matching is
`.claude`, and the repo's own host-built binary. The inverse move is equally case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
file that is wrong in a way that looks careful. Each file is written to its and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
own semantics; neither is derived from the other. Fetch the standard also catches something the build needs, re-include it with a negation
`.dockerignore` from (`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own host-built artifacts — a host `make build` that leaves it with the repo's own artifacts.
a compiled binary in the repo root puts that binary in the build context,
where `.gitignore` hides it from every git-based check. Write that binary
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context.
- **In-repo agent scratch belongs in both files, written to each file's own - **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 semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so with `COPY . .` the build context additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo, and another session's unreviewed, inflates by a multiple of the repo and another session's unreviewed work can
sometimes uncommitted work can be copied into an image layer. The directory is be copied into an image layer. In `.gitignore` the entry is `.claude/`,
also created and destroyed constantly, so it invalidates `COPY . .` for unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
reasons that have nothing to do with the repo's own content. In `.gitignore` prefix, because the prefixed form would also delete any nested directory of
the entry is `.claude/`, unanchored, which already matches at every depth. In that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the `.dockerignore` states in its own comment, since consuming repos receive the
directory occurs exactly once **where agents run at the repo root**, and the file and not the tracker: the directory is created in the agent's working
prefixed form would also match any nested directory of that name and delete it directory, so a repo running agents in subdirectories still ships
from the build. It is not case-folded the way the secret patterns are, because `services/api/.claude/` and must add its own anchored entry there.
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 - **Excluding `.git` means `git describe` cannot run inside any build stage, and
the agent's working directory, so the "exactly once, at the root" premise is it fails quietly there.** In a build stage there is no repository, so
a property of how agents are run and not of the tooling. Where agents run in `git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
subdirectories — a monorepo with a per-service agent is the ordinary case — the binary reports no version at all, and the build still exits 0. Compute the
`services/api/.claude/` is **not** excluded by the canonical entry and still version on the host and thread it in as a build arg. `script/docker` and
reaches the build context and the image, which is the exposure the entry `script/cibuild` do this, byte-identically across repos:
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 ```sh
character classes rather than by doubling patterns.** `**/*.key` does not # Own line: a failing command substitution inside an argument does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive # trip `set -e`, so the inline form degrades to an empty constant.
filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not version="$(git describe --tags --always --dirty 2>/dev/null || true)"
the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though [ -n "$version" ] || version="unknown"
case were handled — the same manufactured confidence as the root-anchored docker build --no-cache --build-arg VERSION="$version" .
form. The matcher supports character ranges, so one line covers every ```
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret
name, not only to extensions: the extensionless SSH keys and `.envrc` need it
for the same reason, since on the very filesystems that make `SERVER.KEY`
reachable, direnv reads `.ENVRC` and ssh reads `ID_RSA`. Note that `*` matches
the empty string, so `**/*.[eE][nN][vV]` already covers a bare `.ENV` and no
separate literal `.env` entry is needed.
- **A pattern that also catches something the build needs is re-included with a `--always` makes an untagged repo yield an abbreviated commit hash rather
negation, not deleted.** The canonical `**/*.[eE][nN][vV]` excludes a than failing, and the `[ -n "$version" ]` line is the single place the
committed env template such as `example.env`; a repo whose build genuinely fallback is applied — a live check that fires on a build from an export with
reads one adds `!docs/example.env` after the pattern. Deleting the pattern no `.git` and on a repository with no commits yet. Do not fold it into the
instead reopens the exposure for every other file it covers. substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). Reading the patterns and (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
agreeing they look right is exactly what lets the root-only form through. The size is not a substitute: a nested secret is a few bytes, and BuildKit
`transferring context` size is not a substitute: a nested secret is a few transfers only the delta from the previous build.
bytes, and BuildKit transfers only the delta from the previous build, so the
reported size describes the transfer and not the contents of the image.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes
`.git` is present; in a build stage there is no repository, so `git describe`
writes nothing to stdout and the `-X main.Version=` value comes out **empty**
rather than erroring. The binary then reports no version at all and the build
still exits 0. Compute the version **on the host** and thread it in as a build
arg. `script/docker` and `script/cibuild` do this, byte-identically across
repos:
```sh
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, so the inline form degrades to an
# empty constant — the same silent-empty failure this rule is about.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
`--always` makes an untagged repo yield the abbreviated commit hash instead
of failing. `|| true` keeps a failing `git describe` from tripping `set -e`
and leaves the value empty, so the `[ -n "$version" ]` line is the single
place the fallback is applied — and it is a **live** check, not defence in
depth: it fires on a build from an export with no `.git`, and on a
repository with no commits yet. Do not fold the fallback into the
substitution as `|| echo unknown`; that makes the guard unreachable, and a
guard that cannot fire is indistinguishable from one that works to everyone
who copies it. The result is non-empty by construction either way, which is
the point: an empty version reads as a successful one, while `unknown` is
visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that
compiles, declared there and not inherited, because `ARG` is stage-scoped
exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile
declares no such `ARG` is silently ignored by BuildKit and costs nothing,
which is why the scripts stay byte-identical rather than growing a per-repo
variant.
One consequence for CI: the standard checkout action clones shallow and
fetches no tags, so `git describe --tags` there falls back to a bare commit
hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on
its checkout step; a repo that does not embed a version needs no change.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -682,110 +399,34 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), pinned as the image digest in `Dockerfile.lint` v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
`golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That only pin, since no repo installs golangci-lint on the host: bumping the
digest is the only pin: golangci-lint is not installed on the host by any version means changing it and nothing else.
repo. Bumping the version means changing that one digest.
- **`script/bootstrap` must not install golangci-lint.** This supersedes the - **`script/bootstrap` installs a pinned tool by comparing versions, never by
pinned host install that used to be canonical here. `script/lint` never runs testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
it on the host — it either builds `Dockerfile.lint` or is already in a `PATH` only, so on an already-provisioned machine the pin is inert and a
container that ships the binary — so a host install has no caller, and its version bump is a silent no-op — while the Dockerfile, installing into a clean
only remaining effect is to put a second, independently-versioned linter where image, gets the pinned version, so a local `make check` and `make docker` can
somebody eventually runs it by hand and believes the result. Delete the block, disagree about what the tool even is. The canonical form:
its version and ref variables, and its call site. - compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
This is not a ban on host dependency installs generally. A JS or docs repo's Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
`script/bootstrap` runs `yarn install`, which brings its linter along with
every other dependency; that is unavoidable and fine. The rule is about a
**dedicated** linter install, and about where a verdict may come from.
**The version-enforcement principle it established still applies to any
other tool a repo pins and installs on the host**, and it is the part worth
keeping, because each of its four properties guards a failure that otherwise
reports success:
- **Compare the installed version against the pin, never test presence.** A
`if missing <tool>; then install; fi` guard tests `PATH` presence and
never version, so on any already-provisioned machine the pin is inert and
a version bump is a silent no-op. Compare the **whole** version token,
exactly: a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install — the original defect,
reintroduced through the comparison meant to fix it.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the directory the installer wrote to — and assert the
reported version is the pin. An installer that writes to `GOBIN` while a
different binary shadows it earlier in `PATH` genuinely succeeds and
changes nothing any caller sees, which is worse than no fix: it converts a
known-stale tool into one everyone believes is pinned. Run `hash -r` first
so the shell does not answer from its own lookup cache, and when the
assertion fails, name the path `command -v` found, the version it reports,
and the directory the install wrote to. Diagnose from the resolved path
rather than asserting a cause: only a path **outside** the install
directory is shadowing.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output and unrecognised output should
all yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** A function defined and never called is
a silent no-op indistinguishable from success: exit 0, nothing installed,
no output. Both success branches must print a line naming the version.
Verifying such logic requires a negative control in an environment where a
shadowing binary exists earlier in `PATH` than the install target — without
it the control passes against the naive compare-then-install form too and
proves nothing — plus a mis-parse control that feeds unparseable `--version`
output and confirms a reinstall. Run those controls against the block as a
consuming repo would adopt it: pasted into a `script/bootstrap`-shaped file
that is then executed, never by sourcing it and invoking the function
yourself. Driving the function directly tests something the artifact does
not do, and it is exactly how a missing call site passes every control while
the adopted snippet does nothing.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
- **Superseded: the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for
`script/lint`.** It existed only to make a host lint run trustworthy, and the
containerised-lint rule above removes the host run. Delete the wrapper, the
`--allow-serial-runners` flag, and `.lint-cache/` from both `.gitignore` and
`.dockerignore`. Two of its conclusions outlive it: **`GOCACHE` does not need
isolating** (measured — content-addressed, no foreign paths in its entries, no
global lock), and **verifying lint plumbing requires paired controls** run
against the artifact as a consuming repo would adopt it, since a control that
passes against the broken form proves nothing.
- **Interim rule for reading a lint result produced on the host, in a repo that
has not yet adopted the containerised lint above.** A lint run is **VOID**
unless both hold:
- the output contains no `parallel golangci-lint is running`, and
- no reported file path begins with `../`, and none is an absolute path
outside the tree the run was launched from.
Do not record a verdict from a void run, and do not "fix" findings in files
the change does not touch — chasing phantom findings across untouched files
puts unrelated edits into a reviewed diff, which is more expensive than the
wasted rework.
The `../` clause is the one that actually bites, and it is why a filter
keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports
paths relative to its own resolved root rather than yours, and three of the
org's reported sightings had relative paths and would have passed such a
filter. Both clauses are needed and neither alone is sufficient — one
reproduction exited non-zero with the lock error and no foreign paths at
all, and another reported 34 well-formed findings, every one of them against
another checkout.
**State the limit of these tests rather than treating them as a guarantee.**
They catch contamination that **names** foreign files. They cannot catch
contamination that **suppresses** findings through a poisoned entry for
colliding content, which has no wall-clock tell either — **no evidence of
that mode has been observed, and nobody should go chasing it**; the point is
the reach of the tests, not a claim that the mode exists. They are a filter
for the loud mode, not a proof of soundness — which is the whole argument
for containerising the linter instead of documenting a discipline that
depends on every agent remembering to apply it. Adopt the rule above and
this one stops applying to the repo entirely.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
+10 -2
View File
@@ -1,14 +1,22 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check) and then the
# extension to scripts-to-rule-them-all. Must not modify any files. # image build. Our own extension to scripts-to-rule-them-all. test and
# lint are Docker phases; fmt-check is native, because a formatter
# writes the working tree. Must not modify any files.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
# No --no-cache here: the two phases this build depends on were just
# built uncached above, so what it reuses is that run and not an
# older one.
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+12 -15
View File
@@ -1,28 +1,25 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but # script/cibuild: run the CI build. script/check runs the gates and
# that only proves anything because CHECK_EPOCH is a fresh nonce on every # builds the image; this repeats the build with the version, which
# invocation: without it Docker serves the check layer from cache and the # reuses the phases just built rather than re-running them.
# build exits 0 without running the suite.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Both assignments on their own line: a failing command substitution # Own line: a failing command substitution inside an argument does
# inside an argument does not trip `set -e`, so the inline form # not trip `set -e`, so the inline form degrades silently to an
# degrades silently to an empty constant. `$$` because busybox `date` # empty constant. VERSION is computed here because .dockerignore
# drops %N without erroring. VERSION is computed here because # excludes .git, so `git describe` in a build stage yields an empty
# .dockerignore excludes .git, so `git describe` in a build stage # version without failing.
# yields an empty version without failing; the guard below is the
# single place the fallback is applied.
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
"$SCRIPT_DIR/check"
docker build \ docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
. -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+10 -13
View File
@@ -1,9 +1,9 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. The # Identical in all repos; the tag comes from script/projectname.
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh # --no-cache because this is a standalone entrypoint: the lint and test
# nonce on every invocation; without it a warm cache turns this into a # phases the final stage depends on must run rather than be served from
# green that proves nothing. # an older build.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,17 +11,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Both assignments on their own line: a failing command substitution # Own line: a failing command substitution inside an argument does
# inside an argument does not trip `set -e`, so the inline form # not trip `set -e`, so the inline form degrades silently to an
# degrades silently to an empty constant. `$$` because busybox `date` # empty constant. VERSION is computed here because .dockerignore
# drops %N without erroring. VERSION is computed here because # excludes .git, so `git describe` in a build stage yields an empty
# .dockerignore excludes .git, so `git describe` in a build stage # version without failing.
# yields an empty version without failing.
epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build \ docker build --no-cache \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+13 -24
View File
@@ -1,34 +1,23 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Inside a container, run it directly; # script/lint: run the linter. Linting is a phase of the Dockerfile and
# on a host, build Dockerfile.lint so it runs in one anyway. The linter # this builds that phase alone; the linter is never installed or run on
# is never run on a developer host, where a shared result cache, a # a developer host, where a shared result cache and a host-global lock
# host-global lock and a stale toolchain make its answer untrustworthy. # make its answer untrustworthy.
# #
# LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY # The phase is not the last stage in the file, so it is built only when
# accepted signal. Do not add a /.dockerenv fallback: it is absent # --target names it. --no-cache because a cached lint layer is a lint
# inside BuildKit RUN steps and present on hosts that are themselves # that did not run. The tag makes each build replace the previous image
# containers, so it both misses and false-positives — and a false # instead of leaving a dangling one behind.
# positive silently restores host linting.
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"
docker build --no-cache \
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then --target lint \
exec yarn run prettier --check '**/*.md' \ -t "$("$SCRIPT_DIR/projectname")-lint" .
--tab-width 4 --prose-wrap always
fi
# Own line, and `$$` because busybox `date` drops %N silently.
# Without a fresh nonce the lint layer is cached and this exits 0
# having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"
+10 -3
View File
@@ -1,12 +1,19 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. # script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
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"
echo "No tests defined." docker build --no-cache \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"