Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the script/ entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.
Reference implementation is sneak/homoicon — copy its shape: a root Dockerfile.lint built FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runs golangci-lint run --config .golangci.yml ./... as a build step, with script/lint reduced to building it. Linting as a build step means a successful build IS a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible.
Note this repo's Dockerfile carries a memlock ulimit for memguard; make sure the lint image does not need it and that nothing about the split changes the main build's behaviour.
Two things to get right, both of which would otherwise ship a false green:
A cached build lints nothing.docker build -f Dockerfile.lint . on an unchanged tree returns success in well under a second having run no linter. Caching is explicitly waived here, so force the lint layers to execute.
golangci-lint config verify fetches its JSON schema over an unpinned live HTTPS call. Decide deliberately whether to include it.
Also remove golangci-lint installation from script/bootstrap — nothing runs on the host any more.
Definition of done
script/lint runs the linter only in Docker; no host golangci-lint path remains.
Two consecutive script/lint runs on an unchanged tree both demonstrably execute the linter.
Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean.
Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the `script/` entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.
Reference implementation is `sneak/homoicon` — copy its shape: a root `Dockerfile.lint` built `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, which COPYs the repo in and runs `golangci-lint run --config .golangci.yml ./...` as a build step, with `script/lint` reduced to building it. Linting as a build step means a successful build IS a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible.
Note this repo's Dockerfile carries a memlock ulimit for memguard; make sure the lint image does not need it and that nothing about the split changes the main build's behaviour.
Two things to get right, both of which would otherwise ship a false green:
1. **A cached build lints nothing.** `docker build -f Dockerfile.lint .` on an unchanged tree returns success in well under a second having run no linter. Caching is explicitly waived here, so force the lint layers to execute.
2. **`golangci-lint config verify` fetches its JSON schema over an unpinned live HTTPS call.** Decide deliberately whether to include it.
Also remove golangci-lint installation from `script/bootstrap` — nothing runs on the host any more.
## Definition of done
- `script/lint` runs the linter only in Docker; no host golangci-lint path remains.
- Two consecutive `script/lint` runs on an unchanged tree both demonstrably execute the linter.
- Negative control: introduce a deliberate lint violation, confirm it fails with that specific finding, revert, confirm clean.
- `make check` still green.
Canonical tracking issue: https://git.eeqj.de/sneak/prompts/issues/40
New Dockerfile.lint, two stages off the pinned golangci/golangci-lint:v2.12.2@sha256:5ccee... digest: deps (COPY go.mod/go.sum, go mod download) and lint (COPY the tree, golangci-lint run --config .golangci.yml ./...). Splitting the stages lets the module download stay cached while the lint stage is force-invalidated.
script/lint becomes docker build --progress=plain --no-cache-filter=lint -f Dockerfile.lint .. --no-cache-filter targets the named lint stage, so the linter re-executes on every run on an unchanged tree; --progress=plain keeps the linter's output visible. No host golangci-lint path remains.
Main Dockerfile: its lint stage currently runs make fmt-check / make lint. make lint would now recurse into a docker build inside a build, so that stage invokes gofmt and golangci-lint directly instead (same shape as the reference repo's Dockerfile). Stage ordering, base image digests, the builder/runtime stages and the memlock ulimit in script/cibuild are untouched, so script/cibuild behaviour is unchanged and CI keeps linting.
script/bootstrap: drop the golangci-lint install block; update the README Entrypoints section accordingly.
golangci-lint config verify: leaning toward excluding it — it fetches its JSON schema over an unpinned live HTTPS call, which conflicts with the repo's hash-pinning policy and makes lint fail on any network outage. Final decision and rationale go in the PR body.
Verification for the PR body: two consecutive script/lint runs on an unchanged tree, both showing the linter executing; a negative control (deliberate violation -> failure naming that finding -> revert -> clean); confirmation that the lint image needs no memlock ulimit; script/cibuild.
TODO.md gets one additive entry in the same commit.
Plan:
1. New `Dockerfile.lint`, two stages off the pinned `golangci/golangci-lint:v2.12.2@sha256:5ccee...` digest: `deps` (COPY `go.mod`/`go.sum`, `go mod download`) and `lint` (COPY the tree, `golangci-lint run --config .golangci.yml ./...`). Splitting the stages lets the module download stay cached while the lint stage is force-invalidated.
2. `script/lint` becomes `docker build --progress=plain --no-cache-filter=lint -f Dockerfile.lint .`. `--no-cache-filter` targets the named `lint` stage, so the linter re-executes on every run on an unchanged tree; `--progress=plain` keeps the linter's output visible. No host `golangci-lint` path remains.
3. Main `Dockerfile`: its lint stage currently runs `make fmt-check` / `make lint`. `make lint` would now recurse into a `docker build` inside a build, so that stage invokes `gofmt` and `golangci-lint` directly instead (same shape as the reference repo's `Dockerfile`). Stage ordering, base image digests, the builder/runtime stages and the memlock ulimit in `script/cibuild` are untouched, so `script/cibuild` behaviour is unchanged and CI keeps linting.
4. `script/bootstrap`: drop the `golangci-lint` install block; update the README Entrypoints section accordingly.
5. `golangci-lint config verify`: leaning toward excluding it — it fetches its JSON schema over an unpinned live HTTPS call, which conflicts with the repo's hash-pinning policy and makes lint fail on any network outage. Final decision and rationale go in the PR body.
6. Verification for the PR body: two consecutive `script/lint` runs on an unchanged tree, both showing the linter executing; a negative control (deliberate violation -> failure naming that finding -> revert -> clean); confirmation that the lint image needs no memlock ulimit; `script/cibuild`.
7. `TODO.md` gets one additive entry in the same commit.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the
script/entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.Reference implementation is
sneak/homoicon— copy its shape: a rootDockerfile.lintbuiltFROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runsgolangci-lint run --config .golangci.yml ./...as a build step, withscript/lintreduced to building it. Linting as a build step means a successful build IS a clean lint, and it works even where the docker daemon is remote and bind mounts are impossible.Note this repo's Dockerfile carries a memlock ulimit for memguard; make sure the lint image does not need it and that nothing about the split changes the main build's behaviour.
Two things to get right, both of which would otherwise ship a false green:
docker build -f Dockerfile.lint .on an unchanged tree returns success in well under a second having run no linter. Caching is explicitly waived here, so force the lint layers to execute.golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call. Decide deliberately whether to include it.Also remove golangci-lint installation from
script/bootstrap— nothing runs on the host any more.Definition of done
script/lintruns the linter only in Docker; no host golangci-lint path remains.script/lintruns on an unchanged tree both demonstrably execute the linter.make checkstill green.Canonical tracking issue: sneak/prompts#40
Plan:
Dockerfile.lint, two stages off the pinnedgolangci/golangci-lint:v2.12.2@sha256:5ccee...digest:deps(COPYgo.mod/go.sum,go mod download) andlint(COPY the tree,golangci-lint run --config .golangci.yml ./...). Splitting the stages lets the module download stay cached while the lint stage is force-invalidated.script/lintbecomesdocker build --progress=plain --no-cache-filter=lint -f Dockerfile.lint ..--no-cache-filtertargets the namedlintstage, so the linter re-executes on every run on an unchanged tree;--progress=plainkeeps the linter's output visible. No hostgolangci-lintpath remains.Dockerfile: its lint stage currently runsmake fmt-check/make lint.make lintwould now recurse into adocker buildinside a build, so that stage invokesgofmtandgolangci-lintdirectly instead (same shape as the reference repo'sDockerfile). Stage ordering, base image digests, the builder/runtime stages and the memlock ulimit inscript/cibuildare untouched, soscript/cibuildbehaviour is unchanged and CI keeps linting.script/bootstrap: drop thegolangci-lintinstall block; update the README Entrypoints section accordingly.golangci-lint config verify: leaning toward excluding it — it fetches its JSON schema over an unpinned live HTTPS call, which conflicts with the repo's hash-pinning policy and makes lint fail on any network outage. Final decision and rationale go in the PR body.script/lintruns on an unchanged tree, both showing the linter executing; a negative control (deliberate violation -> failure naming that finding -> revert -> clean); confirmation that the lint image needs no memlock ulimit;script/cibuild.TODO.mdgets one additive entry in the same commit.