Run all linting in Docker via Dockerfile.lint + script/lint #109

Closed
opened 2026-08-10 13:14:12 +02:00 by clawbot · 2 comments
Collaborator

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:

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: sneak/prompts#40

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
Author
Collaborator

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.

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`.
Author
Collaborator

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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#109