4 Commits

Author SHA1 Message Date
bb19029f53 Migrate canonical .golangci.yml to gomodguard_v2 with a block list
All checks were successful
check / check (push) Successful in 9s
golangci-lint v2.12.0 deprecated gomodguard, and `linters.default: all`
enables it, so every lint run in every consuming Go repo prints a
deprecation warning. Disable the old name, which is what silences the
warning; name gomodguard_v2 in linters.enable so the settings block has
a visible owner; give it a blocked module list.

The block list restates only decisions already recorded in the Go
package defaults: zerolog, the pre-fork go-redis/redis, sergi/go-diff
and hexops/gotextdiff. Recorded rejections that repos vendoring this
file still require are deliberately absent, so a re-vendor cannot
redden a repo that has done nothing wrong.
2026-08-31 01:54:40 +00:00
f77d785bed Scope the .golangci.yml agent prohibition to vendored copies (#49)
All checks were successful
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:

> `.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
3f8201d532 Require thin cmd/ entrypoints: all logic in internal/ or pkg/ (#54)
All checks were successful
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
a8686891ba Raise org-wide make test cap to 60s, backstop timeout to 90s (#42)
All checks were successful
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
11 changed files with 119 additions and 561 deletions

View File

@@ -1,63 +1,3 @@
# .dockerignore uses Go filepath.Match, NOT .gitignore semantics: `*`
# does not cross `/`, and a pattern without a leading `**/` is anchored
# at the build-context root. Every depth-independent pattern therefore
# needs the `**/` prefix — without it `config/.env` and
# `certs/server.key` still ship while the file reads as solved.
#
# Root-anchored entries are for paths that occur exactly once, at the
# context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`. The secret-material extensions below use character
# classes, which cover every capitalisation in one line — a doubled
# ALL-CAPS pattern would still miss `Server.Key` while reading as though
# case were handled. Names that only ever exist in one spelling because
# a tool writes them (`.env`, `.envrc`, `id_rsa`) stay literal.
#
# 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.
.git .git
node_modules
# Environment files. `*.env` covers the `prod.env` / `local.env` .DS_Store
# convention; the `.env` and `.env.*` spellings are listed explicitly
# because they are what most tooling writes.
**/.env
**/.env.*
**/*.[eE][nN][vV]
**/.envrc
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/id_rsa
**/id_dsa
**/id_ecdsa
**/id_ed25519
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state. Never a build input, and it churns under a developer's
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*

View File

@@ -10,14 +10,21 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
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 - 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
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -28,6 +35,27 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
# Only decisions already recorded in the Go package defaults are
# listed here. Entries match the module path exactly (the default
# match-type), so a differently-versioned module path is unaffected.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0

View File

@@ -12,17 +12,4 @@ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
RUN make check
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check

View File

@@ -123,13 +123,9 @@ alpine. We provide:
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension)
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as (byte-identical across repos)
`script/cibuild` - `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then image build runs `script/check`)
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what
stops Docker serving that check from cache on an unchanged tree — a bare
`docker build .` fails closed on purpose)
- `script/precommit` — run by the git pre-commit hook (our own extension); calls - `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own

37
TODO.md
View File

@@ -21,38 +21,11 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a - 2026-08-31: Migrated the canonical `.golangci.yml` from the deprecated
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build `gomodguard` to `gomodguard_v2`: the old linter is disabled by name (which is
context under `COPY . .`, invisible to every git-based check because what silences the deprecation warning), the successor is named explicitly in
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own `linters.enable`, and it carries a `blocked` module list drawn only from
`filepath.Match` semantics — `**/`-prefixed so they hold at every depth, which decisions already recorded in the Go package defaults.
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, with
capitalisation handled by character classes because matching is case-sensitive
and an ALL-CAPS twin per pattern still misses `Server.Key`. `REPO_POLICIES.md`
and both repo checklists now state that asymmetry and require verification by
enumerating the image rather than by reading the patterns. Verified with a
probe image before, against three naive forms (unprefixed, lowercase-only,
ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema - 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually

View File

@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-09 last_modified: 2026-03-18
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -101,16 +101,9 @@ last_modified: 2026-08-09
`golangci-lint`. `golangci-lint`.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and 1. Write a `Dockerfile` for every repo, even if it only runs the tests and
linting. `script/cibuild` and `script/docker` should always make sure that linting. `docker build .` should always make sure that the code is in an
the code is in an able-to-be-compiled state, linted, and any tests run, and able-to-be-compiled state, linted, and any tests run. The Docker build
the build should fail if linting doesn't pass. That guarantee holds only should fail if linting doesn't pass.
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that
busts the 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` or `script/docker`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
@@ -131,10 +124,15 @@ last_modified: 2026-08-09
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

View File

@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-07-06
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -29,27 +29,10 @@ with your task.
if missing if missing
- [ ] `.editorconfig` exists — fetch from - [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); build step — fetch `.dockerignore` from
Dockerfile runs `make check` as a build step, and every stage containing a `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
check-running `RUN` declares `ARG CHECK_EPOCH` with the - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified.
`.dockerignore` anchors an unprefixed pattern at the context root, so the
transplanted form leaves `config/.env` and `certs/server.key` in the build
context while reading as solved — see the `.dockerignore` rule in
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific config: - [ ] Language-specific config:
@@ -76,7 +59,9 @@ with your task.
- [ ] 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
- [ ] `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
@@ -121,6 +106,5 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design, - [ ] `docker build` succeeds
on the `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-07-06
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -52,20 +52,7 @@ Template files can be fetched from:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- Extend `.dockerignore` with the repo's own host-built artifacts, giving - All Dockerfiles must run `make check` as a build step
every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory. Do not transplant
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in
`REPO_POLICIES.md`.
- All Dockerfiles must run `make check` as a build step, and every stage
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- Server: also builds and runs the application - Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check` - Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
@@ -93,8 +80,8 @@ 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` — runs real tests, not a no-op (90-second
timeout) timeout, 60-second hard cap on wall time)
- [ ] `script/lint` / `make lint` — runs linter - [ ] `script/lint` / `make lint` — runs linter
- [ ] `script/fmt` / `make fmt` — formats code (writes) - [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
@@ -103,14 +90,8 @@ are thin shims calling them. Model scripts:
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); assigns `script/projectname` (byte-identical across repos)
`epoch="$(date +%s%N)$$"` on its own line and passes - [ ] `script/cibuild` — cd to repo root, `docker build .` (what CI runs)
`--build-arg CHECK_EPOCH="$epoch"`
- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on
its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .`
(what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md` for why each element is load-bearing. A bare
`docker build .` fails closed by design.
- [ ] `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`

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-09 last_modified: 2026-08-19
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,19 +60,17 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where repo root and runs `docker build .`; the Gitea workflow calls it. Four further
`epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the scripts are our own extensions to the standard: `script/check` runs
Gitea workflow calls it. Four further scripts are our own extensions to the `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
standard: `script/check` runs `script/test`, `script/lint`, and what the git pre-commit hook runs, and it calls `script/check`;
`script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, `script/install-precommit` installs the git pre-commit hook (the `make hooks`
and it calls `script/check`; `script/install-precommit` installs the git target shims to it); and `script/projectname` (literally that filename) simply
pre-commit hook (the `make hooks` target shims to it); and outputs the project's name. Scripts that need the name call
`script/projectname` (literally that filename) simply outputs the project's `script/projectname` — e.g. `script/docker` assembles its image tag from it —
name. Scripts that need the name call `script/projectname` — e.g. so those scripts stay byte-identical across all repos. Repo-type-specific
`script/docker` assembles its image tag from it — so those scripts stay pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. `script/precommit`, not in the hook itself. Model scripts are at
`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).
@@ -92,70 +90,14 @@ style conventions are in separate documents:
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green — which requires as a build step so the build fails if the branch is not green. For non-server
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running repos, the Dockerfile should bring up a development environment and run
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies `make check`. For server repos, `make check` should run as an early build
this criterion while its check layers are served from cache, so the build stage before the final image is assembled. Dockerfiles install development
cannot fail on a branch that is not green. For non-server repos, the prerequisites by running `script/bootstrap` rather than duplicating installs
Dockerfile should bring up a development environment and run `make check`. For inline; COPY `script/` and the dependency manifests (`package.json` +
server repos, `make check` should run as an early build stage before the final `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
image is assembled. Dockerfiles install development prerequisites by running layer stays cached until dependencies change.
`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 `RUN make 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. The canonical form, in **every** stage
containing a check-running `RUN`:
```dockerfile
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
```
and in both `script/cibuild` and `script/docker`:
```sh
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
```
All four elements are load-bearing; none is optional, and each guards a
failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced `ARG`, and
it puts the epoch in the build log. The guard is itself value-keyed, for
the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the
epoch into that layer's description (rendered as
`RUN [ -n "<epoch>" ] || exit 1`) and re-runs it whenever the value
changes. Each stage therefore has two independent invalidation points, and
the guard always precedes the check `RUN`. Keep both: the expansion is
defence in depth, and it is what makes the epoch visible in the build
output.
- The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is
a stable cache key, so without it a bare `docker build .` still produces
the false green. Failed steps are never cached, so the guard fails on
every such invocation, loudly. A bare `docker build .` failing is by
design.
- Assign `epoch=` on its own line, never inline in the `--build-arg`
argument: a failing command substitution inside an argument does not trip
`set -e`, so the inline form silently degrades to an empty constant. The
`$$` suffix is required because busybox `date` drops `%N` and exits 0, so
on an alpine host the epoch would degrade to second granularity and
concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving
`go mod download`, `script/bootstrap`, and the pinned toolchain install
cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` also works but is wasteful and can blow that ceiling.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based repos use a multistage build where linting runs in an independent stage based
@@ -177,9 +119,7 @@ style conventions are in separate documents:
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH RUN make fmt-check
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make lint RUN make lint
# Build stage # Build stage
@@ -193,9 +133,7 @@ style conventions are in separate documents:
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG CHECK_EPOCH RUN make test
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
@@ -216,16 +154,6 @@ style conventions are in separate documents:
a stage dependency. BuildKit runs stages in parallel by default; without a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build. failure might not fail the overall build.
- **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.**
The cache-bust turns this no-op `COPY` into a content-cache hit, so an
ordering guarantee established on a cold cache does not automatically
carry over; it has to be re-checked warm. This was re-proved in another
repo in the org that uses the same file-dependency trick (there with a
marker file in place of `go.sum`), and the ordering held. It has **not**
been verified in this repo, which is single-stage and has no lint stage to
order against. Any repo relying on a file-dependency trick for stage
ordering should re-check it warm after adopting the bust rather than
assuming this result transfers.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must (e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
@@ -237,28 +165,11 @@ style conventions are in separate documents:
- The build stage runs `make test` after compilation setup. Tests run in the - The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies. artifacts or heavier dependencies.
- `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is
stage-scoped: declaring it only in the lint stage leaves `make test`
frozen at the last cached result. In each stage the guard sits immediately
below the `ARG` so a bare `docker build .` fails instead of reusing the
empty cache key, and the value is expanded into the first check `RUN` so
the cache miss does not rely on BuildKit's unreferenced-`ARG` handling.
Both of those lines reference `$CHECK_EPOCH`, so both are value-keyed:
each stage is invalidated at two independent points. The later `RUN`s in
the same stage need no expansion of their own: they are already
invalidated by their busted parent layer.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `script/cibuild` (which runs `docker build .`) on push. Since the
`docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile Dockerfile already runs `make check`, a successful build implies all checks
runs `make check`, so a successful build implies all checks pass — but that pass.
implication holds **only** because of the `CHECK_EPOCH` cache-bust described
above. Without it, an unchanged tree serves the check layer from cache and the
build reports a green it never earned. A bare `docker build .` fails closed by
design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through
`script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the
check layer, means nothing was executed.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -278,8 +189,13 @@ 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 in the Makefile (`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 - **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
@@ -298,9 +214,9 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 30s -race -cover ./... || \ @go test -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 -timeout 90s -race -v ./...; exit 1; }
``` ```
Python example: Python example:
@@ -331,52 +247,7 @@ style conventions are in separate documents:
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. These patterns are written to `.gitignore`'s own semantics, in a new repo.
which an unanchored pattern already matches at every depth. They are not a
`.dockerignore` and must not be transplanted into one unmodified — see the
next rule.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
Go `filepath.Match`: `*` does not cross `/`, and a pattern without a leading
`**/` is anchored at the build-context root. A `.dockerignore` listing `.env`,
`*.pem` and `*.key` therefore excludes only the copies at the repository root;
`config/.env` and `certs/server.key` still reach the context and can land in
an image layer. That file is more dangerous than a short one with no secret
patterns at all, because it reads as solved and stops anyone looking. Give
every depth-independent pattern the `**/` prefix — `**/.env`, `**/.env.*`,
`**/*.pem`, `**/*.key`, `**/node_modules` — and leave only genuinely
root-anchored entries such as `.git` unprefixed. The inverse move is equally
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a
file that is wrong in a way that looks careful. Each file is written to its
own semantics; neither is derived from the other. Fetch the standard
`.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own host-built artifacts — a host `make build` that leaves
a compiled binary in the repo root puts that binary in the build context,
where `.gitignore` hides it from every git-based check. Write that binary
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context.
- **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive
filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not
the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though
case were handled — the same manufactured confidence as the root-anchored
form. `filepath.Match` supports character ranges, so one line covers every
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to
secret-material extensions; names that exist in exactly one spelling because a
tool writes them (`.env`, `.envrc`, `id_rsa`) stay literal.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). Reading the patterns and
agreeing they look right is exactly what lets the root-only form through. The
`transferring context` size is not a substitute: a nested secret is a few
bytes, and BuildKit transfers only the delta from the previous build, so the
reported size describes the transfer and not the contents of the image.
- **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
@@ -392,202 +263,16 @@ 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), installed byte-identical, so that no repo can quietly loosen its own linting. Linter
commit-pinned via 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 canonical golangci-lint version is
v2.12.2 (released 2026-05-06), installed commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- **`script/bootstrap` in Go repos must install the pinned golangci-lint
whenever the installed version does not match the pin — not merely when the
binary is absent — and must then verify the install took effect by
re-resolving the binary through `PATH`.** The presence test
`if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi` is wrong:
it tests `PATH` presence and never version, so on any already-provisioned
machine the pin is inert and a version bump is a no-op. Meanwhile the
Dockerfile installs unconditionally into a clean image, so CI and local
silently disagree about what the linter even is. Observed consequences: a
local `make check` green while `make docker` rejected the same commit with six
`goconst` findings, and a container linter surfacing thirteen findings the
host run missed. A stale host linter does not merely fail to prove the tree is
clean — it hides findings only the container can see. This is a deliberate
departure from the node handling described above, which uses whatever node is
installed: the linter version is the specific thing being held equal between
host and container, so for it, presence is not enough.
Comparing versions is necessary but **not sufficient**, because the obvious
fix also fails green. `go install` writes to `GOBIN` (or `GOPATH/bin`) while
callers resolve `golangci-lint` through `PATH`. If a different binary
shadows it earlier in `PATH`, the install genuinely succeeds and changes
nothing any caller will ever see: bootstrap prints success and the next
`make lint` still runs the stale linter. That is worse than no fix, because
it converts a known-stale toolchain into one everyone believes is pinned.
The canonical form, placed in `script/bootstrap` after Go itself is present:
```sh
# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly
# what `golangci-lint --version` prints for this ref; update both together.
GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints
# something unparseable: all of those must read as "does not match".
# The capture is the whole version token, not just its numeric prefix.
# Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2
# and skip the install, which is the defect this whole rule exists to close.
# The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
# a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint --version 2>/dev/null | head -n 1 |
sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true
}
ensure_golangci_lint() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
return 0
fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
go install "$GOLANGCI_LINT_REF"
# go install writes to GOBIN (or GOPATH/bin); callers resolve through
# PATH. Re-resolve through PATH and assert the install took effect.
# `hash -r` is load-bearing: without it a shell that already resolved
# a stale golangci-lint answers from its own lookup cache, and this
# check false-fails with the shadowing message below.
hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
"and PATH resolves it"
return 0
fi
gcl_bin="$(go env GOBIN)"
[ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
# Strip a trailing slash: GOBIN=/x/ would otherwise make the
# "$gcl_bin"/* test below miss and misreport shadowing.
while :; do
case "$gcl_bin" in
*/) gcl_bin="${gcl_bin%/}" ;;
*) break ;;
esac
done
gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2
case "$gcl_found" in
"")
echo "bootstrap: PATH resolves no golangci-lint at all." \
"Add $gcl_bin to PATH, then re-run bootstrap." >&2
;;
"$gcl_bin"/*)
echo "bootstrap: PATH resolves $gcl_found, inside that same" \
"directory, reporting version ${gcl_got:-unparseable}." \
"Nothing is shadowing it, so the install itself did not" \
"produce the pinned version: check that" \
"GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2
;;
*)
echo "bootstrap: PATH resolves $gcl_found instead, reporting" \
"version ${gcl_got:-unparseable}. Remove that binary or" \
"put $gcl_bin earlier in PATH, then re-run bootstrap." >&2
;;
esac
exit 1
}
# The definitions above are inert on their own; the call site is part of
# the canonical form. In a script/bootstrap that follows the "define all
# functions, then call main" convention, this line belongs inside main()
# next to the other ensure_* steps.
ensure_golangci_lint
```
Four properties are load-bearing; each guards a failure mode that otherwise
fails green:
- **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the **whole** version token, exactly: a parser
that stops at the first `-` reports `2.12.2` for a host running
`2.12.2-rc1`, which compares equal to a `2.12.2` pin and skips the install
— the original defect, reintroduced through the comparison meant to fix
it.
- **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the path `go install` wrote to — and assert
`--version` reports the pin. When it does not, fail non-zero and name the
path `command -v` actually found, the version it reports, and the
directory the install wrote to. That is a condition a human has to fix by
hand, so bootstrap must not print success in it. Use `hash -r` first so
the shell does not answer from its own lookup cache. Diagnose the cause
from the resolved path rather than asserting one: only a path **outside**
the install directory is shadowing. When the resolved path is inside it,
nothing is shadowing and telling the operator to delete that binary or
reorder `PATH` sends them after a fault that does not exist.
- **A mis-parse must fall through to reinstall, never to a false match.**
Absent binary, non-zero exit, empty output, and unrecognised output 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.** Two function definitions with no call
site are a silent no-op that reproduces the original defect exactly: exit
0, nothing installed, no output, stale linter still resolved. A success
path that prints nothing is byte-identical to that no-op — same exit
status, same empty output — so both success branches must print a
confirmation naming the version. In a change about undetectable no-ops,
"it printed nothing and exited 0" must not be the healthy signal.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
**On the hash-pinning rule.** `@c0d3ddc9cf3faa61a4e378e879ece580256d76e5` is
a commit hash, not a server-mutable version tag, and the go command verifies
the fetched module against the checksum database — the mechanism the
hash-pinning rule at the top of this document already names as acceptable
for Go modules. Note that `go install pkg@version` runs in module-aware mode
ignoring the `go.mod` in the current directory or any parent, so no repo
`go.sum` is consulted for this install; the checksum database is what
verifies it. The linter is a bootstrap prerequisite rather than part of any
repo's module graph, which is why the canonical form installs it by
commit-pinned ref instead of declaring it in `go.mod`. Whether a `go.mod`
tool dependency — which would pin the hash in a committed, reviewable file
instead — should replace this is an open decision, tracked at
[prompts#37](https://git.eeqj.de/sneak/prompts/issues/37).
**Keep `GOLANGCI_LINT_VERSION` and the ref in sync.** The ref is a hash and
carries no readable version, so the expected version is a separate string,
and it must be exactly what `--version` prints for that ref — the comparison
is an exact match on the whole version token. When the pinned commit carries
a release tag the go command resolves the hash to that tag, so the string is
simply the release number, `2.12.2` here. When it does not, the go command
falls back to a pseudo-version and the binary reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`; that compares exactly like any other
string, so it works, but it cannot be known without building the binary once
and reading `--version` off it. Prefer pins on tagged releases for that
reason — the expected string is then derivable from the ref — not because
the comparison cannot handle the alternative.
Because the comparison covers the whole token, a pre-release is never
confused with its release: a host carrying `2.12.2-rc1` against a `2.12.2`
pin compares unequal and gets reinstalled. This matters more than it looks,
because a pre-release tag is still a tag, so a rule requiring merely that
the pin be tagged would not catch it.
**Verifying a change to this logic requires a negative control run in an
environment where a shadowing binary exists earlier in `PATH` than the
install target.** Without that, the control passes against the naive
compare-then-install form as well and therefore proves nothing. Also check
the mis-parse direction by feeding it unparseable `--version` output and
confirming it reinstalls rather than reporting a match.
**Run those controls against the block as a consuming repo would adopt it**
— pasted into a `script/bootstrap`-shaped file that is then executed — not
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.
- 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).
@@ -705,7 +390,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)

View File

@@ -1,20 +1,13 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but # script/cibuild: run the CI build. The Dockerfile runs script/check, so
# that only proves anything because CHECK_EPOCH is a fresh nonce on every # a successful build implies all checks pass.
# invocation: without it Docker serves the check layer from cache on an
# unchanged tree and the build exits 0 without running the suite.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an docker build .
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
} }
main "$@" main "$@"

View File

@@ -1,9 +1,6 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. The # Identical in all repos; the tag comes from script/projectname.
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,13 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Assign on its own line: a failing command substitution inside an docker build -t "$("$SCRIPT_DIR/projectname")" .
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"