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, which already does exactly this — copy its shape.
Dockerfile.lint at the repo root:
FROMgolangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240WORKDIR/srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN golangci-lint run --config .golangci.yml ./...
script/lint becomes a thin wrapper that builds 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.
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. Since caching is explicitly waived here, force the lint layers to execute.
golangci-lint config verify fetches its JSON schema over an unpinned live HTTPS call. If you include that step it makes linting network-dependent and breaks hash-pinning. Decide deliberately.
Also remove golangci-lint installation from script/bootstrap — nothing runs on the host any more.
This is directly relevant here: several PRs in this repo rested on host lint results, and the host linter in this repo has differed from the pinned one.
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`, which already does exactly this — copy its shape.
`Dockerfile.lint` at the repo root:
```dockerfile
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
```
`script/lint` becomes a thin wrapper that builds 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.
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. Since caching is explicitly waived here, force the lint layers to execute.
2. **`golangci-lint config verify` fetches its JSON schema over an unpinned live HTTPS call.** If you include that step it makes linting network-dependent and breaks hash-pinning. Decide deliberately.
Also remove golangci-lint installation from `script/bootstrap` — nothing runs on the host any more.
This is directly relevant here: several PRs in this repo rested on host lint results, and the host linter in this repo has differed from the pinned one.
## 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
Dockerfile.lint at repo root, two stages off the pinned digest golangci/golangci-lint:v2.12.2@sha256:5cceeef0...: a deps stage (go.mod/go.sum + go mod download, cacheable) and a lint stage (COPY . ., then golangci-lint run --config .golangci.yml ./...).
script/lint becomes a POSIX-sh wrapper: docker build -f Dockerfile.lint --no-cache-filter=lint --progress=plain .. --no-cache-filter=lint is what defeats trap 1 — the lint stage's layers are rebuilt every invocation while the module download stays cached, so the linter genuinely executes each run. --progress=plain keeps the linter's own output visible on success, which is the evidence that it ran. Cache invalidation is scoped to this one stage; no prune, ever.
golangci-lint config verify will be deliberately omitted, unlike the sneak/homoicon reference. It fetches its JSON schema over an unpinned live HTTPS call, which violates the hash-pinning rule and makes linting fail without network. golangci-lint run already rejects a malformed or unknown-key config; I will demonstrate that empirically rather than assert it. The reason goes in a comment in Dockerfile.lint so nobody adds the step back.
The main Dockerfile lint stage currently does RUN make lint, which would recurse into docker-in-docker once script/lint is a wrapper. It is already inside the pinned linter image, so it invokes golangci-lint run directly instead. script/cibuild and the check workflow are otherwise untouched.
golangci-lint installation comes out of script/bootstrap (pinned version constants, release-archive download, sha256 constants, ensure_golangci_lint). README prerequisites and command docs updated to match.
Acceptance evidence for the PR body: negative control (inject a violation, confirm RED naming that specific finding, revert, confirm clean); two consecutive runs on an unchanged tree both showing real linter output; make check green with no golangci-lint on PATH.
Plan:
1. `Dockerfile.lint` at repo root, two stages off the pinned digest `golangci/golangci-lint:v2.12.2@sha256:5cceeef0...`: a `deps` stage (`go.mod`/`go.sum` + `go mod download`, cacheable) and a `lint` stage (`COPY . .`, then `golangci-lint run --config .golangci.yml ./...`).
2. `script/lint` becomes a POSIX-sh wrapper: `docker build -f Dockerfile.lint --no-cache-filter=lint --progress=plain .`. `--no-cache-filter=lint` is what defeats trap 1 — the lint stage's layers are rebuilt every invocation while the module download stays cached, so the linter genuinely executes each run. `--progress=plain` keeps the linter's own output visible on success, which is the evidence that it ran. Cache invalidation is scoped to this one stage; no prune, ever.
3. `golangci-lint config verify` will be deliberately omitted, unlike the `sneak/homoicon` reference. It fetches its JSON schema over an unpinned live HTTPS call, which violates the hash-pinning rule and makes linting fail without network. `golangci-lint run` already rejects a malformed or unknown-key config; I will demonstrate that empirically rather than assert it. The reason goes in a comment in `Dockerfile.lint` so nobody adds the step back.
4. The main `Dockerfile` lint stage currently does `RUN make lint`, which would recurse into docker-in-docker once `script/lint` is a wrapper. It is already inside the pinned linter image, so it invokes `golangci-lint run` directly instead. `script/cibuild` and the `check` workflow are otherwise untouched.
5. golangci-lint installation comes out of `script/bootstrap` (pinned version constants, release-archive download, sha256 constants, `ensure_golangci_lint`). README prerequisites and command docs updated to match.
Acceptance evidence for the PR body: negative control (inject a violation, confirm RED naming that specific finding, revert, confirm clean); two consecutive runs on an unchanged tree both showing real linter output; `make check` green with no `golangci-lint` on `PATH`.
Implemented in #165 (branch issue-109-docker-lint, commit 46248a2). Full evidence is in the PR body; the DoD items and one reversal:
script/lint runs the linter only in Docker; golangci-lint installation removed from script/bootstrap, no host path remains.
Two consecutive runs on an unchanged tree each executed the linter (~60s, 0 issues. printed both times), because the wrapper passes --no-cache-filter=lint. Without that flag the same build exits 0 in 0.27s with the lint layer CACHED — that contrast is in the PR body.
Negative control: an added exported var produced internal/globals/globals.go:17:5: exported: ... (revive), 1 issues:, exit 1; reverted, 0 issues., exit 0.
make check exits 0 with no golangci-lint anywhere on PATH (the host has three copies; the run used a stripped PATH excluding all of them). Full image build with --no-cache-filter=lint --no-cache-filter=builder also exits 0 in 3m10s with zero (cached) test lines.
Reversal on point 2 of the issue: I intended to drop golangci-lint config verify and testing changed the decision. golangci-lint run silently ignores unrecognized config keys — a bogus top-level key and a bogus key nested under run: both gave 0 issues. and exit 0 — so verify is the only thing catching a typo that disables a setting. And in the pinned image verify needs no network at all: with --network none it still detected the bogus key (rc=3). So it is kept, and both lint steps use RUN --network=none, which enforces the absence of a live fetch rather than trusting it. That also makes the whole analysis phase provably network-free.
This supersedes #106 — that issue's option 2, and structurally: findings are reported relative to the repo root inside a container holding only this repo, so the ../other-worktree/... contamination cannot occur rather than being filtered after the fact.
Implemented in https://git.eeqj.de/sneak/webhooker/pulls/165 (branch `issue-109-docker-lint`, commit `46248a2`). Full evidence is in the PR body; the DoD items and one reversal:
- `script/lint` runs the linter only in Docker; golangci-lint installation removed from `script/bootstrap`, no host path remains.
- Two consecutive runs on an unchanged tree each executed the linter (~60s, `0 issues.` printed both times), because the wrapper passes `--no-cache-filter=lint`. Without that flag the same build exits 0 in 0.27s with the lint layer `CACHED` — that contrast is in the PR body.
- Negative control: an added exported var produced `internal/globals/globals.go:17:5: exported: ... (revive)`, `1 issues:`, exit 1; reverted, `0 issues.`, exit 0.
- `make check` exits 0 with no `golangci-lint` anywhere on `PATH` (the host has three copies; the run used a stripped `PATH` excluding all of them). Full image build with `--no-cache-filter=lint --no-cache-filter=builder` also exits 0 in 3m10s with zero `(cached)` test lines.
Reversal on point 2 of the issue: I intended to drop `golangci-lint config verify` and testing changed the decision. `golangci-lint run` silently ignores unrecognized config keys — a bogus top-level key and a bogus key nested under `run:` both gave `0 issues.` and exit 0 — so verify is the only thing catching a typo that disables a setting. And in the pinned image verify needs no network at all: with `--network none` it still detected the bogus key (`rc=3`). So it is kept, and both lint steps use `RUN --network=none`, which enforces the absence of a live fetch rather than trusting it. That also makes the whole analysis phase provably network-free.
This supersedes https://git.eeqj.de/sneak/webhooker/issues/106 — that issue's option 2, and structurally: findings are reported relative to the repo root inside a container holding only this repo, so the `../other-worktree/...` contamination cannot occur rather than being filtered after the fact.
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, which already does exactly this — copy its shape.Dockerfile.lintat the repo root:script/lintbecomes a thin wrapper that builds 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.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. Since caching is explicitly waived here, force the lint layers to execute.golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call. If you include that step it makes linting network-dependent and breaks hash-pinning. Decide deliberately.Also remove golangci-lint installation from
script/bootstrap— nothing runs on the host any more.This is directly relevant here: several PRs in this repo rested on host lint results, and the host linter in this repo has differed from the pinned one.
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.lintat repo root, two stages off the pinned digestgolangci/golangci-lint:v2.12.2@sha256:5cceeef0...: adepsstage (go.mod/go.sum+go mod download, cacheable) and alintstage (COPY . ., thengolangci-lint run --config .golangci.yml ./...).script/lintbecomes a POSIX-sh wrapper:docker build -f Dockerfile.lint --no-cache-filter=lint --progress=plain ..--no-cache-filter=lintis what defeats trap 1 — the lint stage's layers are rebuilt every invocation while the module download stays cached, so the linter genuinely executes each run.--progress=plainkeeps the linter's own output visible on success, which is the evidence that it ran. Cache invalidation is scoped to this one stage; no prune, ever.golangci-lint config verifywill be deliberately omitted, unlike thesneak/homoiconreference. It fetches its JSON schema over an unpinned live HTTPS call, which violates the hash-pinning rule and makes linting fail without network.golangci-lint runalready rejects a malformed or unknown-key config; I will demonstrate that empirically rather than assert it. The reason goes in a comment inDockerfile.lintso nobody adds the step back.Dockerfilelint stage currently doesRUN make lint, which would recurse into docker-in-docker oncescript/lintis a wrapper. It is already inside the pinned linter image, so it invokesgolangci-lint rundirectly instead.script/cibuildand thecheckworkflow are otherwise untouched.script/bootstrap(pinned version constants, release-archive download, sha256 constants,ensure_golangci_lint). README prerequisites and command docs updated to match.Acceptance evidence for the PR body: negative control (inject a violation, confirm RED naming that specific finding, revert, confirm clean); two consecutive runs on an unchanged tree both showing real linter output;
make checkgreen with nogolangci-lintonPATH.Implemented in #165 (branch
issue-109-docker-lint, commit46248a2). Full evidence is in the PR body; the DoD items and one reversal:script/lintruns the linter only in Docker; golangci-lint installation removed fromscript/bootstrap, no host path remains.0 issues.printed both times), because the wrapper passes--no-cache-filter=lint. Without that flag the same build exits 0 in 0.27s with the lint layerCACHED— that contrast is in the PR body.internal/globals/globals.go:17:5: exported: ... (revive),1 issues:, exit 1; reverted,0 issues., exit 0.make checkexits 0 with nogolangci-lintanywhere onPATH(the host has three copies; the run used a strippedPATHexcluding all of them). Full image build with--no-cache-filter=lint --no-cache-filter=builderalso exits 0 in 3m10s with zero(cached)test lines.Reversal on point 2 of the issue: I intended to drop
golangci-lint config verifyand testing changed the decision.golangci-lint runsilently ignores unrecognized config keys — a bogus top-level key and a bogus key nested underrun:both gave0 issues.and exit 0 — so verify is the only thing catching a typo that disables a setting. And in the pinned image verify needs no network at all: with--network noneit still detected the bogus key (rc=3). So it is kept, and both lint steps useRUN --network=none, which enforces the absence of a live fetch rather than trusting it. That also makes the whole analysis phase provably network-free.This supersedes #106 — that issue's option 2, and structurally: findings are reported relative to the repo root inside a container holding only this repo, so the
../other-worktree/...contamination cannot occur rather than being filtered after the fact.clawbot referenced this issue2026-08-17 23:50:11 +02:00