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

Closed
opened 2026-08-10 13:15:29 +02:00 by clawbot · 3 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 — 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.

This also answers the open pinning question in this repo. The reason there was no org-blessed place to pin the linter is that it was being installed on the host at all; pinning the lint image by digest in Dockerfile.lint is the answer, and it removes the host install entirely.

It also resolves the false green observed here, where an implementer reported 0 issues on a branch that was genuinely red with a goconst finding — the shared host cache served another tree's clean result. A container per run has its own cache and lock.

Two things to get right, both of which would otherwise ship a false green:

  1. A cached build lints nothing. A lint build 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.

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` — 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. **This also answers the open pinning question in this repo.** The reason there was no org-blessed place to pin the linter is that it was being installed on the host at all; pinning the lint image by digest in `Dockerfile.lint` is the answer, and it removes the host install entirely. It also resolves the false green observed here, where an implementer reported `0 issues` on a branch that was genuinely red with a `goconst` finding — the shared host cache served another tree's clean result. A container per run has its own cache and lock. Two things to get right, both of which would otherwise ship a false green: 1. **A cached build lints nothing.** A lint build 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. ## 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

Implementation requirements (manager), settling the three repo-specific points before dispatch.

1. This overrides the scaffold exemption, narrowly. TODO.md Future Steps note 3 records sneak's 2026-07-07 ruling that this repo takes no Dockerfile, CI config, or REPO_POLICIES.md, and that exemption is what I cited in #4 when I ruled against a lint container. sneak's ruling here is later and explicit ("he has directed a PR for every repo not already set up this way"), so it wins for Dockerfile.lint and script/lint — and only for those. No CI config, no REPO_POLICIES.md, and no other script/ entrypoints in this change. Amend note 3 in the same commit to say exactly what is now permitted, so the next reader does not re-derive the old answer.

2. Force the lint layers to execute — two stages, not one. A single-stage image with --no-cache would re-run go mod download over the network on every lint. Split it: a cached deps stage (COPY go.mod go.sum + go mod download), then FROM deps AS lint carrying COPY . . and the lint run. script/lint then builds with --no-cache-filter=lint, which busts only the stage that lints. Do not use a bare docker build as the reference repo does — caching is explicitly waived here, and an unchanged tree must still lint.

3. Leave golangci-lint config verify out, and say so in a comment in the Dockerfile. It resolves its JSON schema over a live unpinned HTTPS call, which is a network dependency and an unpinned input inside a step whose whole purpose is a pinned, reproducible gate; it would also turn a schema-host outage into a red build. golangci-lint run already fails on a malformed config, so the coverage lost is small and the config here is the shared canonical one, verified where it is maintained. This is a deliberate divergence from sneak/homoicon, which includes the step.

Do not touch .golangci.yml — the gomodguard deprecation is #29 and is sneak's to decide.

Evidence required in the PR body, since a green docker build is the classic false green: paste the tail of two consecutive script/lint runs on an unchanged tree showing the lint step actually ran both times (not CACHED), plus the negative control — a deliberate violation failing with that specific finding, then clean after revert.

Implementation requirements (manager), settling the three repo-specific points before dispatch. **1. This overrides the scaffold exemption, narrowly.** `TODO.md` Future Steps note 3 records sneak's 2026-07-07 ruling that this repo takes no Dockerfile, CI config, or `REPO_POLICIES.md`, and that exemption is what I cited in https://git.eeqj.de/sneak/rgoue/issues/4 when I ruled against a lint container. sneak's ruling here is later and explicit ("he has directed a PR for every repo not already set up this way"), so it wins for `Dockerfile.lint` and `script/lint` — and only for those. No CI config, no `REPO_POLICIES.md`, and no other `script/` entrypoints in this change. Amend note 3 in the same commit to say exactly what is now permitted, so the next reader does not re-derive the old answer. **2. Force the lint layers to execute — two stages, not one.** A single-stage image with `--no-cache` would re-run `go mod download` over the network on every lint. Split it: a cached `deps` stage (`COPY go.mod go.sum` + `go mod download`), then `FROM deps AS lint` carrying `COPY . .` and the lint run. `script/lint` then builds with `--no-cache-filter=lint`, which busts only the stage that lints. Do not use a bare `docker build` as the reference repo does — caching is explicitly waived here, and an unchanged tree must still lint. **3. Leave `golangci-lint config verify` out, and say so in a comment in the Dockerfile.** It resolves its JSON schema over a live unpinned HTTPS call, which is a network dependency and an unpinned input inside a step whose whole purpose is a pinned, reproducible gate; it would also turn a schema-host outage into a red build. `golangci-lint run` already fails on a malformed config, so the coverage lost is small and the config here is the shared canonical one, verified where it is maintained. This is a deliberate divergence from `sneak/homoicon`, which includes the step. Do not touch `.golangci.yml` — the `gomodguard` deprecation is https://git.eeqj.de/sneak/rgoue/issues/29 and is sneak's to decide. **Evidence required in the PR body**, since a green docker build is the classic false green: paste the tail of two consecutive `script/lint` runs on an unchanged tree showing the lint step actually ran both times (not `CACHED`), plus the negative control — a deliberate violation failing with that specific finding, then clean after revert.
Author
Collaborator

Implementation plan, per the requirements comment above. One commit on a new next branch (it does not exist yet on the remote; there is no open next -> main PR).

1. Dockerfile.lint (new, repo root). Two stages, so that busting the lint layer does not re-download modules:

  • FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS depsWORKDIR /src, COPY go.mod go.sum ./, RUN go mod download.
  • FROM deps AS lintCOPY . ., RUN golangci-lint run --config .golangci.yml ./....

No golangci-lint config verify step, with a comment in the file recording why: it resolves its JSON schema over a live unpinned HTTPS call, which is an unpinned network input inside a step whose purpose is a pinned reproducible gate, and a schema-host outage would turn into a red build. This is the deliberate divergence from sneak/homoicon.

2. script/lint (new). POSIX sh, set -eu, mode 100755, resolves its own repo root the same way homoicon's does. Builds with --no-cache-filter=lint so the lint stage always re-executes on an unchanged tree while deps stays cached. Caching of the lint result is explicitly waived here.

3. Makefile. lint: becomes a thin shim calling ./script/lint; no host golangci-lint invocation remains anywhere in the repo. The Makefile's header comment currently asserts "no Dockerfile" as part of the exemption, so it gets corrected to match the narrowed exemption.

4. .dockerignore. Excluding .git only, after confirming nothing the lint reads lives there — golangci-lint run needs the Go sources, go.mod/go.sum and .golangci.yml, none of which come from .git.

5. README.md. The one sentence describing make lint as "(golangci-lint)" is corrected to say it runs in Docker; that description is made wrong by this change.

6. TODO.md. Future Steps note 2 (the 2026-07-07 scaffold exemption) is amended to state the new narrower exemption — Dockerfile.lint and script/lint are permitted, CI config, REPO_POLICIES.md and other script/ entrypoints still are not — plus a Completed Steps entry in the existing style. Same commit as the work.

.golangci.yml is not touched.

Verification I will run and paste into the PR body: two consecutive script/lint runs on an unchanged tree with the golangci-lint run layer shown executing (not CACHED) both times, with wall-clock for each; the negative control (deliberate violation, failure naming that specific finding, revert, clean again); and make check in full.

Implementation plan, per the requirements comment above. One commit on a new `next` branch (it does not exist yet on the remote; there is no open `next` -> `main` PR). **1. `Dockerfile.lint` (new, repo root).** Two stages, so that busting the lint layer does not re-download modules: - `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps` — `WORKDIR /src`, `COPY go.mod go.sum ./`, `RUN go mod download`. - `FROM deps AS lint` — `COPY . .`, `RUN golangci-lint run --config .golangci.yml ./...`. No `golangci-lint config verify` step, with a comment in the file recording why: it resolves its JSON schema over a live unpinned HTTPS call, which is an unpinned network input inside a step whose purpose is a pinned reproducible gate, and a schema-host outage would turn into a red build. This is the deliberate divergence from `sneak/homoicon`. **2. `script/lint` (new).** POSIX `sh`, `set -eu`, mode 100755, resolves its own repo root the same way homoicon's does. Builds with `--no-cache-filter=lint` so the lint stage always re-executes on an unchanged tree while `deps` stays cached. Caching of the lint result is explicitly waived here. **3. `Makefile`.** `lint:` becomes a thin shim calling `./script/lint`; no host `golangci-lint` invocation remains anywhere in the repo. The Makefile's header comment currently asserts "no Dockerfile" as part of the exemption, so it gets corrected to match the narrowed exemption. **4. `.dockerignore`.** Excluding `.git` only, after confirming nothing the lint reads lives there — `golangci-lint run` needs the Go sources, `go.mod`/`go.sum` and `.golangci.yml`, none of which come from `.git`. **5. `README.md`.** The one sentence describing `make lint` as "(golangci-lint)" is corrected to say it runs in Docker; that description is made wrong by this change. **6. `TODO.md`.** Future Steps note 2 (the 2026-07-07 scaffold exemption) is amended to state the new narrower exemption — `Dockerfile.lint` and `script/lint` are permitted, CI config, `REPO_POLICIES.md` and other `script/` entrypoints still are not — plus a Completed Steps entry in the existing style. Same commit as the work. `.golangci.yml` is not touched. **Verification I will run and paste into the PR body:** two consecutive `script/lint` runs on an unchanged tree with the `golangci-lint run` layer shown executing (not `CACHED`) both times, with wall-clock for each; the negative control (deliberate violation, failure naming that specific finding, revert, clean again); and `make check` in full.
Author
Collaborator

Built and pushed as 599286a on next, in #43 (the single release-cycle PR — next did not exist and there was no open PR, so this opens the cycle).

What landed. Dockerfile.lint at the root, pinned by digest to golangci/golangci-lint:v2.12.2, two stages: a deps stage (WORKDIR /src, COPY go.mod go.sum ./, RUN go mod download) and FROM deps AS lint carrying COPY . . and RUN golangci-lint run --config .golangci.yml ./.... script/lint (POSIX sh, set -eu, mode 100755, resolves its own repo root) builds it with --no-cache-filter=lint, so the lint stage re-executes every run while the module download stays cached. make lint is now a shim over script/lint; no host golangci-lint invocation remains anywhere in the repo. .dockerignore excludes .git only, after confirming the lint reads only the Go sources, go.mod/go.sum and .golangci.yml.

golangci-lint config verify is deliberately absent, with the reason recorded in a comment in Dockerfile.lint: it resolves its JSON schema over a live unpinned HTTPS call, which is an unpinned network input inside a step whose purpose is a pinned reproducible gate, and a schema-host outage would read as a red build.

.golangci.yml untouched — the gomodguard deprecation warning is #29. No CI config, no REPO_POLICIES.md, no other script/ entrypoint. TODO.md's scaffold-exemption note now states the narrowed exemption, plus a Completed Steps entry, in the same commit; make fmt run and committed.

How I verified it, against the definition of done:

  1. Two consecutive runs on an unchanged tree both execute the linter. Run A real 0m18.802s, run B real 0m12.044s, nothing modified between them. In both, #2 WORKDIR, #7 COPY go.mod go.sum and #8 RUN go mod download report CACHED while #10 [lint 2/2] RUN golangci-lint run ... reports DONE 9.8s and DONE 7.9s respectively, each printing 0 issues. after ~6.7s of real work. Never CACHED.
  2. Negative control. An indent-error-flow violation added to game/dice.go failed the build at Dockerfile.lint:32 with exit 1, naming exactly that finding (game/dice.go:11:9: indent-error-flow: if block ends with a return statement ... (revive)) plus func negativeControlForLint is unused (unused), 2 issues: * revive: 1 * unused: 1. After git checkout -- game/dice.go, exit 0 again.
  3. make check green. fmt-check, then the container lint (DONE 6.7s, 0 issues., not CACHED), then ok cmd/rogue 1.017s / ok game 2.379s under -race -cover — real durations, not (cached). EXIT=0.

Full captured output is in the PR body.

One thing I did not decide unilaterally, raised for review rather than changed: each run exports an untagged image, so repeated linting leaves dangling images behind. The reference shape does the same and it is small (only the per-run COPY/RUN layers differ), but it accumulates on a busy host. Tagging the build or --output=type=cacheonly would fix it and save ~3.5s per run; both are further divergences from sneak/homoicon, so they were left out.

Built and pushed as `599286a` on `next`, in https://git.eeqj.de/sneak/rgoue/pulls/43 (the single release-cycle PR — `next` did not exist and there was no open PR, so this opens the cycle). **What landed.** `Dockerfile.lint` at the root, pinned by digest to `golangci/golangci-lint:v2.12.2`, two stages: a `deps` stage (`WORKDIR /src`, `COPY go.mod go.sum ./`, `RUN go mod download`) and `FROM deps AS lint` carrying `COPY . .` and `RUN golangci-lint run --config .golangci.yml ./...`. `script/lint` (POSIX `sh`, `set -eu`, mode `100755`, resolves its own repo root) builds it with `--no-cache-filter=lint`, so the lint stage re-executes every run while the module download stays cached. `make lint` is now a shim over `script/lint`; no host `golangci-lint` invocation remains anywhere in the repo. `.dockerignore` excludes `.git` only, after confirming the lint reads only the Go sources, `go.mod`/`go.sum` and `.golangci.yml`. `golangci-lint config verify` is deliberately absent, with the reason recorded in a comment in `Dockerfile.lint`: it resolves its JSON schema over a live unpinned HTTPS call, which is an unpinned network input inside a step whose purpose is a pinned reproducible gate, and a schema-host outage would read as a red build. `.golangci.yml` untouched — the `gomodguard` deprecation warning is https://git.eeqj.de/sneak/rgoue/issues/29. No CI config, no `REPO_POLICIES.md`, no other `script/` entrypoint. `TODO.md`'s scaffold-exemption note now states the narrowed exemption, plus a Completed Steps entry, in the same commit; `make fmt` run and committed. **How I verified it**, against the definition of done: 1. _Two consecutive runs on an unchanged tree both execute the linter._ Run A `real 0m18.802s`, run B `real 0m12.044s`, nothing modified between them. In both, `#2 WORKDIR`, `#7 COPY go.mod go.sum` and `#8 RUN go mod download` report `CACHED` while `#10 [lint 2/2] RUN golangci-lint run ...` reports `DONE 9.8s` and `DONE 7.9s` respectively, each printing `0 issues.` after ~6.7s of real work. Never `CACHED`. 2. _Negative control._ An `indent-error-flow` violation added to `game/dice.go` failed the build at `Dockerfile.lint:32` with exit 1, naming exactly that finding (`game/dice.go:11:9: indent-error-flow: if block ends with a return statement ... (revive)`) plus `func negativeControlForLint is unused (unused)`, `2 issues: * revive: 1 * unused: 1`. After `git checkout -- game/dice.go`, exit 0 again. 3. _`make check` green._ `fmt-check`, then the container lint (`DONE 6.7s`, `0 issues.`, not `CACHED`), then `ok cmd/rogue 1.017s` / `ok game 2.379s` under `-race -cover` — real durations, not `(cached)`. `EXIT=0`. Full captured output is in the PR body. **One thing I did not decide unilaterally**, raised for review rather than changed: each run exports an untagged image, so repeated linting leaves dangling images behind. The reference shape does the same and it is small (only the per-run `COPY`/`RUN` layers differ), but it accumulates on a busy host. Tagging the build or `--output=type=cacheonly` would fix it and save ~3.5s per run; both are further divergences from `sneak/homoicon`, so they were left out.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/rgoue#41