Author SHA1 Message Date
sneak 6c489067ce Milestone next: check-cache busting, build-context hygiene, lint in a container (#34)
check / check (push) Successful in 24s
Reviewed-on: #34
2026-09-09 14:01:54 +02:00
sneak c4d5546e86 Gate the build on Docker lint and test phases (closes #40, closes #30)
check / check (push) Successful in 5m12s
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 build one phase each by name with caching disabled, and the
final stage copies a harmless file from each so the image cannot be built
unless both passed. A stage that is not the last is built only when
something depends on it or --target names it, so the gates are invoked by
name and the edges kept. script/check runs the gates and builds no image
of its own; script/cibuild bootstraps first, because CI runs it alone and
fmt-check is native. fmt and fmt-check source nvm for the pinned node
before calling yarn, which bootstrap installs but leaves off its caller's
PATH. Every build in script/ is tagged and uncached. Issue 30 closes too:
a container has its own lint cache and lock.

Model: opus-5
2026-09-09 11:44:59 +00:00
sneak 7b55c444ae 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-09 11:44:59 +00:00
sneak c3a504f647 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-09 11:44:58 +00:00
sneak 85bea7681e 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-09 11:44:58 +00:00
sneak 58f75147be 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-09 11:44:58 +00:00
58eafaf4c2 Add -count=1 to the canonical Go make test example (#45)
check / check (push) Successful in 7s
Answers #44: the canonical Go `make test` target omitted `-count=1`, so Go replayed cached successful results and the target could exit 0 having executed no test. Every repository that copied it inherited that false green.

The change adds `-count=1` to both `go test` invocations in the example in `REPO_POLICIES.md`, with a short paragraph saying why, and records the step in `TODO.md`. It defeats only the test-result cache, not the build cache, so it costs the suite's runtime and no recompilation. It is independent of the Docker layer cache that #26 addresses.

Rebased onto current `main`; the check is green. Repositories pick it up the next time each vendors the canonical files.

Model: opus-5 (change); fable-5-1 (this description)
Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: clawbot <cai2025@acidhou.se>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #45
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-09-09 13:41:57 +02:00
clawbotandsneak fbec5a523b Enable depguard so a non-test file cannot import test support (#59)
check / check (push) Successful in 51s
`depguard` was in the disable list. It is now enabled with one rule,
`test-support`: in files that are neither test files nor inside a package whose
directory name ends in `test`, the imports named under `deny` are refused.
Canonical denies `net/http/httptest`, which serves tests only and is the same
in every repository.

This replaces `internal/testimportgate` in sneak/homoicon, a package whose only
job was to refuse a non-test Go file importing an in-module package whose last
path segment ends in `test`. sneak ruled on
sneak/homoicon#1029 that the rule moves into linter
configuration here.

depguard cannot express that rule generically. Its package lists are prefix
lists -- its own README says so, and I confirmed it: `*test`, `**test` and
`$gomod/**test` each match nothing, while a full import path matches. "In
module" is not generic either, since the module path differs per repository.
And depguard refuses a rule with no allow or deny list, so this file cannot
ship the rule pre-armed and empty for each repository to fill in.

The closest expressible rule is the one here. The file half is generic and
exact; the package half is a deny list each repository extends with its own
test-support packages, by full import path. REPO_POLICIES.md now says that list
is the one part of a vendored copy a repository may add to.

Tested with golangci-lint v2.12.2 on a scratch module: a production file
importing a denied `*test` package fails, and the same import from a `_test.go`
file and from inside the `*test` package passes. `make check` here is green.

Model: opus-5
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #59
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-09-08 05:14:43 +02:00
clawbotandsneak f77d785bed Scope the .golangci.yml agent prohibition to vendored copies (#49)
check / check (push) Successful in 9s
SPECULATIVE and ahead of your ruling — nothing here is urgent and closing it costs nothing. One sentence of policy prose changed; no config file is touched.

## The contradiction

`REPO_POLICIES.md` line 266 currently reads:

&gt; `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only manually by the user.

Stated unqualified, that forbids an agent from modifying `.golangci.yml` **anywhere** — including the canonical copy in this repo, which is the only place it can ever be fixed. An agent that wants to remediate a linter problem must either violate the rule or leave the problem standing. A rule that cannot be complied with and satisfied at the same time gets resolved ad hoc, differently by each reader, which is the worst of both outcomes it was trying to produce.

This is not hypothetical. It has already cost real time:

- The `gomodguard` deprecation (#25) has been open since 2026-08-07 and still prints on every lint run in every consuming Go repo.
- One agent read the rule as binding here and **declined to open even a speculative branch**, so the fix was not written at all on that pass.
- #47 exists only because a later request was explicit enough to override the reading, and its lead comment asks for exactly this ruling before the PR itself can be judged on its merits.
- The same warning is refiled downstream as sneak/homoicon#4, where it is correctly marked owner-only and correctly punted upstream.

Each new agent that meets the rule reruns this whole argument.

## The change

Scope the prohibition to the vendored copy, and name the one legitimate path by which the config can change:

```
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
  _NEVER_ be modified by an agent: fetch it from
  `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
  byte-identical, so that no repo can quietly loosen its own linting. Linter
  configuration changes are made to the canonical copy in the `prompts` repo and
  reach consuming repos by re-vendoring; an agent may open a PR against
  canonical, which only the user merges.
```

The version pin sentence that followed is unchanged.

This keeps the property the rule exists for — no repo silently weakens its own linting, and divergence from canonical stays detectable — while removing the reading that freezes canonical itself. Your control is not reduced: an agent may open a PR here, and only you merge it.

## Scope of the wording sweep

I grepped every `.md` in the repo for the absolute phrasing. It appears in **exactly one place**, `prompts/REPO_POLICIES.md` lines 266-267.

`EXISTING_REPO_CHECKLIST.md` (line 39) and `NEW_REPO_CHECKLIST.md` (line 63) both mention `.golangci.yml`, but only as "fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`" — an instruction to vendor canonical verbatim, which is exactly what the scoped rule says. Neither carries a prohibition, so neither needs changing and neither is left contradicting the other. `REPO_POLICIES.md` line 414 lists `.golangci.yml` as a required file, also unaffected.

Note that the repo-root `REPO_POLICIES.md` is a symlink to `prompts/REPO_POLICIES.md`, so the single edit covers both paths.

## Deliberately NOT included

This PR does **not** change `.golangci.yml`. The `gomodguard` fix stays in #47 so the two can be judged separately — the policy question is worth settling on its own terms regardless of what you decide about that config change, and merging them would collapse two decisions into one.

## Unrelated observation, for the record

While verifying #47 against a scratch `sneak/homoicon` clone, I found that homoicon's vendored `.golangci.yml` is sha256 `391ea68e637432980f1db0776076f51578fa58193bbd17f4b40ad725975e21f8`, while canonical `main` is `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. The whole difference is a three-line comment recording a one-time agent edit you authorized on 2026-08-07; the config is functionally identical.

That matters only if you ever want a hash-based drift guard against canonical, which #25 floats as an offline alternative to `golangci-lint config verify`: such a guard would already report homoicon as drifted on day one. Worth knowing before building one. No change proposed here.

## Validation

`make check` passes (`prettier --check '**/*.md' --tab-width 4 --prose-wrap always`: all matched files clean). `make fmt` produced no further changes. `last_modified` in the front matter updated to 2026-08-19 per this file's own rule.

Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #49
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-30 06:26:19 +02:00
clawbotandsneak 3f8201d532 Require thin cmd/ entrypoints: all logic in internal/ or pkg/ (#54)
check / check (push) Successful in 13s
Codifies your ruling (2026-08-30, filed as homoicon issue 555): no project logic outside `internal/` or `pkg/`; each `cmd/<name>/` is a single `main.go` whose body is one call into library code.

- `CODE_STYLEGUIDE_GO.md`: strengthens the "keep `main` small" rule to the single-call form and adds the no-logic-outside-`internal/`-or-`pkg/` rule.
- `REPO_POLICIES.md`: annotates `cmd/` in the canonical subdirectory list accordingly. (Root copy is a symlink; one edit covers both.)

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #54
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-30 04:20:42 +02:00
clawbotandclawbot a8686891ba Raise org-wide make test cap to 60s, backstop timeout to 90s (#42)
check / check (push) Successful in 4s
## The question this answers

Is the 60-second test-time cap the new **org-wide** ceiling, or an approved
**dnswatcher-only** divergence?

- Question: #41
- Origin: sneak/dnswatcher#93

This PR implements **org-wide**.

## The ruling

On sneak/dnswatcher#93 (comment) (2026-08-09),
verbatim:

&gt; make the cap 60s in both and never use mocking, always use live resolvers and
&gt; assume the build and run environments have full unmodified unrestricted
&gt; internet access. it is ok if they fail due to a bad build environment that
&gt; alters dns packets.

And on #41 (comment), disambiguating
the scope:

&gt; org wide. the hard cap is 60 for ci/green, but over 20s should be filed as an
&gt; improvement bug.

That second comment landed after this work was started, and it confirms the
option implemented here. The two-tier shape it describes (60s hard, 20s target,
overage filed as a bug) is encoded in the policy text.

## What changed, and the number chosen

The backstop moves from `30s` to **`90s`**.

The old pairing was incoherent under the new cap: a 60-second ceiling with a
30-second `-timeout` means the timeout kills the suite long before the ceiling
is reached, so the ceiling would never be the thing that fails. The backstop has
to sit above the cap, where it does its actual job of catching a hung test
rather than a merely slow one. `90s` preserves the 1.5x backstop-to-cap ratio
the old `20s`/`30s` pair already had, so the relationship between the two
numbers is unchanged and only the scale moves.

Every place a number changed:

| File | What |
| --- | --- |
| `prompts/REPO_POLICIES.md` | Prose ceiling: `20 seconds` to `60 seconds`, plus the new 20s target / improvement-bug tier |
| `prompts/REPO_POLICIES.md` | Backstop prose: `30-second timeout` to `90-second timeout`, with the rationale for why it exceeds the cap |
| `prompts/REPO_POLICIES.md` | Go example Makefile snippet, first run: `go test -timeout 30s` to `-timeout 90s` |
| `prompts/REPO_POLICIES.md` | Go example Makefile snippet, verbose rerun: `go test -timeout 30s` to `-timeout 90s` |
| `prompts/EXISTING_REPO_CHECKLIST.md` | `make test` has a `30-second` timeout, to `90-second` timeout plus the 60s hard cap and the 20s filing rule |
| `prompts/NEW_REPO_CHECKLIST.md` | `script/test` / `make test` entrypoint line: `30-second timeout` to `90-second timeout, 60-second hard cap on wall time` |

I swept the whole repo for `20 second`, `30-second`, `20s`, `30s`, `timeout 20`,
`timeout 30`, and `under 20`/`under 30`. After this change the only remaining
occurrences of `20` in a test-timing context are the two intentional references
to the new 20-second target. The other `timeout` hits in the repo are unrelated
(`.golangci.yml` lint timeout, HTTP server `ReadTimeout`/`WriteTimeout` examples,
`middleware.Timeout`) and were left alone.

One deliberate non-change: the Python example Makefile snippet in
`prompts/REPO_POLICIES.md` carries no timeout flag today and still carries none.
`pytest` has no built-in timeout, so adding one would mean mandating the
`pytest-timeout` plugin org-wide, which is a new dependency requirement rather
than a renumbering, and outside what was ruled on. It is a pre-existing gap
between the prose and that snippet, not one this PR introduces. Happy to file it
separately or add `--timeout=90` here if you want it in scope.

## The alternative that was not implemented

**Keep canonical at 20s and let `sneak/dnswatcher` carry a documented per-repo
divergence.** That was the recommendation originally written up in
#41, on the reasoning that the 20s
ceiling is doing real work in repos with fast deterministic suites and only
dnswatcher needs the headroom.

Org-wide was chosen instead for two reasons. First, the pressure is not specific
to DNS: any repo whose tests exercise real infrastructure over the network
inherits the same variance, and there is no principled line that admits
dnswatcher and excludes the next such repo. Second, and more decisively,
`REPO_POLICIES.md` is a vendored file. A sanctioned per-repo divergence in a
vendored file is indistinguishable, on inspection, from a stale vendored copy:
the next re-vendoring silently reverts the divergence, and nobody reading a
consuming repo can tell whether the number they are looking at is an intentional
exception or drift. That is the bidirectional-drift problem already tracked in
#31. The two-tier cap gets the same
outcome without the drift, since a fast repo that regresses from 4s to 45s still
generates an improvement bug.

## Status

This PR was opened speculatively, ahead of a decision, on the standing "open it
rather than wait" instruction. The scope question has since been answered
org-wide in the issue, but the specific backstop value of `90s` and the two-tier
wording are still my proposals rather than anything ruled on, so closing this or
sending it back for a different number is a perfectly fine outcome.

`make check` passes; `make fmt` was run and the result is included (it was a
no-op, the edits were already prettier-conformant).

`sneak/dnswatcher` is landing the matching 60s edit to its vendored copy in
parallel, and will match whichever way this is decided.

Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #42
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-10 16:13:31 +02:00
18 changed files with 628 additions and 1169 deletions
+24 -63
View File
@@ -1,75 +1,37 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match # .dockerignore does NOT use .gitignore semantics. Docker matches with
# semantics plus a `**` extension, compiled to a regexp. Plain # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# filepath.Match has no `**` at all. What follows from that: `*` does not # `/` and an unprefixed pattern is anchored at the context root. Every
# cross `/`, and a pattern without a leading `**/` is anchored at the # depth-independent pattern therefore needs `**/`, or `config/.env` and
# build-context root. Every depth-independent pattern therefore needs the # `certs/server.key` still ship while this file reads as solved. Only
# `**/` prefix — without it `config/.env` and `certs/server.key` still # genuinely root-anchored entries go unprefixed. Never transplant these
# ship while the file reads as solved. # into .gitignore, where `**/` is wrong.
# #
# Root-anchored entries are for paths that occur exactly once, at the # Matching is case-sensitive, so secrets use character ranges rather
# context root. A host-built binary is the usual case, and it must be # than an ALL-CAPS twin, which would still miss `Server.Key`.
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context. In-repo agent scratch is the other case, for the same reason
# — with the caveat recorded at that entry: anchoring is exact only
# where agents run at the repo root, and a repo where they do not must
# add its own entries.
# #
# Matching is case-sensitive, so `**/*.key` does not match # Extend with this repo's own host-built artifacts, written anchored:
# `certs/SERVER.KEY`, which is reachable on the case-insensitive # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# filesystems most laptops use. Adding an ALL-CAPS twin per pattern is # deletes the package directory from the context.
# not the fix: it still misses `Server.Key` while reading as though case
# were handled. Character ranges cover every spelling in one line, so
# every secret name below is written that way — including the
# extensionless SSH keys and `.envrc`, because on those same
# case-insensitive filesystems direnv reads `.ENVRC` and ssh reads
# `ID_RSA`.
#
# `**/*.[eE][nN][vV]` also excludes a committed env template such as
# `example.env`. If the build genuinely needs one, re-include it with a
# negation after the pattern: `!docs/example.env`.
#
# Extend this file with the repo's own host-built artifacts (compiled
# binaries, test binaries, coverage output); those are per-repo and
# belong here because a host build otherwise drops them into the
# context.
# Repository metadata: exactly one, at the context root. Excluding it # Excluding .git means `git describe` cannot run in any build stage and
# means `git describe` cannot run in any build stage, and it fails # fails quietly there; pass the version in with --build-arg VERSION.
# quietly there rather than erroring, so a version embedded that way
# comes out empty. Compute the version on the host and pass it in with
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
.git .git
# In-repo agent scratch: a directory holding a full additional checkout # Agent scratch: one full checkout of the repo per in-flight agent.
# of the repo for each in-flight agent. Anchored because it occurs # Anchored because it occurs once where agents run at the repo root.
# exactly once *where agents run at the repo root*, which is the # KNOWN GAP: a repo running agents in subdirectories still ships
# convention this file assumes; the `**/` form would also match any # `services/api/.claude/` and must add its own anchored entry.
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
.claude .claude
# Environment files. `*.env` covers both the bare `.env` name (`*` matches # Environment files. `*.env` covers bare `.env` and the `prod.env`
# the empty string) and the `prod.env` convention. # convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]
**/.[eE][nN][vV].* **/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC] **/.[eE][nN][vV][rR][cC]
# Private keys and the bundles that carry them. Public certificates # Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are # (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
# sometimes a legitimate build input.
**/*.[pP][eE][mM] **/*.[pP][eE][mM]
**/*.[kK][eE][yY] **/*.[kK][eE][yY]
**/*.[pP]12 **/*.[pP]12
@@ -86,8 +48,7 @@
**/.DS_Store **/.DS_Store
**/Thumbs.db **/Thumbs.db
# Editor state. Never a build input, and it churns under a developer's # Editor state: never a build input, and it churns COPY.
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp **/*.swp
**/*.swo **/*.swo
**/*~ **/*~
+26 -1
View File
@@ -13,7 +13,6 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
@@ -28,6 +27,32 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+47 -26
View File
@@ -1,37 +1,58 @@
# 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 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apk here; node
# and yarn are already in the base image, so those steps are skipped).
# Dependency manifests are copied first so the bootstrap layer is
# cached until they change.
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# The individual non-lint checks, NOT `make check`. Lint is deliberately # Test phase, same shape and for the same reason.
# absent here: `script/lint` is itself a `docker build` (of #
# Dockerfile.lint), so running `make check` in this image would attempt # node 22-alpine, 2026-02-22
# a docker build inside a build step, where there is no daemon. Putting FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test
# `make check` back reintroduces exactly that recursion. Lint is not
# skipped — script/cibuild runs script/lint first, in its own container, WORKDIR /app
# before this build starts.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test COPY script/ script/
RUN script/fmt-check 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
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
COPY --from=lint /app/package.json /dev/null
COPY --from=test /app/package.json /dev/null
# script/bootstrap installs all prerequisites. Manifests are copied
# first so that layer stays cached until dependencies change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# The version is computed on the host and passed in, because
# .dockerignore excludes .git.
ARG VERSION=dev
LABEL org.opencontainers.image.version="${VERSION}"
-41
View File
@@ -1,41 +0,0 @@
# Lint-only image. `script/lint` builds this file and nothing else: the
# linter runs as a build step, so a successful build IS a clean lint.
# Building rather than bind-mounting is what makes it work where the
# docker daemon is remote and bind mounts are impossible.
#
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# This repo's linter is prettier over markdown. A Go repo's version of
# this file differs only in the base image and the two lint commands;
# see the containerised-lint rule in prompts/REPO_POLICIES.md.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
# Without that ordering the cache-bust would reinstall dependencies on
# every lint and make linting network-dependent.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# it an unchanged tree serves the lint layer from cache and the build
# reports a lint it never ran — a green that proves nothing, which is
# the whole failure mode this file exists to avoid reintroducing. The
# guard makes a bare `docker build -f Dockerfile.lint .` fail loudly
# instead of silently reusing the empty (and therefore stable) cache
# key. The value is expanded into the lint command as well, so the cache
# miss does not depend on BuildKit's handling of an unreferenced ARG and
# the epoch is visible in the build log. Keep both references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
+17 -19
View File
@@ -116,26 +116,24 @@ 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, by building `Dockerfile.lint`. The building the `test` phase of the `Dockerfile` (no tests defined here)
linter runs in a container, always: it is never installed on the host and - `script/lint``docker build --no-cache --target lint -t prompts-lint .`,
never invoked there. Linting happens as a build step, so a successful build is building the `lint` phase, which runs prettier over the markdown files
a clean lint, and the same per-invocation `CHECK_EPOCH` nonce used elsewhere - `script/fmt` — format all markdown files with prettier (writes; native, not in
is what stops Docker serving that lint from cache on an unchanged tree a container)
- `script/fmt` — format all markdown files with prettier (writes) - `script/fmt-check` — check formatting (read-only; native)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension). Needs a docker daemon, since `script/lint` is a container build extension); builds no image of its own
- `script/docker` build the Docker image, tagged via `script/projectname` - `script/docker`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as `docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
`script/cibuild` coming from `script/projectname` (byte-identical across repos)
- `script/cibuild` — cd to the repo root, run `script/lint` first, then assign - `script/cibuild` — cd to the repo root, run `script/bootstrap`, run
`epoch="$(date +%s%N)$$"` and `script/check`, compute `version` from `git describe`, then
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs). Two `docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI
container builds: the lint image, then the main image, which runs runs; it bootstraps because CI checks out and runs this alone while
`script/test` and `script/fmt-check` but deliberately not `make check` — that `script/fmt-check` is native, and the version is computed on the host because
would nest a docker build inside a build step. A bare `docker build .` fails `.dockerignore` excludes `.git`)
closed on purpose
- `script/precommit` — run by the git pre-commit hook (our own extension); calls - `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
+36 -134
View File
@@ -21,140 +21,42 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Closed three gaps the containerised-lint rule left between the - 2026-09-08: Moved linting and testing into Docker as phases of the main
canonical text and the first repos to implement it. `.dockerignore` excluding `Dockerfile`, per the owner ruling on issue 40. `script/lint` and
the agent scratch directory is now stated as a correctness precondition of `script/test` build one phase each by name with `--no-cache` — the same answer
that rule rather than a context-size measure: the lint image lints whatever issue 26 got, so no separate cache-busting mechanism survives — and the final
`COPY . .` copies, and toolchains discover files by walking the tree instead stage copies a harmless file from both, so the image cannot be built unless
of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds they pass. This also closes issue 30: a container has its own result cache and
back inside the container — `sneak/quak` measured the same discovery mechanism its own lock, so a lint verdict can no longer belong to another checkout. No
taking a test count from 210 to 1050. The cache-bust arg is fixed at separate lint Dockerfile, and no `golangci-lint config verify` step.
`CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is `script/check` runs the gates and nothing else, and `script/cibuild`
invisible to the grep that proves every build is busted, making a renamed bootstraps first, since it is all CI runs and `script/fmt-check` is native.
guard indistinguishable from a missing one. And the formatting check is now - 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
required to run in exactly one of the two images, with either placement of version control: `.claude/` is one full checkout of the repo per in-flight
allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check` agent, and under `COPY . .` all of it was reaching the image. Also closed the
gets dropped from both, and running the formatter beside the linters is the consequence of excluding `.git``git describe` yields an empty version
better shape where it is the same pinned dependency. inside a build stage without failing, so `script/docker` and `script/cibuild`
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and now compute the version on the host and pass `--build-arg VERSION`.
made this repo do it rather than merely document it. `script/lint` is now - 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a
`docker build -f Dockerfile.lint .` and nothing else; the linter is never local `.env`, `*.pem` or `*.key` was reaching the build context under
installed on the host and never invoked there, so a run cannot inherit another `COPY . .`, invisible to every git-based check. The patterns are now written
checkout's content-keyed result cache, the host-global to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every
`$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned depth, case-folded with character ranges — and `REPO_POLICIES.md` requires
one — the three mechanisms behind a confirmed false green, a string of verifying by enumerating the image rather than by reading the file.
findings reported against other agents' checkouts, and a container that saw - 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host.
thirteen findings the host missed. Linting runs as a build step, so a `REPO_POLICIES.md` now requires comparing the installed version against the
successful build is a clean lint, which also works where the docker daemon is pin rather than testing `PATH` presence, and re-resolving the binary through
remote and bind mounts are impossible. The recursion this creates is resolved `PATH` after installing, so a version bump cannot be a silent no-op and a
by direction rather than by detection: the main `Dockerfile` runs the shadowed install cannot report success.
individual non-lint checks instead of `make check`, and `script/cibuild` runs - 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild`
`script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries and `script/docker` now build with `--no-cache`, so the Dockerfile's check
the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the layers cannot be served from cache on an unchanged tree, and the text claiming
dependency layer so only the lint steps re-run — blanket `--no-cache` was a bare `docker build .` proves the checks ran is corrected in
rejected because it makes every lint reinstall its dependencies over the `REPO_POLICIES.md`, both checklists and the Go styleguide.
network. Two canonical forms were superseded rather than left standing beside - 2026-09-03: Added `-count=1` to both `go test` invocations in the canonical Go
the new one, since consuming repos read this document literally: the `make test` example in `REPO_POLICIES.md`, so the target cannot report a
`script/bootstrap` golangci-lint install (nothing runs a host linter now, so cached pass it did not earn, and documented that Go's test-result cache is a
it can only reintroduce skew; the version-enforcement principle stays second, independent cache stacked below the Docker layer cache.
documented for other pinned host tools) and the per-checkout
cache/lock/`.lint-cache` wrapper (its whole subject was making a host run
trustworthy). The Go multistage lint stage goes with them: it ran `make lint`,
which is now a docker build. `golangci-lint config verify` was kept on
measurement, not preference — a bogus config key passes `golangci-lint run`
with `0 issues` and fails `config verify`, and every case reproduced
byte-identically under `docker run --network none`, so the schema is embedded
in the pinned binary and the line costs no network. Verified with two
consecutive runs on an unchanged tree both executing the linter, a planted
violation caught and reverted, the bare-build guard firing, and the main image
building without attempting a nested build.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two
are separate defects and the second is the one that gets dropped: the result
cache is keyed on file content rather than location, so checkouts holding
identical files serve each other's findings under the other's path, while the
concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of
the cache, and unaffected by isolating it. Moving workers from worktrees to
their own clones does not help either half; it only removes the foreign-path
artefact that made the defect visible. The lock error is retried rather than
surfaced, because it is not a result: it exits non-zero exactly as findings
do, and reporting it as findings sends a correct branch back for rework.
Detection is on the stderr stream and never on exit status, so a finding
quoting the lock message in source cannot be retried away, and exhaustion
exits 75 with a VOID message rather than passing or failing quietly.
`--allow-serial-runners` (which keeps the guard and queues) covers the
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners`
is rejected outright. The stdout and stderr capture files are per invocation
rather than per checkout, because serialising the linter does not serialise
the shell's redirections: two runs in one checkout — the overlap the flag
exists to support — would otherwise truncate and read each other's output,
which is the same defect one layer above where it was fixed. Both checklists
gained the corresponding items, since a half-fix that sets only the cache
reads as complete. `GOCACHE` was measured and does not need isolating.
Verified with the snippet extracted from the committed document and executed
as a consuming repo would adopt it, against paired controls: contamination
reproduced on the pre-fix form and absent on the adopted one, retry engaged,
exhaustion loud, a genuine finding still reported, and a held host lock
failing the pre-fix script while leaving the adopted one untouched.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
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
+27 -35
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)
@@ -111,26 +105,19 @@ last_modified: 2026-08-10
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`: `golangci-lint`. Run it with `make lint`, never by invoking the binary: the
the linter runs in a container, always, and is never installed on the host. linter runs as a phase of the `Dockerfile` and is not installed on the host
A `golangci-lint` invoked directly on a shared host reads a result cache by any repo. Invoked directly on a shared host it reads a result cache keyed
keyed on file content rather than location and a host-global lock, so its on file content rather than location, and a host-global lock, so its answer
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. It runs 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
the non-lint checks; linting lives in `Dockerfile.lint` and is run by linting. It carries the lint and test phases, and the final stage depends on
`script/cibuild` before the main build, because `script/lint` is itself a both, so a build makes sure the code is in an able-to-be-compiled state,
`docker build` and cannot run inside one. So `script/cibuild` is what linted, and its tests run. Go through `script/cibuild` or `script/docker`
guarantees the code is in an able-to-be-compiled state, linted, and tested — rather than a bare `docker build .`: they pass `--no-cache`, without which
**a successful `docker build .` on its own does not, because it never an unchanged tree serves the gate layers from cache and the build reports a
lints.** That guarantee holds only because each build passes a green it never ran.
per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
the Docker cache; without it an unchanged tree serves those layers from
cache and the build reports a green it never ran. A bare `docker build .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard — always go
through `script/cibuild`, `script/docker` or `script/lint`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
@@ -151,10 +138,15 @@ last_modified: 2026-08-10
1. Keep the `main()` function as small as possible. 1. Keep the `main()` function as small as possible.
1. Keep the `main` package as small as possible. Move as much code as is 1. Keep the `main` package as small as possible. Each `cmd/<name>/` directory
feasible to a library package, even if it's an internal one. `main` is just contains a single `main.go` whose body is one call into library code (for
an entrypoint to your code, not a place for implementations. Exception: example `os.Exit(cli.Main())` calling `internal/cli`). All CLI logic — flag
single-file scripts. parsing, subcommand dispatch, argument handling, output formatting — lives
in `internal/` or `pkg/`, not in `cmd/`. `main` is just an entrypoint to
your code, not a place for implementations. Exception: single-file scripts.
1. No project logic outside `internal/` or `pkg/`. Anything in `cmd/` is a thin
entrypoint only.
1. HTTP HandleFuncs should be returned from methods or functions that need to 1. HTTP HandleFuncs should be returned from methods or functions that need to
handle HTTP requests. Don't use methods or your top level functions as handle HTTP requests. Don't use methods or your top level functions as
+62 -87
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,74 +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 the **non-lint** checks as build steps (`script/test`, a harmless file from each — fetch `.dockerignore` from
`script/fmt-check`), and every stage containing a check-running `RUN` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` - [ ] Nothing has been appended after the final stage, and the gate phases are
guard immediately below it — see the `CHECK_EPOCH` rule in reachable from it. A stage nothing depends on is built only when
`REPO_POLICIES.md`. Without them the check layer is served from cache on `--target` names it, so a lost `COPY --from=` edge leaves `docker build .`
an unchanged tree and the build reports a green it never ran. passing while the gate never runs. Confirm by planting a violation, not by
- [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint` reading the file.
stage or a `COPY --from=lint ... /dev/null` ordering line. This is the - [ ] The gate phases invoke their tools directly, never through `make lint` or
item an existing repo most often fails: `script/lint` is now a `script/test` — those are themselves a `docker build` and would recurse
`docker build`, so both of those nest a docker build inside a build step. inside a build step
Delete the stage; `script/cibuild` running `script/lint` first is what - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix,
replaces its fail-fast purpose. only genuinely root-anchored entries such as `.git` are unprefixed, and
- [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the `.gitignore`'s patterns have not been transplanted unmodified — the
containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its transplanted form leaves `config/.env` and `certs/server.key` in the build
base image is pinned by sha256 with a version/date comment, it carries context while reading as solved
`ARG CHECK_EPOCH` **after** the dependency layer with the guard below it,
and it invokes the linter directly rather than through `make lint`. The
arg is named `CHECK_EPOCH` in this file too — a repo that calls it
`LINT_EPOCH` here is missed by the grep that checks every build is
cache-busted.
- [ ] The formatting check runs in exactly one of the two images — either
`script/fmt-check` in the `Dockerfile` or the formatter beside the linters
in `Dockerfile.lint`, whichever puts it on the pinned toolchain. Neither
image running it is the failure to look for here, since moving lint out of
the `Dockerfile` is exactly when it gets dropped.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled - [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored — binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An `/myapp`, never `**/myapp`. 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 — and `Dockerfile.lint` then lints that checkout here run anywhere other than the repo root, the anchored entry misses
as though it were this one, because toolchains discover files by walking `services/api/.claude/`: add anchored entries for those directories.
the tree and never read `.gitignore` (`sneak/quak`: 210 discovered tests
became 1050). Confirm by enumerating the image, not by reading the file —
`.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with
a per-service agent — it still reaches the build context and the image. An
existing repo is where such a layout already exists, so check it here
rather than assuming the canonical entry covers you: add anchored entries
for the subdirectories that have one (`/services/api/.claude`), or
`**/.claude` once you have confirmed no legitimately named nested
directory would be caught.
- [ ] If the repo embeds a version in a binary, that version is computed on the - [ ] 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`
@@ -122,26 +89,33 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` is the canonical container build and nothing else. No host - [ ] `script/lint` and `script/test` build their phase by name
linter invocation survives anywhere in the repo — grep for the linter's (`docker build --no-cache --target <phase> -t <name>-<phase> .`), and no
own name in `script/`, the `Makefile` and CI config, not just in host invocation anywhere in the repo can produce a lint verdict — grep for
`script/lint`. An existing repo is where a second path to the linter is the linter's own name across `script/`, the `Makefile` and CI config, not
likeliest to exist: a `make lint-fast`, a container-versus-host branch, or just `script/lint`. A second path is likeliest here: a `make lint-fast`,
a CI step that calls the binary directly. an older host-versus-container branch, or a CI step calling the binary
- [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install directly. `script/fmt` and `script/fmt-check` are expected hits and stay
block, its version and ref variables, and its call site: nothing invokes a on the host.
host linter any more, so all it can still do is put a differently - [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
versioned binary where somebody runs it by hand and believes the result. dangling image behind on every run, on every host and CI runner
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR` - [ ] `script/cibuild` runs `script/bootstrap` before `script/check`, and builds
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from the image with `--no-cache`. Without the bootstrap the CI run dies in
`.gitignore` and `.dockerignore`. A container has its own cache and its `script/fmt-check`, which runs the formatter on the host and finds nothing
own lock, so keeping the wrapper leaves two contradictory `script/lint` installed.
forms in the fleet. - [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version
- [ ] `script/cibuild` runs `script/lint` before the main `docker build`. before invoking `yarn`, as `script/bootstrap`'s own install step does.
Without that line CI never lints at all, because the main image `script/bootstrap` leaves the node and yarn it installs off the `PATH` of
deliberately does not. the shell that called it, so a bare `yarn` exits 127 on a runner carrying
nothing but docker and git.
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its
version variables and its call site. A JS repo's `yarn install` stays; it
brings a linter along with every other dependency, and no verdict is taken
from it.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 30-second timeout - [ ] `make test` has a 90-second timeout and completes within the 60-second
hard cap (over 20 seconds is green but must be filed as an improvement
bug)
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile - [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
check) check)
- [ ] `make check` passes on current branch - [ ] `make check` passes on current branch
@@ -186,9 +160,10 @@ 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 in a fresh clone on a host carrying nothing but
both times, never `CACHED` and never sub-second docker and git, with no node or yarn on `PATH`, which is what CI has, and
- [ ] `script/cibuild` succeeds and runs both container builds (a bare demonstrably executed the checks — a sub-second build, or `CACHED` on a
`docker build .` or `docker build -f Dockerfile.lint .` fails closed by gate layer, means nothing ran
design, on the `CHECK_EPOCH` guard) - [ ] A planted lint violation fails both `make lint` and a plain
`docker build .`; revert it afterwards
- [ ] 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)
+48 -84
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,47 +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.
- The `Dockerfile` runs the **non-lint** checks as build steps — - The Dockerfile carries a `lint` phase and a `test` phase, each invoking
`script/test` and `script/fmt-check`, never `make check`. `script/lint` is its tool directly rather than through `make` or `script/`, and the final
a `docker build` of `Dockerfile.lint`, so `make check` here nests a build stage carries a `COPY --from=` of a harmless file from each so the image
inside a build step, where there is no daemon. Put a comment above those cannot be built unless both passed. Keep the final stage last: a stage
`RUN` lines saying so. Every stage containing a check-running `RUN` must nothing depends on is built only when `--target` names it.
declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` - Server: the final stage builds and runs the application
guard immediately below it — see the `CHECK_EPOCH` rule in - Non-server: the final stage brings up the dev environment
`REPO_POLICIES.md`. Without them the check layer is served from cache on
an unchanged tree and the build reports a green it never ran.
- Server: also builds and runs the application
- Non-server: brings up dev environment and runs those checks
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image that `script/lint` builds. Same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` placed **after** the dependency layer so only the lint steps re-run.
Base image pinned by sha256 with a version/date comment. Go repos use
`golangci/golangci-lint` and run both `golangci-lint config verify` and
`golangci-lint run`; other repos use the same pattern around their own
linter (eslint, ruff, prettier). Copy the canonical file from
`REPO_POLICIES.md`. The linter is invoked directly there, never via
`make lint`, which would recurse. The arg keeps the name `CHECK_EPOCH` in
this file as well, so one grep covers both builds.
- The formatting check runs in exactly one of the two images: either
`script/fmt-check` in the `Dockerfile`, or the formatter beside the
linters in `Dockerfile.lint` where that is the same pinned dependency.
Never neither, never both.
- `.dockerignore` must exclude the agent scratch directory before this image
is trusted: it lints whatever is in the build context, and toolchains walk
the tree rather than reading `.gitignore`, so an agent worktree that
reaches the context is linted as though it were the repo.
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs - [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference `script/cibuild` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -127,51 +104,34 @@ 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 (30-second - [ ] `script/test` / `make test``docker build --no-cache --target test .`,
timeout) tagged; the phase runs real tests, not a no-op (90-second timeout,
- [ ] `script/lint` / `make lint` — builds `Dockerfile.lint` and nothing else: 60-second hard cap on wall time)
`epoch="$(date +%s%N)$$"` on its own line, then - [ ] `script/lint` / `make lint``docker build --no-cache --target lint .`,
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The tagged. No lint verdict may come from a host invocation of the linter.
linter is never installed on the host and never invoked there. Copy the - [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a
canonical script from `REPO_POLICIES.md`; it is byte-identical across container)
repos. Without the nonce this script exits 0 on an unchanged tree having - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
linted nothing. native)
- [ ] `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 - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files. It needs a docker daemon, because `script/lint` is a modify files
container build, and it must never be called from inside a build stage
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); carries the same three `script/projectname` (byte-identical across repos); `--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/bootstrap`, run
- [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for `script/check`, then
fail-fast feedback, then, each on its own line: `docker build --no-cache --build-arg VERSION="$version" .` (what CI runs).
The bootstrap is required: CI checks out and runs this alone, and
```sh `script/fmt-check` runs the formatter on the host.
epoch="$(date +%s%N)$$" - [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version
version="$(git describe --tags --always --dirty 2>/dev/null || true)" before invoking `yarn`, as `script/bootstrap`'s own install step does.
[ -n "$version" ] || version="unknown" `script/bootstrap` leaves the node and yarn it installs off the `PATH` of
docker build \ the shell that called it, so a bare `yarn` exits 127 on a runner carrying
--build-arg CHECK_EPOCH="$epoch" \ nothing but docker and git.
--build-arg VERSION="$version" \ - [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a
. dangling image behind
```
(what CI runs). The `script/lint` call is not optional: the main image does
not lint, so without it CI never lints. Both build args are mandatory, and
both assignments must be on their own line: a failing command substitution
inside an argument does not trip `set -e`, so the inline form degrades
silently to an empty constant. The `[ -n "$version" ]` line is a live check
that fires on an export with no `.git` and on a repo with no commits — keep
it, and do not collapse it into `|| echo unknown`, which makes it
unreachable. See the `CHECK_EPOCH` and git-describe rules in
`REPO_POLICIES.md` for why each element is load-bearing. A bare
`docker build .` fails closed by design, and so does a bare
`docker build -f Dockerfile.lint .`.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit` `script/precommit`
@@ -182,12 +142,16 @@ are thin shims calling them. Model scripts:
# 4. Verify # 4. Verify
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` demonstrably runs the linter rather than returning a cached
build: run it twice on an unchanged tree and confirm the lint layer says
`DONE`, never `CACHED`, both times
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds and runs both container builds - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but
- [ ] No secrets in repo docker and git, with no node or yarn on `PATH`, which is what CI has, and
demonstrably executed the checks — a 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
+248 -601
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-10 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,22 +60,28 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/lint` first for fail-fast feedback (that is itself a repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
container build — see the containerised-lint rule below), and then runs with the version; the Gitea workflow calls it. **`script/cibuild` runs
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, `script/bootstrap` first**, because the workflow checks out the repo and runs
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and nothing else, while `script/fmt-check` runs the formatter on the host: on a
`version` is computed on the host because `.git` is not in the build context pristine checkout with nothing installed the run dies there, after the
(see the git-describe rule below); the Gitea workflow calls it. Four further containerised gates have passed. **The bootstrap alone is not enough**:
scripts are our own extensions to the standard: `script/check` runs `script/bootstrap` installs node and yarn under nvm and leaves neither on the
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
what the git pre-commit hook runs, and it calls `script/check`; entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
`script/install-precommit` installs the git pre-commit hook (the `make hooks` source nvm for the pinned node version before invoking it, exactly as
target shims to it); and `script/projectname` (literally that filename) simply `script/bootstrap`'s own install step does. A runner carrying nothing but
outputs the project's name. Scripts that need the name call docker and git then gets through `script/check`. Four further scripts are our
`script/projectname` — e.g. `script/docker` assembles its image tag from it — own extensions to the standard: `script/check` runs `script/test`,
so those scripts stay byte-identical across all repos. Repo-type-specific `script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
`script/precommit`, not in the hook itself. Model scripts are at installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -94,372 +100,140 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. It must run the repo's checks as build - Every repo should have a `Dockerfile`, and it carries the repo's gates: a
steps so the build fails if the branch is not green — which requires `lint` phase and a `test` phase, with the final stage depending on both so the
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running image cannot be built unless they pass. For non-server repos the final stage
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies brings up a development environment; for server repos it is the runtime image.
this criterion while its check layers are served from cache, so the build Dockerfiles install development prerequisites by running `script/bootstrap`
cannot fail on a branch that is not green. rather than duplicating installs inline; COPY `script/` and the dependency
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
running it.
**It runs the individual non-lint checks — `script/test` and - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
`script/fmt-check` and never `make check`.** `script/lint` is itself a no separate lint file. `script/lint` and `script/test` each build one phase
`docker build` (of `Dockerfile.lint`, per the containerised-lint rule and nothing else:
below), so a `RUN make check` in this file attempts a docker build inside a
build step, where there is no daemon. Lint is not skipped by this: it runs
in its own container, and `script/cibuild` runs it first. Put a comment to
that effect directly above those `RUN` lines, because `make check` is what
the next person will reach for. Of the two, only `script/test` is fixed
here: a repo may run its formatter in `Dockerfile.lint` beside the linters
instead, and some should — see the containerised-lint rule below. It must
then run in that file and not in this one, and never in neither.
For non-server repos, the Dockerfile should bring up a development
environment and run those checks. For server repos, they should run as an
early build stage before the final image is assembled. Dockerfiles install
development prerequisites by running `script/bootstrap` rather than
duplicating installs inline; COPY `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it
so the bootstrap layer stays cached until dependencies change.
- **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the check layer is served from cache, the suite never runs, and
the build still exits 0. A sub-second `docker build` reporting success is a
cache hit, not a result. This applies to **every** file that runs checks in a
build step, which since linting moved into its own container means
`Dockerfile` and `Dockerfile.lint` both — a `Dockerfile.lint` without the
cache-bust is a lint that never ran, reported as a pass. The canonical form,
in **every** stage containing a check-running `RUN`, placed **after** the
dependency-install layer so that layer stays cached:
```dockerfile
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command>
```
and in `script/lint`, `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" \
.
``` ```
The `VERSION` lines are there for a different reason, covered by the **A stage that is not the last one in the file is built only when the final
git-describe rule below; they are shown here so the two rules do not each stage's chain depends on it, or when `--target` names it.** That is why the
document half a command. `script/lint` passes only `CHECK_EPOCH`, since no two gates are always invoked by name here, and why the final stage carries a
version is embedded in a lint image. All four `CHECK_EPOCH` elements are `COPY --from=` of a harmless file from each of them: without that edge a
load-bearing; none is optional, and each guards a failure mode that plain `docker build .` builds the last stage alone and exits 0 having linted
otherwise fails green: and tested nothing.
- `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 **Every `docker build` in `script/` is tagged**, here and in
`go mod download`, `script/bootstrap`, and the pinned toolchain install `script/cibuild` and `script/docker`. An untagged build leaves a dangling
cached, so it does not push against the five-minute Docker build ceiling. image behind on every invocation, on every developer host and every CI
Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or runner; a tagged one replaces the previous image.
on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every
invocation, which makes linting network-dependent and pushes a lint that
should take seconds toward the build ceiling. Never reach for
`docker builder prune` to achieve the same end — the build cache is shared
with every other build on the host, including other people's.
- **Every lint run happens in a container, and `script/lint` is that container Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
build.** The linter is never installed on the host and never invoked there. `eslint`, `prettier` — never through `make lint` or `script/test`, which are
Every repo carries a `Dockerfile.lint` next to its `Dockerfile`; the linter themselves a `docker build` and would recurse into a daemon that does not
runs as a **build step**, so a successful build _is_ a clean lint. Building exist in a build step. Formatting is the exception and stays on the host:
rather than bind-mounting is deliberate: it is what makes the pattern work `script/fmt` writes the working tree, and `script/fmt-check` is its
unchanged where the docker daemon is remote and bind mounts are impossible. read-only twin.
Docker is assumed available in every environment. Discarding the linter's
cache on every run is the point of this rule, not a cost it pays.
This closes a family of defects, every one of them an artefact of running **No lint verdict may come from a host invocation of the linter.** On a
the linter on a shared host, and every one of them observed rather than shared host golangci-lint reads a result cache keyed on file content rather
hypothesised: than location, so a second checkout of the same content is served the first
- **A confirmed false green.** An implementer reported `0 issues` on a one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
branch that was genuinely red with a `goconst` finding. golangci-lint keys exit non-zero with `parallel golangci-lint is running` — a status a caller
cached results on file **content, not location**, so a second checkout of cannot tell from real findings. Both have produced wrong verdicts in this
the same commit holds byte-identical files and serves its result. Note org, in both directions. A container has its own cache, its own `TMPDIR` and
what content-keying implies: moving agents from worktrees into their own a digest-pinned binary, so neither is reachable.
clones does **not** help, because two clones are byte-identical exactly as
two worktrees were. It removes the foreign-path symptom and leaves the
mechanism live, which makes the defect quieter rather than rarer.
- **False reds**, repeatedly: findings reported against `../wt82-lint/...`,
against another agent's checkout, and against a worktree that had already
been deleted; in one case 399 issues returned to a clean clone that
genuinely lints 0.
- **Lock contention that cannot be distinguished from findings.**
golangci-lint flocks `$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`,
`acquireFileLock()`) — host-global, keyed on the temp directory, entirely
independent of `GOLANGCI_LINT_CACHE`, with a 5-second acquire timeout, so
it fails precisely when the host is busiest. On failure it prints
`parallel golangci-lint is running`, analyzes nothing, and exits non-zero.
**Proven not fixed by per-cache isolation**: two concurrent runs with
entirely separate cache directories still collided.
- **Version skew.** A host linter differing from the pinned one, with the
container surfacing thirteen findings the host missed on one repo, and a
local `make check` green against a `make docker` that rejected the same
commit with six `goconst` findings.
A container per run has its own cache, its own `TMPDIR` and therefore its - **Any build that runs checks is built with `--no-cache`.** Docker invalidates
own lock, and a binary pinned by digest, so none of the above is reachable. a `COPY` layer only when the copied content changes, so on an unchanged tree
That is also why the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper the check `RUN` is served from cache, nothing executes, and the build still
that used to be canonical here is **gone rather than kept alongside this**: exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
its entire subject was making a host run trustworthy, and there are no host `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
runs. Consuming repos delete it when they adopt this; see the adoption list four, and there is no fifth — `script/check` runs the two gate phases and
at the end of this rule. `script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
The canonical `Dockerfile.lint` for a Go repo: - **The gate phases are separate stages, and the build stage depends on both.**
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,
and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`:
```dockerfile ```dockerfile
# Lint-only image. `script/lint` builds this file and nothing else: the # Lint phase
# linter runs as a build step, so a successful build IS a clean lint. # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
# FROM golangci/golangci-lint@sha256:... AS lint
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src WORKDIR /src
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
golangci-lint config verify --config .golangci.yml
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
```
and the canonical `script/lint`, identical in every repo: # 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; }
```sh # Build stage. Nothing is wanted from either phase above; the copies
#!/bin/sh # are what make BuildKit build them first, so this stage cannot run
# script/lint: run the linter. The linter is never installed on the host # unless lint and test passed.
# and never invoked there — it runs in a container, one way, everywhere,
# so a run cannot inherit another checkout's cache, another process's
# lock, or a host toolchain that differs from the pinned one.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, and `$$` is required because busybox `date`
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
}
main "$@"
```
Load-bearing properties:
- **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .`
on an unchanged tree returns a sub-second cached success having linted
nothing — the same false green the `CHECK_EPOCH` rule above exists to
close, arriving through a new file. The `ARG` goes **after** the
dependency layer so `go mod download` / `yarn install` stay cached and
only the lint steps re-run. Blanket `--no-cache` also busts the dependency
layer, which makes every lint network-dependent.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
not every Go lint run. Only the base image and the lint commands change;
the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
node image, runs `script/bootstrap` as the dependency layer, and lints
with the linter from `node_modules`, which is also how it gets the version
pinned in `package.json` rather than whatever is on the host.
- **The lint container lints whatever is in the build context, so
`.dockerignore` is part of this rule and not merely hygiene.** `COPY . .`
copies an agent scratch worktree — an entire second checkout of the repo —
into the lint image unless `.dockerignore` excludes it, and language
toolchains discover files by walking the tree rather than by reading
`.gitignore`, so `./...`, `eslint .` and `prettier --check .` all descend
into it. `sneak/quak` measured this on the same discovery mechanism in its
test runner: a nested `.claude/` worktree took the discovered test count
from 210 to 1050 (https://git.eeqj.de/sneak/quak/issues/30). Left in the
context it re-creates _inside_ the container the foreign-tree false reds
that moving lint into a container was adopted to end, and it does so in
the convincing form — the findings are real, they simply belong to another
checkout. See the `.dockerignore` rules below, and verify by enumerating
the image rather than by reading the patterns.
- **The build arg is named `CHECK_EPOCH` in `Dockerfile.lint` too**, not
`LINT_EPOCH` or any other per-file name, and `script/lint` passes it under
that name. Both files guard the same failure under the same contract, and
the single name is what lets a reviewer grep a repo for `CHECK_EPOCH` and
see every cache-bust it has. Rename it in one file and that grep silently
misses it, so a renamed guard and an absent guard read identically without
opening both Dockerfiles.
- **The formatting check runs in exactly one of the two images, and either
one is allowed.** The canonical `Dockerfile` above runs `script/fmt-check`
because that is where the non-lint checks live. A repo may instead run its
formatter in `Dockerfile.lint` beside the linters, which is the better
shape wherever the formatter is the same pinned dependency as the linter
(`prettier` out of `node_modules`, say), because it takes the last host
toolchain off the checked path for the same reason the linter came off it.
What is not allowed is running it in neither image, or in both. Whichever
image runs it carries the epoch guard, and `script/check` still runs all
three targets on the developer's side either way.
- **Keep `golangci-lint config verify`, and it costs no network.** The two
commands catch **disjoint** classes of defect, measured under the pinned
v2.12.2 against a config carrying one planted defect at a time: a bogus
top-level key and a bogus key nested under `linters.settings.lll` both
pass `golangci-lint run` with **exit 0 and `0 issues`** while
`config verify` exits 3 and names the key; an invalid value type fails
both; an unknown linter name fails `run` and passes `config verify`. So
`run` alone silently ignores an unknown key, which is exactly the mode
where a threshold reads as configured and is not applied. The earlier
caution that `config verify` resolves its JSON schema over a live HTTPS
fetch does **not** hold for this pinned version: every case above was
re-run under `docker run --network none` and produced byte-identical
diagnostics and exit statuses, in a container where
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin rather than
treating the result as permanent.
- **No repo installs a linter on the host, in `script/bootstrap` or anywhere
else.** A host install is now dead weight whose only remaining effect is
to reintroduce the version skew above.
- **`script/check` still runs `test`, `lint` and `fmt-check`**, so a
developer and the pre-commit hook get all three. It therefore requires a
docker daemon, and it must never be invoked from inside a build stage —
see the `Dockerfile` rule above.
- If the project uses `//go:embed` directives referencing build artifacts
(e.g. a web frontend compiled elsewhere), `Dockerfile.lint` must create
placeholder files so the directives resolve:
`RUN mkdir -p web/dist && touch web/dist/index.html`. It must not depend
on the real build output; it exists to fail fast.
- If linting requires CGO or system libraries (e.g. `vips-dev`), install
them in `Dockerfile.lint`.
**What a consuming repo does to adopt this**, in order: add
`Dockerfile.lint`; replace `script/lint` with the build above; delete the
`lint` stage from its `Dockerfile` along with the
`COPY --from=lint ... /dev/null` ordering line; change that `Dockerfile`'s
`RUN make check` to `script/test` and `script/fmt-check` with the comment
explaining why; add `script/lint` as the first step of `script/cibuild`;
delete any golangci-lint install from `script/bootstrap`; and delete the
`.lint-cache/` entries from `.gitignore` and `.dockerignore` together with
the per-checkout cache/lock wrapper they served.
**The separate lint _stage_ is superseded by this and must not survive
alongside it.** It ran `make lint`, which is now a docker build, so keeping
it is not a stylistic preference but a recursion. Its purpose — fail-fast
feedback before the slow build — is served by `script/cibuild` running
`script/lint` first, and its `COPY --from=lint /src/go.sum /dev/null`
ordering trick, along with the warm-cache re-proof that trick required, is
no longer needed because the ordering is now sequential in the shell.
- **The canonical Go repo `Dockerfile`**, which builds and tests but does not
lint:
```dockerfile
# Build stage
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src 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
# The individual non-lint checks, NOT `make check`: script/lint is a
# docker build (Dockerfile.lint), so `make check` here would nest a
# build inside a build step, where there is no daemon. Lint is not
# skipped — script/cibuild runs it first, in its own container.
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make test
# VERSION comes from the host via --build-arg; see the git-describe rule
# below. Never run `git describe` here: .dockerignore excludes .git, so
# it yields an empty version without failing the build.
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-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:
- Tests run in the build stage because they may require compiled artifacts - The lint phase uses the `golangci/golangci-lint` image directly (it has
or heavier dependencies. both Go and the linter), so nothing needs installing.
- `ARG CHECK_EPOCH` must be declared in **every** stage containing a - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
check-running `RUN`, because `ARG` is stage-scoped: declaring it in one purpose is the ordering edge. BuildKit runs stages in parallel by default,
stage leaves the others frozen at their last cached result while the fix and a stage nothing depends on is not built at all, so without these two
reviews as complete. In each such stage the guard sits immediately below lines a red gate would not fail the build.
the `ARG`, and the value is expanded into the first check `RUN` so the - Keep the runtime stage last, and if you add a stage after it, give it the
cache miss does not rely on BuildKit's unreferenced-`ARG` handling. Both same two copies. A plain `docker build .` builds the last stage's chain
lines reference `$CHECK_EPOCH`, so each stage has two independent and nothing else.
invalidation points. Later `RUN`s in the same stage need no expansion of - If the project uses `//go:embed` directives that reference build artifacts
their own: their parent layer is already busted. (e.g. a web frontend compiled in a separate stage), the lint phase must
- `ARG VERSION=dev` is declared in the build stage, and its value is create placeholder files so the embed directives resolve. Example:
supplied on the host by `script/docker` and `script/cibuild` via `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
`--build-arg VERSION=...`. The `dev` default is a placeholder for a local - If the project requires CGO or system libraries for linting (e.g.
build, not a source of truth. **No stage may call `git describe`**: `vips-dev`), install them in the lint phase with `apk add`.
`.dockerignore` excludes `.git`, so it yields an empty version without - `ARG VERSION=dev` is declared in the stage that compiles and supplied by
failing. See the git-describe rule further down. `script/docker` and `script/cibuild`; no stage may call `git describe`.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push. `script/cibuild` runs **two** container builds: runs `script/cibuild` on push, and checks out the repo as its only other step.
`script/lint` (`Dockerfile.lint`) first, then That script bootstraps, runs the gate phases, and then builds the image, so a
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .` successful run means every check passed; a bare `docker build .` does not
for the main image, which runs the non-lint checks. A successful carry the same guarantee, because its gate phases may come from the cache. The
`script/cibuild` therefore implies all checks pass; **a successful image build is uncached and so runs the gate phases a second time. That is the
`docker build .` on its own does not, because it never lints.** That is the price of the rule above, and it is worth paying: the image that ships is built
one claim to be careful with when reading these files: the guarantee belongs from a run of its own gates rather than from a cache entry.
to `script/cibuild`, not to any single Dockerfile. Both halves of it hold only
because each build passes its own `CHECK_EPOCH` nonce — without it an
unchanged tree serves the layers from cache and the build reports a green it
never earned. A bare `docker build .` or `docker build -f Dockerfile.lint .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go
through `script/cibuild`, `script/docker` or `script/lint`. Never accept a
pass as evidence without confirming it ran: a sub-second wall time, or
`CACHED` on a check or lint layer, means nothing was executed.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -479,14 +253,21 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op. `make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 60 seconds. That is the hard cap, and a
Makefile. 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
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
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:
@@ -499,11 +280,24 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun
reproduces a failure instead of replaying it. It leaves the build cache
alone, so it costs the runtime of the suite and no recompilation.
Note that this is a second, independent cache, stacked below the Docker
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
it does not guarantee `go test` inside that step does any work, because the
`GOCACHE` baked into earlier image layers survives into the re-executed
step. They are two separate defects requiring two separate fixes, and a fix
for one must not be recorded as covering the other.
Python example: Python example:
```makefile ```makefile
@@ -529,148 +323,83 @@ 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. **And because prefix, because the prefixed form would also delete any nested directory of
`Dockerfile.lint` and `Dockerfile` run their tooling over the copied context, that name from the build. Anchoring carries a known gap that the canonical
a worktree that reaches it is linted and tested as though it were the repo.** `.dockerignore` states in its own comment, since consuming repos receive the
Nothing else stops that: language toolchains discover files by walking the file and not the tracker: the directory is created in the agent's working
tree and do not read `.gitignore`, which is how `sneak/quak` saw a nested directory, so a repo running agents in subdirectories still ships
`.claude/` worktree take its discovered test count from 210 to 1050 `services/api/.claude/` and must add its own anchored entry there.
(https://git.eeqj.de/sneak/quak/issues/30). This entry is therefore a
correctness precondition of the containerised-lint rule above and not a size
optimisation — without it the foreign-tree false reds that rule exists to end
simply move inside the container. In `.gitignore` the entry is `.claude/`,
unanchored, which already matches at every depth. In `.dockerignore` it is
`.claude`, anchored and with **no** `**/` prefix: the directory occurs exactly
once **where agents run at the repo root**, and the prefixed form would also
match any nested directory of that name and delete it from the build. It is
not case-folded the way the secret patterns are, because tooling creates it in
exactly one spelling, so a folded pattern would add no coverage.
**Known gap that comes with the anchored form.** The directory is created in - **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 \
form. The matcher supports character ranges, so one line covers every --build-arg VERSION="$version" \
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret -t "$(script/projectname)" .
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
@@ -686,129 +415,45 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized. The vendored copy in a consuming repo must
manually by the user. Fetch from _NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
canonical golangci-lint version is v2.12.2 (released 2026-05-06), pinned as byte-identical, so that no repo can quietly loosen its own linting. Linter
the image digest in `Dockerfile.lint` configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`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
re-vendor carries its entries forward. The canonical golangci-lint version is
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 there is: the linter is not installed on the host, in version means changing it and nothing else.
`script/bootstrap` or anywhere else. Bumping the version means changing that
one digest, and it propagates to every consumer of the image with no host
state able to disagree with it.
- **`script/bootstrap` must not install a linter at all.** This supersedes the - **`script/bootstrap` installs a pinned tool by comparing versions, never by
pinned-golangci-lint install that used to be canonical here. Nothing runs a testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
linter on the host any more — `script/lint` is a container build — so a host `PATH` only, so on an already-provisioned machine the pin is inert and a
install has no caller left, and its only remaining effect is to put a second, version bump is a silent no-op — while the Dockerfile, installing into a clean
independently-versioned linter on the machine where somebody will eventually image, gets the pinned version, so a local `make check` and `make docker` can
run it by hand and believe the result. The version-skew failures that install disagree about what the tool even is. The canonical form:
was written to close (a local `make check` green while `make docker` rejected - compares the installed version against the pin over the **whole** version
the same commit with six `goconst` findings; a container linter surfacing token; a parser that stops at the first `-` reports `2.12.2` for a host
thirteen findings the host run missed) are closed more completely by having running `2.12.2-rc1` and skips the install;
exactly one linter, pinned by image digest, that no host state can shadow. - treats absent, non-zero, empty or unrecognised `--version` output as a
Repos adopting the containerised lint delete the install block, its version mismatch, so the failure direction is a redundant install and never a
and ref variables, and its call site from `script/bootstrap`. 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.
**The version-enforcement principle it established still applies to any Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
other tool a repo pins and installs on the host**, and it is the part worth
keeping, because each of its four properties guards a failure that otherwise
reports success:
- **Compare the installed version against the pin, never test presence.** A
`if missing <tool>; then install; fi` guard tests `PATH` presence and
never version, so on any already-provisioned machine the pin is inert and
a version bump is a silent no-op. Compare the **whole** version token,
exactly: a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install — the original defect,
reintroduced through the comparison meant to fix it.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the directory the installer wrote to — and assert the
reported version is the pin. An installer that writes to `GOBIN` while a
different binary shadows it earlier in `PATH` genuinely succeeds and
changes nothing any caller sees, which is worse than no fix: it converts a
known-stale tool into one everyone believes is pinned. Run `hash -r` first
so the shell does not answer from its own lookup cache, and when the
assertion fails, name the path `command -v` found, the version it reports,
and the directory the install wrote to. Diagnose from the resolved path
rather than asserting a cause: only a path **outside** the install
directory is shadowing.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output and unrecognised output should
all yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** A function defined and never called is
a silent no-op indistinguishable from success: exit 0, nothing installed,
no output. Both success branches must print a line naming the version.
Verifying such logic requires a negative control in an environment where a
shadowing binary exists earlier in `PATH` than the install target — without
it the control passes against the naive compare-then-install form too and
proves nothing — plus a mis-parse control that feeds unparseable `--version`
output and confirms a reinstall. Run those controls against the block as a
consuming repo would adopt it: pasted into a `script/bootstrap`-shaped file
that is then executed, never by sourcing it and invoking the function
yourself. Driving the function directly tests something the artifact does
not do, and it is exactly how a missing call site passes every control while
the adopted snippet does nothing.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
- **SUPERSEDED, and deleted rather than kept: the per-checkout
`GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** Every line of it
was about making a linter run on a shared host trustworthy — a private result
cache so a byte-identical checkout could not serve its findings, a private
`TMPDIR` so the host-global lock could not collide, retry and VOID handling so
a lock collision was never reported as findings. The containerised-lint rule
above removes the host run itself, so there is nothing left for that wrapper
to isolate, and a repo carrying both would carry two contradictory canonical
`script/lint` forms. Its findings are not lost: they are the evidence for
containerising, and they are recorded in that rule. Repos that adopted it
delete the wrapper, the `.lint-cache/` entries from `.gitignore` and
`.dockerignore`, and the `--allow-serial-runners` flag with them.
Two of its conclusions are kept because they outlive it. **`GOCACHE` does
not need isolating**, measured rather than assumed: it is content-addressed,
its entries are compiled artifacts rather than diagnostics carrying a
foreign tree's paths, and it has no equivalent global lock — the whole fleet
compiles concurrently against one `GOCACHE` all day without a contention
error. And **verifying any change to lint plumbing requires paired
controls**: a control that passes against the broken form proves nothing,
and it must be run against the artifact as a consuming repo would adopt it —
the file executed, not the functions sourced and driven by hand.
- **Interim rule for reading a lint result produced on the host, in a repo that
has not yet adopted the containerised lint above.** A lint run is **VOID**
unless both hold:
- the output contains no `parallel golangci-lint is running`, and
- no reported file path begins with `../`, and none is an absolute path
outside the tree the run was launched from.
Do not record a verdict from a void run, and do not "fix" findings in files
the change does not touch — chasing phantom findings across untouched files
puts unrelated edits into a reviewed diff, which is more expensive than the
wasted rework.
The `../` clause is the one that actually bites, and it is why a filter
keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports
paths relative to its own resolved root rather than yours, and three of the
org's reported sightings had relative paths and would have passed such a
filter. Both clauses are needed and neither alone is sufficient — one
reproduction exited non-zero with the lock error and no foreign paths at
all, and another reported 34 well-formed findings, every one of them against
another checkout.
**State the limit of these tests rather than treating them as a guarantee.**
They catch contamination that **names** foreign files. They cannot catch
contamination that **suppresses** findings through a poisoned entry for
colliding content, which has no wall-clock tell either — **no evidence of
that mode has been observed, and nobody should go chasing it**; the point is
the reach of the tests, not a claim that the mode exists. They are a filter
for the loud mode, not a proof of soundness — which is the whole argument
for containerising the linter instead of documenting a discipline that
depends on every agent remembering to apply it. Adopt the rule above and
this one stops applying to the repo entirely.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -927,7 +572,9 @@ style conventions are in separate documents:
language-specific config). Everything else goes in a subdirectory. Canonical language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples - `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform) - `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root) - `docs/` — documentation and markdown (README.md stays in root)
+3 -8
View File
@@ -1,13 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. test and lint are Docker
# # phases; fmt-check is native, because a formatter writes the working
# script/lint is a docker build (see Dockerfile.lint), so this script # tree. Must not modify any files.
# requires a docker daemon. That is deliberate: it is the only way a
# developer and the pre-commit hook get the same linter CI gets. It also
# means this script must never be run from inside a build stage — see
# the comment in Dockerfile, which runs the individual non-lint checks
# for exactly that reason.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+15 -26
View File
@@ -1,10 +1,10 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. Two container builds, in order: # script/cibuild: run the CI build. It bootstraps first: a CI runner
# script/lint (Dockerfile.lint) and then the main image, which runs the # checks out and runs this and nothing else, and script/fmt-check runs
# non-lint checks. Both only prove anything because each passes its own # the formatter on the host, which a pristine checkout cannot do.
# fresh CHECK_EPOCH nonce: without it Docker serves the check layers # --no-cache for the same reason as script/docker: the gate phases the
# from cache on an unchanged tree and the build exits 0 without running # final stage depends on are RUN steps, and a cached one is a check that
# anything. # did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -12,29 +12,18 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container build "$SCRIPT_DIR/bootstrap"
# and computes its own CHECK_EPOCH. It runs here rather than inside "$SCRIPT_DIR/check"
# the main image because a docker build cannot run a docker build. # Own line: a failing command substitution inside an argument does
"$SCRIPT_DIR/lint" # not trip `set -e`, so the inline form degrades silently to an
# Assign on its own line: a failing command substitution inside an # empty constant. VERSION is computed here because .dockerignore
# argument does not trip `set -e`, which would silently degrade the # excludes .git, so `git describe` in a build stage yields an empty
# nonce to an empty constant. `$$` is required because busybox `date` # version without failing.
# drops %N without erroring.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" 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")" .
} }
main "$@" main "$@"
+9 -19
View File
@@ -1,9 +1,8 @@
#!/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 the gate phases the final stage depends on are RUN
# nonce on every invocation; without it a warm cache turns this into a # steps, and a cached one is a check that did not run.
# green that proves nothing.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,23 +10,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an # Own line: a failing command substitution inside an argument does
# argument does not trip `set -e`, which would silently degrade the # not trip `set -e`, so the inline form degrades silently to an
# nonce to an empty constant. `$$` is required because busybox `date` # empty constant. VERSION is computed here because .dockerignore
# drops %N without erroring. # excludes .git, so `git describe` in a build stage yields an empty
epoch="$(date +%s%N)$$" # version without failing.
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build \ docker build --no-cache \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+14 -18
View File
@@ -1,27 +1,23 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. The linter is never installed on the host # script/lint: run the linter. Linting is a phase of the Dockerfile and
# and never invoked there — it runs in a container, one way, everywhere, # this builds that phase alone; the linter is never installed or run on
# so a run cannot inherit another checkout's cache, another process's # a developer host, where a shared result cache and a host-global lock
# lock, or a host toolchain that differs from the pinned one. Linting # make its answer untrustworthy.
# happens as a build step (see Dockerfile.lint), so a successful build #
# is a clean lint, and it works where the docker daemon is remote and # The phase is not the last stage in the file, so it is built only when
# bind mounts are impossible. # --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
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"
# Assign on its own line: a failing command substitution inside an docker build --no-cache \
# argument does not trip `set -e`, which would silently degrade the --target lint \
# nonce to an empty constant. `$$` is required because busybox `date` -t "$("$SCRIPT_DIR/projectname")-lint" .
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"
+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 "$@"