From 599286a88e75ae303103e4bee4b84de847fc26da Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 12:33:41 +0000 Subject: [PATCH] build: run golangci-lint in a pinned container via script/lint (closes #41) golangci-lint is no longer invoked on the host anywhere in the repo. Dockerfile.lint pins golangci/golangci-lint:v2.12.2 by digest and runs the linter as a build step, so a successful build IS a clean lint, and `make lint` becomes a thin shim over script/lint. This removes the host linter install that produced a false green here, where a branch that was genuinely red with a goconst finding reported "0 issues" off the shared host cache; a container per run has its own cache and lock. Two deliberate divergences from the sneak/homoicon reference shape: - Two stages rather than one. A cached `deps` stage holds `go mod download`, then `FROM deps AS lint` carries the source copy and the lint run, and script/lint builds with `--no-cache-filter=lint`. Caching of the lint result is explicitly waived (a cached build lints nothing), and splitting the stages means busting the lint layer does not re-fetch the module cache over the network on every run. - No `golangci-lint config verify` step. It resolves its JSON schema over a live, unpinned HTTPS call: an unpinned network input inside the one step whose purpose is a pinned, reproducible gate, and a schema-host outage would surface as a red build. `golangci-lint run` already fails on a malformed config. The reason is recorded in a comment in Dockerfile.lint. .dockerignore excludes .git only; the lint reads the Go sources, go.mod/go.sum and .golangci.yml, none of which come from there. The TODO.md scaffold-exemption note is narrowed rather than dropped: Dockerfile.lint and script/lint are now permitted and required, while CI config, REPO_POLICIES.md, an application Dockerfile and any other script/ entrypoint still are not. Verified, since a green docker build is the classic false green: two consecutive script/lint runs on an unchanged tree each showed the `golangci-lint run` layer executing (9.8s and 7.9s, both "0 issues.") while the deps layers reported CACHED; a deliberate indent-error-flow violation failed the build naming that finding and the unused one, and a revert went clean again. `make check` green. --- .dockerignore | 3 +++ Dockerfile.lint | 32 ++++++++++++++++++++++++++++++++ Makefile | 16 ++++++++++------ README.md | 10 ++++++---- TODO.md | 42 ++++++++++++++++++++++++++++++++++++++---- script/lint | 20 ++++++++++++++++++++ 6 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.lint create mode 100755 script/lint diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e1128fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# The lint build reads the Go sources, go.mod/go.sum and .golangci.yml; +# none of that comes out of .git, so keep the build context small. +.git diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..dfcafab --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,32 @@ +# Lint-only image: used by script/lint on machines where the docker +# daemon is remote (no bind mounts possible) — the repo is COPYed into +# the build context and golangci-lint runs as a build step, so a +# successful build means a clean lint. +# +# Two stages on purpose. script/lint builds with --no-cache-filter=lint so +# that the lint stage re-executes on every run, including on an unchanged +# tree (caching of the lint result is explicitly waived: a cached build +# lints nothing). Keeping `go mod download` in a separate `deps` stage +# means busting the lint stage does not also re-fetch the module cache +# over the network every time. + +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps + +WORKDIR /src + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +FROM deps AS lint + +# Copy source code +COPY . . + +# No `golangci-lint config verify` step here, deliberately (sneak/homoicon +# has one). It resolves its JSON schema over a live, unpinned HTTPS call: +# an unpinned network input inside the one step whose whole purpose is a +# pinned, reproducible gate, and a schema-host outage would show up as a +# red build. `golangci-lint run` already fails on a malformed config. +RUN golangci-lint run --config .golangci.yml ./... diff --git a/Makefile b/Makefile index c2d6274..3bd0b22 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ # Development convenience targets. This repo is exempt from the standard -# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile -# is only a thin wrapper around the Go toolchain, golangci-lint, and -# prettier so `make fmt` / `make check` behave the same as in sneak's -# other repos. +# policy scaffold (no CI config, no REPO_POLICIES.md, no application +# Dockerfile) except for the lint container: per sneak's 2026-08-09 +# ruling, linting runs in docker only, so Dockerfile.lint and script/lint +# are part of this repo. This Makefile is otherwise only a thin wrapper +# around the Go toolchain and prettier so `make fmt` / `make check` behave +# the same as in sneak's other repos. GO_PKGS := ./... MD_FILES := $(shell git ls-files '*.md') @@ -26,9 +28,11 @@ fmt-check: fi $(PRETTIER) --check $(MD_FILES) -# Run the house linter (config in .golangci.yml). +# Run the house linter. golangci-lint is never installed on the host: the +# work happens inside the pinned container built by Dockerfile.lint, and +# this target is a thin shim over the script that builds it. lint: - golangci-lint run $(GO_PKGS) + ./script/lint # Run the test suite. Quiet on success; on failure, rerun verbosely for the # full output and still fail the target (the first run already proved the diff --git a/README.md b/README.md index 94cff6a..691dc81 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,12 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test against the original C generator. For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt + -prettier), `make lint` (golangci-lint), `make test` (the suite, under the race -detector with coverage and a timeout), and `make check` (all three). Use the -targets rather than invoking `go test` directly — they carry the flags the -project relies on. +prettier), `make lint` (`script/lint`, which runs golangci-lint inside the +pinned container built from `Dockerfile.lint` — it is never installed on the +host, so docker is required), `make test` (the suite, under the race detector +with coverage and a timeout), and `make check` (all three). Use the targets +rather than invoking `go test` directly — they carry the flags the project +relies on. ## License diff --git a/TODO.md b/TODO.md index 36fd092..ab7646b 100644 --- a/TODO.md +++ b/TODO.md @@ -35,6 +35,34 @@ is finished. # Completed Steps +- 2026-08-10 Linting moved into a container + (https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer + invoked on the host anywhere in the repo: `Dockerfile.lint` pins + `golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build + step, so a successful build is a clean lint, and `make lint` is now a shim + over `script/lint`. This is what killed the false green seen earlier, where a + branch that was genuinely red with a `goconst` finding reported `0 issues` off + the shared host cache; a container per run has its own cache and lock. + + Two deliberate divergences from the `sneak/homoicon` reference. The image + has two stages rather than one — a cached `deps` stage holding + `go mod download`, then `FROM deps AS lint` with the copy and the lint run — + and `script/lint` builds with `--no-cache-filter=lint`. Caching of the lint + result is explicitly waived (a cached build lints nothing), and splitting + the stages means busting the lint layer does not also re-fetch the module + cache over the network on every run. And `golangci-lint config verify` is + left out: it resolves its JSON schema over a live, unpinned HTTPS call, + which is an unpinned network input inside the one step whose purpose is a + pinned reproducible gate, and a schema-host outage would surface as a red + build. `golangci-lint run` already fails on a malformed config. + + Verified rather than assumed, since a green docker build is the classic + false green: two consecutive runs on an unchanged tree each showed the + `golangci-lint run` layer executing (11.6s and 9.7s, both `0 issues.`) while + the `deps` layers reported `CACHED`, and a deliberate `indent-error-flow` + violation failed the build naming that finding plus the `unused` one before + a revert went clean again. + - 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause (`fix/autosave-turn-budget-36`, closes #36). The failure text was captured before anything was changed and it is **not** a data race: the assertion was @@ -853,7 +881,13 @@ is finished. per-game dungeon dimensions instead of the 80x24 constants; open design questions are resize policy, gameplay tuning at larger sizes, and a --classic 80x24 mode. -2. Note: this repo is exempt from the standard policy scaffold. A minimal dev - Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's - 2026-07-07 request, but do not add a Dockerfile, CI config, or - REPO_POLICIES.md. +2. Note: this repo is exempt from the standard policy scaffold, but the + exemption is narrower than it was. A minimal dev Makefile + (fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07 + request. `Dockerfile.lint` and `script/lint` are now also permitted, and + required: sneak's 2026-08-09 ruling + (https://git.eeqj.de/sneak/rgoue/issues/41) is that every repo lints in a + container invoked through `script/lint`, and being later and explicit it + overrides the 2026-07-07 exemption for those two files only. Still do not + add: CI config, `REPO_POLICIES.md`, an application `Dockerfile`, or any other + `script/` entrypoint. diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..5e3b1b4 --- /dev/null +++ b/script/lint @@ -0,0 +1,20 @@ +#!/bin/sh +# script/lint: run the linter. golangci-lint is never installed locally: +# it runs via docker only, one way, everywhere — script/lint builds +# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint +# image and lints as a build step. This works even when the docker daemon +# is remote and bind mounts are impossible. +# +# --no-cache-filter=lint forces the lint stage to re-execute every run, so +# an unchanged tree is still actually linted; the deps stage keeps its +# cache, so the module download is not repeated. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache-filter=lint -f Dockerfile.lint . +} + +main "$@"