Author SHA1 Message Date
sneak 4a0d49b8e0 Keep agent guidance in one root AGENTS.md (closes #31)
check / check (push) Successful in 32s
The canonical REPO_POLICIES.md now says that guidance for coding agents lives in one AGENTS.md at the repository root, including anything an agent should remember between sessions, and is never committed under a file or directory named after one agent tool or split into separate memory files. This retires the in-repo memory rule that older vendored copies still carry.

AGENTS.md is added to the list of files allowed in the root, which would otherwise contradict the new rule. Both checklists carry the matching item.

Model: opus-5-5
2026-10-04 04:51:08 +00:00
clawbot 5de98c404e Keep each submodule's git config out of the build context (closes #75)
check / check (push) Successful in 28s
The canonical `.dockerignore` kept out `.git/config`, which can hold a credential, but not the `config` in each submodule's git directory under `.git/modules/`, nested again for a submodule's own submodules. It now also lists `.git/modules/**/config`, with one sentence in the comment above; `prompts/REPO_POLICIES.md` and both checklists say so in the same words.

The pattern stays under `.git/modules/` because `.git/**/config` would also drop a branch or tag named `config`, which `git describe` may need.

Known gap: a submodule whose name has a `config` path segment loses its whole git directory, so Go's version stamping fails the build loudly; tracked separately.

Model: opus-5-5
2026-10-04 05:31:51 +02:00
clawbot dcc0ba0b66 Fix git ownership and -race in the canonical Go Dockerfile (closes #73)
check / check (push) Successful in 52s
The canonical Go `Dockerfile` example in `prompts/REPO_POLICIES.md` had two defects.

The test phase ran `go test -race` on the alpine Go image, which has no C compiler, so `-race` failed before any test ran. The test phase now uses the Debian Go image, pinned by digest like the others.

A build context sent as a tar stream keeps the sender's file owners, so git refused the checkout and the version step failed the build. The builder stage now runs `git config --system --add safe.directory /src`; the policy and both checklists say why in the same words.

The builder's `apk add --no-cache git` line is unchanged: whether it must be pinned is the open owner question on #72.

Model: opus-5-5
2026-10-04 04:31:51 +02:00
clawbot 343628fb3a Pin golangci-lint v2.14.0; disable exhaustruct_v5 (closes #65)
check / check (push) Successful in 37s
The canonical golangci-lint moves from v2.12.2 to v2.14.0, built with go1.27: v2.12.2 refuses a module whose `go` directive names 1.27 or later. The policy now states the rule: the `go` directive must not name a newer Go minor version than the one golangci-lint was built with.

From v2.13.0, `default: all` turns on `exhaustruct_v5`, the successor of the deprecated `exhaustruct`. `.golangci.yml` disables it beside the old name, which stays listed or its deprecation warning returns.

v2.12.2 rejects the new `.golangci.yml`, so a repo changes the lint phase digest and re-vendors `.golangci.yml` in one commit; both checklists point to that rule.

Model: opus-5-5
2026-10-04 03:32:08 +02:00
clawbot 7ea5cdcdcd Cover more secret shapes in the canonical .gitignore (closes #38)
check / check (push) Successful in 23s
The canonical .gitignore matched only .env, .env.*, *.pem and *.key, so prod.env, .envrc, *.p12 and *.pfx bundles, and the SSH private keys id_rsa, id_dsa, id_ecdsa and id_ed25519 could be committed. The secrets section now covers the same shapes as the canonical .dockerignore, written to gitignore's own rules: unanchored, no **/ prefix, character ranges for case. example.env and sample.env stay trackable through negations, and the comment tells a repository to add its own negation for any other committed template.

Judgement call: the existing entries were rewritten with character ranges, which only widens them, and the bare .env line is dropped because *.env covers it.

Model: opus-5-5
2026-10-03 17:39:40 +02:00
clawbot 507a57e813 Derive the image version from git; send .git without its config (closes #69, closes #71)
check / check (push) Successful in 23s
The canonical documents told every repo to exclude .git from the build
context, default ARG VERSION to dev and never run git describe in a
build stage, so an image built from a clone with no build argument
reported dev. .dockerignore now sends .git but keeps out .git/config,
which can hold a credential. The Dockerfile example installs git, takes
the VERSION build argument when one is given and otherwise
git describe --tags --always, and fails when .git exists but the version
is empty, dev or unknown. The policy and both checklists state the rule
in the same words, including that a plain docker build . with no build
arguments must succeed.

Model: opus-5-5
2026-10-02 04:38:10 +02:00
clawbot 2ae9391b26 Read the architecture at run time, not via a Buildarch ldflag (closes #66)
check / check (push) Successful in 25s
The Go styleguide and the HTTP server conventions no longer pass the
build architecture in through the Makefile. The Buildarch variable,
globals field and BUILDARCH Makefile lines are removed from every
example; the styleguide example prints runtime.GOARCH, and the
logger's Identify logs "arch", runtime.GOARCH. The styleguide item
gains one sentence saying so.

Model: opus-5-5
2026-10-02 01:03:38 +02:00
clawbotandsneak ed5ed236b1 Migrate canonical .golangci.yml to gomodguard_v2, with a block list (#55)
check / check (push) Successful in 30s
Option-2 answer to sneak's ruling of 2026-08-19 on
sneak/homoicon#4: migrate to the successor, with
settings. Closes #25.

## The change

`golangci-lint` v2.12.0 deprecated `gomodguard`, and this config sets
`linters.default: all`, so it is enabled everywhere and warns on every run.

- `gomodguard` joins `wsl` in `linters.disable` under a shared "deprecated"
  comment. The warning is attached to the old name, so disabling it is what
  silences it.
- `gomodguard_v2` is named in `linters.enable`, a no-op under `default: all`
  that gives the settings block a visible owner.
- Blocked, each restating a decision already recorded in the Go package
  defaults: `rs/zerolog` → `log/slog`; the pre-fork `go-redis/redis` →
  `redis/go-redis/v9`; `sergi/go-diff` and `hexops/gotextdiff` → `go-udiff`.
  Every entry matches the module path exactly, so the pre-fork go-redis takes
  three: `go-redis/redis`, `/v7`, `/v8`. A prefix would also cover
  `go-redis/redismock`, the test double for the successor recommended here.

Deliberately absent: recorded rejections that vendoring repos still require
(`mattn/go-sqlite3`, `gorm.io/gorm`, `pmezard/go-difflib`), plus `urfave/cli`
and unversioned `go-chi/chi`. Blocking those would redden repos mid-migration
on their next re-vendor.

## After merge

The file's sha256 moves from
`d10f47ef5e0d8620efd62275b016a7de8fd5abedf333922eec86fe4abc06176e` to
`a79b63a254602a5318db5d0e9a06bc71b84bf0c1d896305229d8bfed1d1b1776`, so every
vendoring repo mismatches. #60 is the
propagation brief; it and the record on
#25 carry this value.

## Disclosures

- Judgement call: `wsl` moved two lines down to share the "deprecated" comment.
  No behaviour change, but it widens the diff.
- The `go.mod` survey and the settings-block probe are on
  #25.
- Unverified: the linter was not run against each vendoring repo; the per-repo
  claim rests on reading their `go.mod` files.
- `make check` exit 0.

Model: opus-5
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #55
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-09-09 14:04:18 +02:00
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
19 changed files with 863 additions and 1228 deletions
+30 -64
View File
@@ -1,75 +1,42 @@
# 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 # .git is sent without its config. Without a VERSION build argument the
# means `git describe` cannot run in any build stage, and it fails # stage that compiles runs `git describe --tags --always` on .git, which
# quietly there rather than erroring, so a version embedded that way # does not need .git/config; that file can hold a credential, such as a
# comes out empty. Compute the version on the host and pass it in with # password in a remote URL or the token the CI checkout step stores there.
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md. # Each submodule keeps a config with the same exposure in its git directory
.git # under .git/modules/, nested again for a submodule's own submodules.
.git/config
.git/modules/**/config
# 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 +53,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
**/*~ **/*~
+23 -5
View File
@@ -20,8 +20,26 @@ Thumbs.db
# Node # Node
node_modules/ node_modules/
# Environment / secrets # Secrets. Unanchored like every entry above, so each matches at every
.env # depth. Matching is case-sensitive on Linux, so names use character
.env.* # ranges rather than a lowercase form that misses `Server.Key`.
*.pem
*.key # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][dD]25519
+67 -2
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 - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- 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,64 @@ 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.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- 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/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
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
+48 -26
View File
@@ -1,37 +1,59 @@
# 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 . .
# Nothing here is compiled and a LABEL cannot run git, so the version is
# the VERSION build argument that script/docker and script/cibuild pass;
# a plain `docker build .` leaves it empty.
ARG VERSION
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
+16 -19
View File
@@ -116,26 +116,23 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here) - `script/test` — `docker build --no-cache --target test -t prompts-test .`,
- `script/lint` — lint the markdown files, 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)
would nest a docker build inside a build step. A bare `docker build .` fails
closed on purpose
- `script/precommit` — run by the git pre-commit hook (our own extension); calls - `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
+81 -134
View File
@@ -21,140 +21,87 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Closed three gaps the containerised-lint rule left between the - 2026-10-04: `REPO_POLICIES.md` now states that guidance for coding agents
canonical text and the first repos to implement it. `.dockerignore` excluding lives in one `AGENTS.md` at the repository root, never under a file or
the agent scratch directory is now stated as a correctness precondition of directory named after one agent tool and never in separate memory files (issue
that rule rather than a context-size measure: the lint image lints whatever 31). This retires the rule, still present in older vendored copies, that kept
`COPY . .` copies, and toolchains discover files by walking the tree instead agent memory as committed files under `.claude/memory/`. `AGENTS.md` joins the
of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds list of files allowed in the root, and both checklists say so.
back inside the container — `sneak/quak` measured the same discovery mechanism - 2026-10-04: The canonical `.dockerignore` now also keeps out each submodule's
taking a test count from 210 to 1050. The cache-bust arg is fixed at `config` (issue 75). A submodule's git directory lives under `.git/modules/`,
`CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is nested again for its own submodules, and its `config` can hold a credential
invisible to the grep that proves every build is busted, making a renamed just like `.git/config`. The pattern `.git/modules/**/config` covers every
guard indistinguishable from a missing one. And the formatting check is now depth and leaves the top-level `.git` that `git describe` reads untouched.
required to run in exactly one of the two images, with either placement `REPO_POLICIES.md` and both checklists say so in the same words.
allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check` - 2026-10-03: Fixed two defects in the canonical Go `Dockerfile` example (issue
gets dropped from both, and running the formatter beside the linters is the 73). The test phase now uses the Debian Go image, since `-race` needs cgo and
better shape where it is the same pinned dependency. the alpine image has no C compiler, so the phase failed before running a test.
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and The stage that compiles runs `git config --system --add safe.directory /src`,
made this repo do it rather than merely document it. `script/lint` is now because a context sent as a tar stream keeps the sender's file owners and git
`docker build -f Dockerfile.lint .` and nothing else; the linter is never refuses that checkout, leaving the version empty. Both checklists state that
installed on the host and never invoked there, so a run cannot inherit another step in the same words.
checkout's content-keyed result cache, the host-global - 2026-10-03: Moved the canonical golangci-lint to v2.14.0, built with go1.27,
`$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned because v2.12.2 refuses to lint a module whose `go` directive is 1.27 (issue
one — the three mechanisms behind a confirmed false green, a string of 65). Releases from v2.13.0 deprecate `exhaustruct` in favour of
findings reported against other agents' checkouts, and a container that saw `exhaustruct_v5`, which `default: all` switches on, so the canonical
thirteen findings the host missed. Linting runs as a build step, so a `.golangci.yml` now disables `exhaustruct_v5` beside `exhaustruct`. v2.12.2
successful build is a clean lint, which also works where the docker daemon is rejects that file, so `REPO_POLICIES.md` and both repo checklists now say a
remote and bind mounts are impossible. The recursion this creates is resolved repo sets the lint phase digest and re-vendors `.golangci.yml` in one commit.
by direction rather than by detection: the main `Dockerfile` runs the - 2026-10-03: Brought the canonical `.gitignore` level with `.dockerignore` on
individual non-lint checks instead of `make check`, and `script/cibuild` runs secrets (issue 38): it now also ignores `prod.env`-style `*.env` files,
`script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries `.envrc`, `*.p12`, `*.pfx` and the extensionless SSH private keys, written to
the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the `.gitignore`'s own rules (no `**/` prefix) and case-folded with character
dependency layer so only the lint steps re-run — blanket `--no-cache` was ranges. `example.env` and `sample.env` stay trackable through negations.
rejected because it makes every lint reinstall its dependencies over the - 2026-10-02: The image version now comes from git inside the build (issues 69
network. Two canonical forms were superseded rather than left standing beside and 71), superseding the 2026-09-08 entry that excluded `.git`. The canonical
the new one, since consuming repos read this document literally: the `.dockerignore` sends `.git` but keeps out `.git/config`, which can hold a
`script/bootstrap` golangci-lint install (nothing runs a host linter now, so credential. The Dockerfile example in `REPO_POLICIES.md` installs `git`, takes
it can only reintroduce skew; the version-enforcement principle stays the `VERSION` build argument when one is given and otherwise
documented for other pinned host tools) and the per-checkout `git describe --tags --always`, and fails when `.git` exists but the version
cache/lock/`.lint-cache` wrapper (its whole subject was making a host run is empty, `dev` or `unknown`; a plain `docker build .` with no build arguments
trustworthy). The Go multistage lint stage goes with them: it ran `make lint`, must succeed. This repo's `script/docker` and `script/cibuild` still pass
which is now a docker build. `golangci-lint config verify` was kept on `--build-arg VERSION`, since its own `Dockerfile` compiles nothing.
measurement, not preference — a bogus config key passes `golangci-lint run` - 2026-09-08: Moved linting and testing into Docker as phases of the main
with `0 issues` and fails `config verify`, and every case reproduced `Dockerfile`, per the owner ruling on issue 40. `script/lint` and
byte-identically under `docker run --network none`, so the schema is embedded `script/test` build one phase each by name with `--no-cache` — the same answer
in the pinned binary and the line costs no network. Verified with two issue 26 got, so no separate cache-busting mechanism survives — and the final
consecutive runs on an unchanged tree both executing the linter, a planted stage copies a harmless file from both, so the image cannot be built unless
violation caught and reverted, the bare-build guard firing, and the main image they pass. This also closes issue 30: a container has its own result cache and
building without attempting a nested build. its own lock, so a lint verdict can no longer belong to another checkout. No
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it. separate lint Dockerfile, and no `golangci-lint config verify` step.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the `script/check` runs the gates and nothing else, and `script/cibuild`
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two bootstraps first, since it is all CI runs and `script/fmt-check` is native.
are separate defects and the second is the one that gets dropped: the result - 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
cache is keyed on file content rather than location, so checkouts holding of version control: `.claude/` is one full checkout of the repo per in-flight
identical files serve each other's findings under the other's path, while the agent, and under `COPY . .` all of it was reaching the image. Also closed the
concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of consequence of excluding `.git` — `git describe` yields an empty version
the cache, and unaffected by isolating it. Moving workers from worktrees to inside a build stage without failing, so `script/docker` and `script/cibuild`
their own clones does not help either half; it only removes the foreign-path now compute the version on the host and pass `--build-arg VERSION`.
artefact that made the defect visible. The lock error is retried rather than - 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a
surfaced, because it is not a result: it exits non-zero exactly as findings local `.env`, `*.pem` or `*.key` was reaching the build context under
do, and reporting it as findings sends a correct branch back for rework. `COPY . .`, invisible to every git-based check. The patterns are now written
Detection is on the stderr stream and never on exit status, so a finding to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every
quoting the lock message in source cannot be retried away, and exhaustion depth, case-folded with character ranges — and `REPO_POLICIES.md` requires
exits 75 with a VOID message rather than passing or failing quietly. verifying by enumerating the image rather than by reading the file.
`--allow-serial-runners` (which keeps the guard and queues) covers the - 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host.
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners` `REPO_POLICIES.md` now requires comparing the installed version against the
is rejected outright. The stdout and stderr capture files are per invocation pin rather than testing `PATH` presence, and re-resolving the binary through
rather than per checkout, because serialising the linter does not serialise `PATH` after installing, so a version bump cannot be a silent no-op and a
the shell's redirections: two runs in one checkout — the overlap the flag shadowed install cannot report success.
exists to support — would otherwise truncate and read each other's output, - 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild`
which is the same defect one layer above where it was fixed. Both checklists and `script/docker` now build with `--no-cache`, so the Dockerfile's check
gained the corresponding items, since a half-fix that sets only the cache layers cannot be served from cache on an unchanged tree, and the text claiming
reads as complete. `GOCACHE` was measured and does not need isolating. a bare `docker build .` proves the checks ran is corrected in
Verified with the snippet extracted from the committed document and executed `REPO_POLICIES.md`, both checklists and the Go styleguide.
as a consuming repo would adopt it, against paired controls: contamination - 2026-09-03: Added `-count=1` to both `go test` invocations in the canonical Go
reproduced on the pre-fix form and absent on the adopted one, retry engaged, `make test` example in `REPO_POLICIES.md`, so the target cannot report a
exhaustion loud, a genuine finding still reported, and a held host lock cached pass it did not earn, and documented that Go's test-result cache is a
failing the pre-fix script while leaving the adopted one untouched. second, independent cache stacked below the Docker layer cache.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out - 2026-08-31: Migrated the canonical `.golangci.yml` from the deprecated
of version control. `.claude/` holds one worktree — an entire additional `gomodguard` to `gomodguard_v2`: the old linter is disabled by name (which is
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was what silences the deprecation warning), the successor is named explicitly in
reaching the image: another session's unreviewed, sometimes uncommitted work, `linters.enable`, and it carries a `blocked` module list drawn only from
inflating the context by a multiple of the repo and invalidating `COPY` for decisions already recorded in the Go package defaults.
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
+32 -46
View File
@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-10 last_modified: 2026-10-02
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -24,7 +24,8 @@ last_modified: 2026-08-10
1. Embed the git commit hash into the binary and include it in startup logs and 1. Embed the git commit hash into the binary and include it in startup logs and
in health check output. This is to make it easier to correlate running in health check output. This is to make it easier to correlate running
instances with their code. Do not include build time or build user, as these instances with their code. Do not include build time or build user, as these
will make the build nondeterministic. will make the build nondeterministic. The architecture is not passed in at
build time; a program that reports it reads `runtime.GOARCH` at run time.
Example relevant Makefile sections: Example relevant Makefile sections:
@@ -35,38 +36,25 @@ last_modified: 2026-08-10
import ( import (
"fmt" "fmt"
"runtime"
) )
var ( var Version string
Version string
Buildarch string
)
func main() { func main() {
fmt.Printf("Version: %s\n", Version) fmt.Printf("Version: %s\n", Version)
fmt.Printf("Buildarch: %s\n", Buildarch) fmt.Printf("Arch: %s\n", runtime.GOARCH)
} }
``` ```
```make ```make
# ?= rather than := because this `$(shell git describe ...)` is only # ?= rather than := so that a `VERSION` build argument takes precedence:
# correct on the host. `.dockerignore` excludes `.git`, so evaluated # where a build stage invokes make, `ARG VERSION` puts it in the
# inside a build stage it expands to the empty string without failing # environment and `?=` defers to it. Otherwise `git describe` runs, in a
# and the binary reports no version at all. The version is computed on # build stage on the `.git` the build context carries.
# the host by `script/docker` / `script/cibuild` and passed with VERSION ?= $(shell git describe --tags --always)
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the
# value in the environment and `?=` defers to it. The canonical Go
# template in REPO_POLICIES.md instead runs `go build` directly with
# `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is
# a host-only path — but it is still `?=`, because a repo that later
# moves the build behind make must not silently start shipping an empty
# version. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
# osx can't statically link apparently?! # osx can't statically link apparently?!
ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_S),Darwin)
@@ -111,26 +99,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 +132,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
+89 -90
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-10-04
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -24,84 +24,75 @@ with your task.
- [ ] `LICENSE` file exists and matches the README - [ ] `LICENSE` file exists and matches the README
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from - [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] Guidance for coding agents, if the repo has any, is one `AGENTS.md` at the
root — never a file or directory named after one agent tool, such as
`CLAUDE.md` or `.claude/`, and never separate memory files. Move what any
such committed file says into `AGENTS.md` and delete it.
- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language - [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language
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 `.claude` 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 - [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into
became 1050). Confirm by enumerating the image, not by reading the file — the build context. It keeps out `.git/config` and each submodule's
`.gitignore` hides these from `git status` too. `config` under `.git/modules/` at any depth (`.git/modules/**/config`),
- [ ] **Do agents in this repo run anywhere other than the repo root?** The which `git describe` does not need and which can hold a credential: a
scratch directory is created in the agent's working directory, so the password in a remote URL, or the token the CI checkout step stores there.
canonical anchored entry misses `services/api/.claude/` in a monorepo with The stage that compiles has `git` (the Debian Go image has it; an alpine
a per-service agent — it still reaches the build context and the image. An one needs `apk add --no-cache git`) and takes the version from the
existing repo is where such a layout already exists, so check it here `VERSION` build argument when one is given, otherwise from
rather than assuming the canonical entry covers you: add anchored entries `git describe --tags --always`. That gives the tag on a tagged commit; on
for the subdirectories that have one (`/services/api/.claude`), or a later commit, the tag, the number of commits since it and the short
`**/.claude` once you have confirmed no legitimately named nested commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
directory would be caught. reachable. The stage that compiles also marks its working directory safe
- [ ] If the repo embeds a version in a binary, that version is computed on the for git (`git config --system --add safe.directory /src`): a context sent
host and passed with `--build-arg VERSION=...` by `script/docker` and as a tar stream keeps the sender's file owners, and git refuses a checkout
`script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes owned by another user, so the version would come out empty. `ARG VERSION`
`.git`, so it yields an empty version without failing the build. A has no default, and the build fails if the context carries `.git` and the
tag-derived version additionally needs `fetch-depth: 0` on the CI checkout version still comes out empty, `dev` or `unknown`. A plain
step, which clones shallow and fetches no tags by default. `docker build .` with no build arguments must succeed; a Dockerfile that
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix; refuses an empty build argument drops that refusal and keeps the argument.
only genuinely root-anchored entries such as `.git` are unprefixed, and `script/docker` and `script/cibuild` pass the version they compute on the
`.gitignore`'s patterns have not been transplanted unmodified. host; it takes precedence. A tag-derived version additionally needs
`.dockerignore` anchors an unprefixed pattern at the context root, so the `fetch-depth: 0` on the CI checkout step, which clones shallow and fetches
transplanted form leaves `config/.env` and `certs/server.key` in the build no tags by default.
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`
- [ ] Language-specific config: - [ ] Language-specific config:
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from - [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`) `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
(fetch from (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and
@@ -122,26 +113,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 +184,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
+17 -28
View File
@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-08-09 last_modified: 2026-10-02
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -118,15 +118,13 @@ import (
) )
var ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -826,7 +824,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch, "arch", runtime.GOARCH,
) )
} }
``` ```
@@ -946,23 +944,20 @@ import "go.uber.org/fx"
// Package-level variables (set from main) // Package-level variables (set from main)
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Buildarch: Buildarch, Version: Version,
Version: Version,
} }
return n, nil return n, nil
} }
@@ -973,15 +968,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go ```go
// cmd/httpd/main.go // cmd/httpd/main.go
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -991,18 +984,14 @@ func main() {
Use ldflags to inject version information at build time: Use ldflags to inject version information at build time:
```makefile ```makefile
# ?= rather than := because this `$(shell git describe ...)` is only correct # ?= rather than := so that a `VERSION` build argument takes precedence:
# on the host: `.dockerignore` excludes `.git`, so evaluated inside a build # where a build stage invokes make, `ARG VERSION` puts it in the
# stage it expands to the empty string without failing and the binary reports # environment and `?=` defers to it. Otherwise `git describe` runs, in a
# no version. The version is computed on the host by `script/docker` / # build stage on the `.git` the build context carries.
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --tags --always) VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
``` ```
--- ---
+70 -87
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-10 last_modified: 2026-10-04
--- ---
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
@@ -54,6 +54,9 @@ Template files can be fetched from:
- [ ] `LICENSE` file matching the chosen license - [ ] `LICENSE` file matching the chosen license
- [ ] `REPO_POLICIES.md` — fetch from - [ ] `REPO_POLICIES.md` — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] Guidance for coding agents, if the repo has any, is one `AGENTS.md` at the
root — never a file or directory named after one agent tool, such as
`CLAUDE.md` or `.claude/`, and never separate memory files
- [ ] `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 - Extend `.dockerignore` with the repo's own host-built artifacts, giving
@@ -62,53 +65,46 @@ 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: `.dockerignore` lets `.git`
host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is into the build context. It keeps out `.git/config` and each submodule's
declared in the stage that compiles, and **no stage calls `git describe`** `config` under `.git/modules/` at any depth (`.git/modules/**/config`),
— `.dockerignore` excludes `.git`, so it yields an empty version without which `git describe` does not need and which can hold a credential: a
failing the build. password in a remote URL, or the token the CI checkout step stores there.
- The `Dockerfile` runs the **non-lint** checks as build steps — The stage that compiles has `git` (the Debian Go image has it; an alpine
`script/test` and `script/fmt-check`, never `make check`. `script/lint` is one needs `apk add --no-cache git`) and takes the version from the
a `docker build` of `Dockerfile.lint`, so `make check` here nests a build `VERSION` build argument when one is given, otherwise from
inside a build step, where there is no daemon. Put a comment above those `git describe --tags --always`. That gives the tag on a tagged commit; on
`RUN` lines saying so. Every stage containing a check-running `RUN` must a later commit, the tag, the number of commits since it and the short
declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
guard immediately below it — see the `CHECK_EPOCH` rule in reachable. The stage that compiles also marks its working directory safe
`REPO_POLICIES.md`. Without them the check layer is served from cache on for git (`git config --system --add safe.directory /src`): a context sent
an unchanged tree and the build reports a green it never ran. as a tar stream keeps the sender's file owners, and git refuses a checkout
- Server: also builds and runs the application owned by another user, so the version would come out empty. `ARG VERSION`
- Non-server: brings up dev environment and runs those checks has no default, and the build fails if the context carries `.git` and the
version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking
its tool directly rather than through `make` or `script/`, and the final
stage carries a `COPY --from=` of a harmless file from each so the image
cannot be built unless both passed. Keep the final stage last: a stage
nothing depends on is built only when `--target` names it.
- Server: the final stage builds and runs the application
- Non-server: the final stage brings up the dev environment
- 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`
- [ ] Language-specific: - [ ] Language-specific:
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from - [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`) `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `yarn init`, `yarn add --dev prettier` - [ ] JS: `yarn init`, `yarn add --dev prettier`
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
@@ -127,51 +123,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 +161,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
+301 -610
View File
File diff suppressed because it is too large Load Diff
+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)"
+14 -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,17 @@ 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. The VERSION build argument takes precedence over
# argument does not trip `set -e`, which would silently degrade the # the version a build stage derives from the .git in the context.
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" 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 "$@"
+8 -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,13 @@ 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. The VERSION build argument takes precedence over
# drops %N without erroring. # the version a build stage derives from the .git in the context.
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")" . -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 "$@"