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

Open
opened 2026-08-10 13:14:06 +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, 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.

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. ## 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 plan

Copying the shape of sneak/homoicon's root Dockerfile.lint and script/lint.

1. Dockerfile.lint (new, repo root) — two stages so the forced re-run stays cheap:

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

Digest verified before use: docker buildx imagetools inspect golangci/golangci-lint:v2.12.2 reports exactly that index digest, and golangci-lint --version inside the pinned image reports 2.12.2 ... from c0d3ddc9, matching the org's canonical pin c0d3ddc9cf3faa61a4e378e879ece580256d76e5.

2. script/lint — thin wrapper, docker only, no host code path:

docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint .

--no-cache-filter=lint forces the source copy and the linter run to execute on every invocation while leaving the deps stage (base image + go mod download) cached, so the "cached build lints nothing" trap cannot fire. --progress=plain makes the linter's real output visible. No global cache invalidation is used.

3. golangci-lint config verify — deliberately NOT included. It fetches its JSON schema over a live unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning.

4. script/bootstrap — drop the golangci-lint go install and its pinned ref; KEEP the goimports install, since script/fmt and script/fmt-check still run on the host. Header comment updated, plus a non-fatal warning when docker is absent.

5. Root Dockerfile — required consequence, not scope creep: its builder stage currently runs make check, which after this change calls script/lint, which shells out to docker build. There is no docker daemon inside a docker build, so script/cibuild and script/docker would break. Restructured to the policy/homoicon shape: a dedicated lint stage on the pinned golangci-lint image invoking the linter directly, the builder stage depending on it via COPY --from=lint /src/go.sum /dev/null and running make fmt-check, make test, make build. The now-unneeded golangci-lint install is removed from the builder stage.

6. Docs — README Entrypoints and Building sections updated to describe linting as a docker-only operation; TODO.md gets a Completed Steps entry in the same commit.

Verification I will show: two consecutive script/lint runs on an unchanged tree with the linter's output present in both; a negative control that introduces a deliberate violation, fails naming that finding, then reverts clean; and make check green end to end. All via make / script/ entrypoints only.

Landing as a single commit on the long-lived next branch.

## Implementation plan Copying the shape of `sneak/homoicon`'s root `Dockerfile.lint` and `script/lint`. **1. `Dockerfile.lint` (new, repo root)** — two stages so the forced re-run stays cheap: - `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef...ad5240 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 ./...`. Digest verified before use: `docker buildx imagetools inspect golangci/golangci-lint:v2.12.2` reports exactly that index digest, and `golangci-lint --version` inside the pinned image reports `2.12.2 ... from c0d3ddc9`, matching the org's canonical pin `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. **2. `script/lint`** — thin wrapper, docker only, no host code path: ``` docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint . ``` `--no-cache-filter=lint` forces the source copy and the linter run to execute on every invocation while leaving the `deps` stage (base image + `go mod download`) cached, so the "cached build lints nothing" trap cannot fire. `--progress=plain` makes the linter's real output visible. No global cache invalidation is used. **3. `golangci-lint config verify`** — deliberately NOT included. It fetches its JSON schema over a live unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. **4. `script/bootstrap`** — drop the `golangci-lint` `go install` and its pinned ref; KEEP the `goimports` install, since `script/fmt` and `script/fmt-check` still run on the host. Header comment updated, plus a non-fatal warning when `docker` is absent. **5. Root `Dockerfile`** — required consequence, not scope creep: its builder stage currently runs `make check`, which after this change calls `script/lint`, which shells out to `docker build`. There is no docker daemon inside a docker build, so `script/cibuild` and `script/docker` would break. Restructured to the policy/homoicon shape: a dedicated lint stage on the pinned `golangci-lint` image invoking the linter directly, the builder stage depending on it via `COPY --from=lint /src/go.sum /dev/null` and running `make fmt-check`, `make test`, `make build`. The now-unneeded `golangci-lint` install is removed from the builder stage. **6. Docs** — README `Entrypoints` and `Building` sections updated to describe linting as a docker-only operation; `TODO.md` gets a Completed Steps entry in the same commit. **Verification I will show:** two consecutive `script/lint` runs on an unchanged tree with the linter's output present in both; a negative control that introduces a deliberate violation, fails naming that finding, then reverts clean; and `make check` green end to end. All via `make` / `script/` entrypoints only. Landing as a single commit on the long-lived `next` branch.
Author
Collaborator

Implemented and pushed as a single commit on next (cc86473). PR: #136

What landed

  • Dockerfile.lint (new, repo root) on golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240. Split into a deps stage (base image + go mod download) and a lint stage (COPY . . + golangci-lint run --config .golangci.yml ./...).
  • script/lint reduced to a thin wrapper. No host golangci-lint path remains anywhere in the repo.
  • script/bootstrap: golangci-lint install and its pinned ref removed; header comment rewritten; non-fatal warning when docker is absent. goimports install kept, since script/fmt and script/fmt-check still run it on the host.
  • Root Dockerfile: its builder stage ran make check, which after this change calls script/lint, which shells out to docker build — and there is no docker daemon inside a docker build, so script/cibuild and script/docker would have broken. It now has its own lint stage on the same pinned image with the linter invoked directly, the builder depending on it via COPY --from=lint /src/go.sum /dev/null and running make fmt-check, make test, make build. The now-unneeded golangci-lint install is gone from the builder.
  • README Entrypoints and Building updated; TODO.md entry in the same commit.

Digest verification

The quoted digest resolves and is genuinely v2.12.2:

$ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2
Digest:    sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240

$ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version
golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z

Commit c0d3ddc9 matches the org's canonical pin c0d3ddc9cf3faa61a4e378e879ece580256d76e5.

Trap 1: cached build lints nothing

script/lint passes --no-cache-filter=lint --target lint. The invalidation is scoped to the lint stage only — no global cache wipe, deps stays cached. --progress=plain keeps the linter's output visible.

Two consecutive make lint runs on an unchanged tree, both executing the linter:

# run 1
#10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
#10 10.98 0 issues.
#10 DONE 12.0s

# run 2, tree untouched
#10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
#10 14.63 0 issues.

A third run shows the scoping working — deps from cache, lint re-executed:

#6 [deps 2/4] WORKDIR /src
#6 CACHED
#7 [deps 3/4] COPY go.mod go.sum ./
#7 CACHED
#8 [deps 4/4] RUN go mod download
#8 CACHED
#9 [lint 1/2] COPY . .
#10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...

Trap 2: config verify

Deliberately NOT included, for exactly the reason you flagged: it fetches its JSON schema over a live unpinned HTTPS call, making linting network-dependent and defeating hash-pinning. The reason is recorded in a comment at the top of Dockerfile.lint so it does not get re-added by accident.

Trap 3: .dockerignore

Read and checked. It excludes .git/, bin/, *.md, LICENSE, .editorconfig, .gitignore — none of which the lint build needs. .golangci.yml and the Go tree are present in the context; #6 transferring context: 275.12kB and the linter reporting real findings in the negative control confirm it is not linting an empty tree.

Negative control

Added an unused function containing an ineffectual assignment to internal/config/config.go:

#10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign)
#10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused)
#10 11.26 2 issues:
#10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
make: *** [Makefile:26: lint] Error 1

make lint exited non-zero naming both findings at their exact lines. File reverted; make lint clean again (0 issues.).

Other verification

  • make check green end to end (test, lint, fmt-check), exit 0.
  • script/cibuild green, confirming the Dockerfile restructure does not recurse: lint stage ran (#16 12.56 0 issues.), then #22 [builder 8/9] RUN make test with PASS lines, then #23 [builder 9/9] RUN make build.
  • All invocations through make / script/ entrypoints only.
Implemented and pushed as a single commit on `next` (`cc86473`). PR: https://git.eeqj.de/sneak/dnswatcher/pulls/136 ## What landed - **`Dockerfile.lint`** (new, repo root) on `golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`. Split into a `deps` stage (base image + `go mod download`) and a `lint` stage (`COPY . .` + `golangci-lint run --config .golangci.yml ./...`). - **`script/lint`** reduced to a thin wrapper. No host golangci-lint path remains anywhere in the repo. - **`script/bootstrap`**: golangci-lint install and its pinned ref removed; header comment rewritten; non-fatal warning when `docker` is absent. `goimports` install kept, since `script/fmt` and `script/fmt-check` still run it on the host. - **Root `Dockerfile`**: its builder stage ran `make check`, which after this change calls `script/lint`, which shells out to `docker build` — and there is no docker daemon inside a docker build, so `script/cibuild` and `script/docker` would have broken. It now has its own lint stage on the same pinned image with the linter invoked directly, the builder depending on it via `COPY --from=lint /src/go.sum /dev/null` and running `make fmt-check`, `make test`, `make build`. The now-unneeded golangci-lint install is gone from the builder. - **README** `Entrypoints` and `Building` updated; `TODO.md` entry in the same commit. ## Digest verification The quoted digest resolves and is genuinely v2.12.2: ``` $ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2 Digest: sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 $ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z ``` Commit `c0d3ddc9` matches the org's canonical pin `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. ## Trap 1: cached build lints nothing `script/lint` passes `--no-cache-filter=lint --target lint`. The invalidation is scoped to the `lint` stage only — no global cache wipe, `deps` stays cached. `--progress=plain` keeps the linter's output visible. Two consecutive `make lint` runs on an unchanged tree, both executing the linter: ``` # run 1 #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 10.98 0 issues. #10 DONE 12.0s # run 2, tree untouched #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 14.63 0 issues. ``` A third run shows the scoping working — `deps` from cache, `lint` re-executed: ``` #6 [deps 2/4] WORKDIR /src #6 CACHED #7 [deps 3/4] COPY go.mod go.sum ./ #7 CACHED #8 [deps 4/4] RUN go mod download #8 CACHED #9 [lint 1/2] COPY . . #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... ``` ## Trap 2: `config verify` Deliberately NOT included, for exactly the reason you flagged: it fetches its JSON schema over a live unpinned HTTPS call, making linting network-dependent and defeating hash-pinning. The reason is recorded in a comment at the top of `Dockerfile.lint` so it does not get re-added by accident. ## Trap 3: `.dockerignore` Read and checked. It excludes `.git/`, `bin/`, `*.md`, `LICENSE`, `.editorconfig`, `.gitignore` — none of which the lint build needs. `.golangci.yml` and the Go tree are present in the context; `#6 transferring context: 275.12kB` and the linter reporting real findings in the negative control confirm it is not linting an empty tree. ## Negative control Added an unused function containing an ineffectual assignment to `internal/config/config.go`: ``` #10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign) #10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused) #10 11.26 2 issues: #10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1 make: *** [Makefile:26: lint] Error 1 ``` `make lint` exited non-zero naming both findings at their exact lines. File reverted; `make lint` clean again (`0 issues.`). ## Other verification - `make check` green end to end (test, lint, fmt-check), exit 0. - `script/cibuild` green, confirming the `Dockerfile` restructure does not recurse: lint stage ran (`#16 12.56 0 issues.`), then `#22 [builder 8/9] RUN make test` with `PASS` lines, then `#23 [builder 9/9] RUN make build`. - All invocations through `make` / `script/` entrypoints only.
Author
Collaborator

[manager] Landed on next as cc86473, carried by #136. Independent review passed.

The one result worth recording, because the raw evidence looks like the opposite: script/cibuild on an unchanged tree returns exit 0 in 0.63s with all 18 layers CACHED, lint stage included — the signature of the false green tracked at #115. It is not that defect. The source COPY . . sits inside the lint stage's cache key, so any source change invalidates it; a violation planted from that same warm cache failed the build in 16.6s naming both findings. The gate is live.

Three comment-only defects found during review are split out to #137.

**[manager]** Landed on `next` as `cc86473`, carried by https://git.eeqj.de/sneak/dnswatcher/pulls/136. Independent review passed. The one result worth recording, because the raw evidence looks like the opposite: `script/cibuild` on an unchanged tree returns exit 0 in 0.63s with all 18 layers `CACHED`, lint stage included — the signature of the false green tracked at https://git.eeqj.de/sneak/dnswatcher/issues/115. It is not that defect. The source `COPY . .` sits inside the lint stage's cache key, so any source change invalidates it; a violation planted from that same warm cache failed the build in 16.6s naming both findings. The gate is live. Three comment-only defects found during review are split out to https://git.eeqj.de/sneak/dnswatcher/issues/137.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/dnswatcher#134