Author SHA1 Message Date
clawbot fdb566a209 Reflow the merged .golangci.yml bullet with prettier
check / check (push) Successful in 11s
2026-09-03 20:10:18 +00:00
clawbot d709d88e79 Merge main into next
check / check (push) Has been cancelled
Reconciles next with three merges it predated: the 60s/90s test budget,
thin cmd/ entrypoints, and the .golangci.yml prohibition scoped to
vendored copies.

Conflicts:

- prompts/REPO_POLICIES.md frontmatter: took main's later last_modified.
- prompts/NEW_REPO_CHECKLIST.md: main's test-budget line, next's
  containerised-lint line. The two edits touch adjacent list items only.
- prompts/REPO_POLICIES.md .golangci.yml bullet: both sides rewrote it.
  Took main's vendored-copy scoping and the change-by-re-vendoring
  sentence, and kept next's digest pin in Dockerfile.lint, which
  supersedes the host go install that main's text still describes.

The test-budget text is main's verbatim on both sides of every conflict.
2026-09-03 20:10:01 +00: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:

> `.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
sneak cc6a5a00e7 Run every lint in a container via Dockerfile.lint (closes #40)
check / check (push) Successful in 9s
script/lint runs the linter directly when it is already inside a
container and otherwise builds Dockerfile.lint, so the linter never runs
on a developer host. That closes three host-only mechanisms: the result
cache golangci-lint keys on file content rather than location, which
produced a confirmed false green and findings reported against other
checkouts; the host-global $TMPDIR/golangci-lint.lock, which fails a run
in a way no caller can distinguish from findings; and host/container
version skew, which hid thirteen findings on one repo.

Detection is on LINT_IN_CONTAINER=1, set by every Dockerfile, and on
nothing else. The two directions are not symmetric: a false negative
inside a container attempts a nested docker build, finds no daemon and
fails loudly, while a false positive on a host silently lints there,
which is the defect this issue exists to kill. /.dockerenv is therefore
rejected even as a fallback -- measured absent inside BuildKit RUN steps
and present on any host that is itself a container, so it fails in both
directions and one of them is the dangerous one.

Nothing else changes shape. The Dockerfile still runs make check,
script/check still runs test, lint and fmt-check, script/cibuild is
still a single docker build with CHECK_EPOCH and VERSION, and the Go
multistage lint stage and its COPY --from=lint ordering dependency
survive with ENV LINT_IN_CONTAINER=1 added. Dockerfile.lint is the
standalone developer-host path and carries the same CHECK_EPOCH guard,
with the ARG below the dependency layer so only the lint re-runs.

The script/bootstrap golangci-lint install and the per-checkout
GOLANGCI_LINT_CACHE/TMPDIR wrapper are deleted as superseded. Neither
has a caller left. A JS repo's yarn install stays: the rule is that no
lint verdict may come from a host invocation, not that no linter binary
may exist there, and in a repo whose formatter is its linter the
formatter necessarily runs on the host.

golangci-lint config verify is kept, on measurement. Under the pinned
v2.12.2 a bogus top-level key and a bogus key under linters.settings.lll
both pass `golangci-lint run` with exit 0 and `0 issues` while config
verify exits 3 and names them; an unknown linter name fails run and
passes config verify. It needs no network: every case reproduced
byte-identically under `docker run --network none`, in a container where
`getent hosts golangci-lint.run` exits 2.

Comment blocks were cut hard across every file this unit touches.
.dockerignore drops from 67 comment lines to 28, script/cibuild from 17
to 12, script/docker from 18 to 12, and prompts/REPO_POLICIES.md from
1182 lines to 907. What remains says why a line is load-bearing; the
discovery narratives are gone.

config verify lives in script/lint's native branch rather than in a
Dockerfile, so every path that lints inherits it: the lint stage of the
main image, which is what CI runs, as well as Dockerfile.lint. Putting
it in one Dockerfile is how the other path silently loses it.
2026-08-10 14:05:54 +00:00
12 changed files with 541 additions and 721 deletions
+24 -63
View File
@@ -1,75 +1,37 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match
# semantics plus a `**` extension, compiled to a regexp. Plain
# filepath.Match has no `**` at all. What follows from that: `*` does not
# cross `/`, and a pattern without a leading `**/` is anchored at the
# build-context root. Every depth-independent pattern therefore needs the
# `**/` prefix — without it `config/.env` and `certs/server.key` still
# ship while the file reads as solved.
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while the file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Root-anchored entries are for paths that occur exactly once, at the
# context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context. 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 secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive
# filesystems most laptops use. Adding an ALL-CAPS twin per pattern is
# 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.
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# Repository metadata: exactly one, at the context root. Excluding it
# means `git describe` cannot run in any build stage, and it fails
# quietly there rather than erroring, so a version embedded that way
# comes out empty. Compute the version on the host and pass it in with
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
# Excluding .git means `git describe` cannot run in any build stage and
# fails quietly there; pass the version in with --build-arg VERSION.
.git
# In-repo agent scratch: a directory holding a full additional checkout
# of the repo for each in-flight agent. Anchored because it occurs
# exactly once *where agents run at the repo root*, which is the
# convention this file assumes; the `**/` form would also match any
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# 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][rR][cC]
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
@@ -86,8 +48,7 @@
**/.DS_Store
**/Thumbs.db
# Editor state. Never a build input, and it churns under a developer's
# hands, so it invalidates COPY for reasons unrelated to the source.
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
+13 -13
View File
@@ -3,26 +3,26 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
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.
# Makes script/lint run the linter directly rather than building
# Dockerfile.lint, which would need a docker daemon here.
ENV LINT_IN_CONTAINER=1
# 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 . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# script/docker. Without it an unchanged tree serves this layer from
# CHECK_EPOCH is a per-invocation nonce from script/cibuild and
# script/docker; without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
# so declare it in every stage that runs checks. The guard fails a bare
# `docker build .`, which would otherwise reuse the empty (and therefore
# stable) cache key. The value is also expanded into the check command,
# so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG; keep both references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
+27
View File
@@ -0,0 +1,27 @@
# Lint-only image, built by script/lint when it is not already inside a
# container. Linting is a build step, so a successful build is a clean
# lint, and nothing is bind-mounted, which matters when the daemon is
# remote.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
# Makes script/lint run the linter directly instead of recursing into
# another docker build, which has no daemon here.
ENV LINT_IN_CONTAINER=1
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# ARG sits after the dependency layer so that layer stays cached and
# only the lint re-runs. The guard fails a bare `docker build
# -f Dockerfile.lint .`, which would otherwise reuse the empty (stable)
# cache key and report a lint it never ran.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint
+3 -1
View File
@@ -117,7 +117,9 @@ alpine. We provide:
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files with prettier
- `script/lint` — lint the markdown files with prettier. Inside a container
(`LINT_IN_CONTAINER=1`, set by both Dockerfiles) it runs prettier directly; on
a host it builds `Dockerfile.lint` so the linter still runs in a container
- `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
+21
View File
@@ -21,6 +21,27 @@ fmt-check, and commit.
# Completed Steps
- 2026-08-10: Moved every lint run into a container. `script/lint` now runs the
linter directly when `LINT_IN_CONTAINER=1` and otherwise builds
`Dockerfile.lint`, so the linter never runs on a developer host — closing the
content-keyed result cache that produced a confirmed false green, the
host-global `$TMPDIR/golangci-lint.lock`, and host/container version skew.
Detection is on that marker alone: a false negative inside a container fails
loudly on the missing daemon, while a false positive on a host would silently
restore host linting, so `/.dockerenv` is rejected outright — measured absent
inside BuildKit `RUN` steps and present on hosts that are themselves
containers. Everything else keeps its existing shape: `make check` still runs
in the image, `script/cibuild` is still one build, and the Go multistage lint
stage survives with `ENV LINT_IN_CONTAINER=1`. `Dockerfile.lint` carries the
same `CHECK_EPOCH` guard, with the `ARG` below the dependency layer so only
the lint re-runs. The `script/bootstrap` golangci-lint install and the
per-checkout cache/lock/`.lint-cache` wrapper are deleted as superseded; a JS
repo's `yarn install` stays, since the rule is about where a verdict comes
from, not about which binaries exist. `golangci-lint config verify` was kept
on measurement: a bogus config key passes `golangci-lint run` with `0 issues`
and fails `config verify`, and every case reproduced byte-identically under
`--network none`, so the schema is embedded and the line costs no network.
Comment blocks across the touched files were cut hard in the same pass.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two
+15 -6
View File
@@ -1,6 +1,6 @@
---
title: Code Styleguide — Go
last_modified: 2026-08-09
last_modified: 2026-08-10
---
1. Try to hard wrap long lines at 77 characters or less.
@@ -111,7 +111,11 @@ last_modified: 2026-08-09
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
`golangci-lint`.
`golangci-lint`. Run it with `make lint`, never by invoking the binary: the
linter always runs in a container, and `golangci-lint` is not installed on
the host by any repo. Invoked directly on a shared host it reads a result
cache keyed on file content rather than location, and a host-global lock, so
its answer may belong to another checkout entirely.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and
linting. `script/cibuild` and `script/docker` should always make sure that
@@ -144,10 +148,15 @@ last_modified: 2026-08-09
1. Keep the `main()` function as small as possible.
1. Keep the `main` package as small as possible. Move as much code as is
feasible to a library package, even if it's an internal one. `main` is just
an entrypoint to your code, not a place for implementations. Exception:
single-file scripts.
1. Keep the `main` package as small as possible. Each `cmd/<name>/` directory
contains a single `main.go` whose body is one call into library code (for
example `os.Exit(cli.Main())` calling `internal/cli`). All CLI logic — flag
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
handle HTTP requests. Don't use methods or your top level functions as
+36 -11
View File
@@ -1,6 +1,6 @@
---
title: Existing Repo Checklist
last_modified: 2026-08-09
last_modified: 2026-08-10
---
Use this checklist when beginning work in a repo that may not yet conform to our
@@ -42,6 +42,15 @@ with your task.
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- [ ] **Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`** — the lint
stage and the build stage both. This is the item an existing repo most
often fails after adopting the containerised lint: without it
`script/lint` tries to build `Dockerfile.lint` from inside a build step,
where there is no daemon.
- [ ] `Dockerfile.lint` exists and `script/lint` builds it when not already in a
container — see the containerised-lint rule in `REPO_POLICIES.md`. Base
image pinned by sha256 with a version/date comment, `ARG CHECK_EPOCH`
**after** the dependency layer with the guard below it.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
@@ -100,15 +109,28 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] Go: `script/lint` isolates golangci-lint per checkout —
`GOLANGCI_LINT_CACHE` and `TMPDIR` both exported into `.lint-cache/`
(which is in `.gitignore` and `.dockerignore`), `--allow-serial-runners`
passed, and the lock error retried rather than reported as findings. Copy
the canonical block from `REPO_POLICIES.md`. Setting only the cache is the
common half-fix and leaves `parallel golangci-lint is running` failing
runs red.
- [ ] `script/lint` is the canonical detect-and-branch form, and no host
invocation anywhere in the repo can produce a lint **verdict** — grep for
the linter's own name across `script/`, the `Makefile` and CI config, not
just `script/lint`. A second path is likeliest here: a `make lint-fast`,
an older container-versus-host branch, or a CI step calling the binary
directly. **Expected hits that are not the defect**: `script/fmt`, and in
a repo whose formatter is also its linter, `script/fmt-check`. Everything
else the grep finds is a real second path and goes.
- [ ] Detection is on `LINT_IN_CONTAINER` alone. Reject any `/.dockerenv` or
cgroup heuristic: absent in BuildKit `RUN` steps, present on hosts that
are themselves containers, and a false positive lints on the host.
- [ ] `script/bootstrap` installs no golangci-lint. Delete the block, its
version and ref variables, and its call site. A JS repo's `yarn install`
stays — it brings a linter along with every other dependency, which is
fine as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`.
- [ ] `make check` does not modify any files in the repo
- [ ] `make 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
check)
- [ ] `make check` passes on current branch
@@ -153,6 +175,9 @@ with your task.
# Final
- [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design,
on the `CHECK_EPOCH` guard)
- [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
both times, never `CACHED` and never sub-second
- [ ] `script/cibuild` succeeds (a bare `docker build .` or
`docker build -f Dockerfile.lint .` fails closed by design, on the
`CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task
+32 -16
View File
@@ -1,6 +1,6 @@
---
title: New Repo Checklist
last_modified: 2026-08-09
last_modified: 2026-08-10
---
Use this checklist when creating a new repository from scratch. Follow the steps
@@ -79,9 +79,21 @@ Template files can be fetched from:
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it
never ran.
- Every stage that runs checks sets `ENV LINT_IN_CONTAINER=1`, so
`script/lint` runs the linter natively instead of trying to build
`Dockerfile.lint` where there is no daemon.
- Go repos: separate `lint` stage on the `golangci/golangci-lint` image,
with `COPY --from=lint /src/go.sum /dev/null` in the build stage to force
the ordering. Re-prove that ordering warm after adopting `CHECK_EPOCH`.
- Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check`
- Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image `script/lint` builds when it is
not already inside a container. Sets `ENV LINT_IN_CONTAINER=1`; same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` **after** the dependency layer so only the lint re-runs. Base image
pinned by sha256 with a version/date comment. Copy from
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -106,20 +118,17 @@ are thin shims calling them. Model scripts:
installs
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout)
- [ ] `script/lint` / `make lint` — runs linter
- [ ] Go: exports `GOLANGCI_LINT_CACHE` **and** `TMPDIR` into a
`.lint-cache/` directory inside the checkout, above any
container-versus-host branch so every path that reaches the linter
gets them; passes `--allow-serial-runners` (never
`--allow-parallel-runners`); retries on
`parallel golangci-lint is running` detected on **stderr** and exits
75 with a VOID message on exhaustion. Copy the canonical block from
`REPO_POLICIES.md` rather than writing your own: a version that sets
only the cache leaves the false-red half live, and one that detects
the collision by exit status can retry a real finding away.
- [ ] Go: `.lint-cache/` is in both `.gitignore` and `.dockerignore`
- [ ] `script/test` / `make test` — runs real tests, not a no-op (90-second
timeout, 60-second hard cap on wall time)
- [ ] `script/lint` / `make lint` — runs the linter directly when
`LINT_IN_CONTAINER=1`, otherwise `epoch="$(date +%s%N)$$"` on its own line
then `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`.
No lint verdict may come from a host invocation. Copy from
`REPO_POLICIES.md`. Detect on `LINT_IN_CONTAINER` only — never
`/.dockerenv`, which is absent in BuildKit `RUN` steps and present on
hosts that are themselves containers. Without the nonce this exits 0 on an
unchanged tree having linted nothing; without `-f Dockerfile.lint` it
builds the main image and lints nothing at all.
- [ ] `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
@@ -149,7 +158,10 @@ are thin shims calling them. Model scripts:
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.
element is load-bearing. A bare `docker build .` fails closed by design, and
so does a bare `docker build -f Dockerfile.lint .`. The image runs
`make check`, which includes lint, so `script/cibuild` needs no separate
lint step.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
@@ -161,7 +173,11 @@ are thin shims calling them. Model scripts:
# 4. Verify
- [ ] `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
- [ ] `script/cibuild` succeeds and demonstrably executes
- [ ] No secrets in repo
- [ ] No mutable image/package references
- [ ] No unnecessary files in repo root
+331 -582
View File
File diff suppressed because it is too large Load Diff
+9 -14
View File
@@ -1,27 +1,22 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but
# that only proves anything because CHECK_EPOCH is a fresh nonce on every
# invocation: without it Docker serves the check layer from cache on an
# unchanged tree and the build exits 0 without running the suite.
# invocation: without it Docker serves the check layer from cache and the
# build exits 0 without running the suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
# Both assignments 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. `$$` because busybox `date`
# drops %N without erroring. VERSION is computed here because
# .dockerignore excludes .git, so `git describe` in a build stage
# yields an empty version without failing; the guard below is the
# single place the fallback is applied.
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)"
[ -n "$version" ] || version="unknown"
docker build \
+6 -12
View File
@@ -11,19 +11,13 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring.
# Both assignments 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. `$$` because busybox `date`
# drops %N without erroring. VERSION is computed here because
# .dockerignore excludes .git, so `git describe` in a build stage
# yields an empty version without failing.
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)"
[ -n "$version" ] || version="unknown"
docker build \
+24 -3
View File
@@ -1,13 +1,34 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter. Inside a container, run it directly;
# on a host, build Dockerfile.lint so it runs in one anyway. The linter
# is never run on a developer host, where a shared result cache, a
# host-global lock and a stale toolchain make its answer untrustworthy.
#
# LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY
# accepted signal. Do not add a /.dockerenv fallback: it is absent
# inside BuildKit RUN steps and present on hosts that are themselves
# containers, so it both misses and false-positives — and a false
# positive silently restores host linting.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Linting markdown files..."
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then
exec yarn run prettier --check '**/*.md' \
--tab-width 4 --prose-wrap always
fi
# Own line, and `$$` because busybox `date` drops %N silently.
# Without a fresh nonce the lint layer is cached and this exits 0
# having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
}
main "$@"