From 599286a88e75ae303103e4bee4b84de847fc26da Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 12:33:41 +0000 Subject: [PATCH 1/4] 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 "$@" -- 2.49.1 From 329c03f06e019a65eaaa85c29d4d0a4389b6305d Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 12:55:40 +0000 Subject: [PATCH 2/4] build: make the lint cache-busting self-validating in script/lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--no-cache-filter=lint` is silently ignored by BuildKit when no stage matches the name, so the entire anti-false-green mechanism hung on one unvalidated magic string: renaming or mistyping the `lint` stage would have left the lint layer served from cache and `script/lint` reporting green having linted nothing. Reproduced here — with the filter pointed at a nonexistent stage and no `--target`, an unchanged tree built with `RUN golangci-lint run ... CACHED` and exited 0. `--target lint` closes it: a stage name that does not exist now fails loudly (`target stage "nosuchstage" could not be found`, exit 1) instead of passing. The two flags name the same stage from the same string and validate each other; both the script and the stage definition in Dockerfile.lint carry a comment saying they must be kept in sync. `--output=type=cacheonly` drops the image export. Nothing consumes the image — the deliverable of this build is an exit code — and the export cost seconds per run and left one dangling image behind every time, on a host where pruning is prohibited. The lint stage still executes and a lint failure still exits non-zero, both verified rather than assumed. TODO.md: the 2026-08-07 entry's claim that the repo has no linter pin and lints on the host is marked superseded in place rather than rewritten; the narrowed scaffold exemption now names `.dockerignore` alongside `Dockerfile.lint` and `script/lint`; and the specific wall-clock timings are replaced by the durable property they were evidence for, since they vary per host and per run. --- Dockerfile.lint | 2 ++ TODO.md | 38 ++++++++++++++++++++++++++++---------- script/lint | 20 +++++++++++++++++++- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/Dockerfile.lint b/Dockerfile.lint index dfcafab..443f421 100644 --- a/Dockerfile.lint +++ b/Dockerfile.lint @@ -19,6 +19,8 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download +# Do not rename this stage without changing script/lint in the same commit: +# it passes both --target lint and --no-cache-filter=lint by this name. FROM deps AS lint # Copy source code diff --git a/TODO.md b/TODO.md index ab7646b..7e104e0 100644 --- a/TODO.md +++ b/TODO.md @@ -58,10 +58,24 @@ is finished. 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` + `golangci-lint run` layer executing and reporting `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. + a revert went clean again. The durable property to check when touching any + of this is that the lint stage executes on every run and is never served + from cache; wall-clock durations vary per host and per run, so they are not + recorded here. + + Hardened 2026-08-10 after review. `script/lint` now also passes + `--target lint` and `--output=type=cacheonly`. `--target` is what makes + `--no-cache-filter=lint` trustworthy: BuildKit silently ignores the filter + when no stage matches the name, so a rename or typo of the `lint` stage + would have left the lint layer cached and `script/lint` green having linted + nothing — the same false green in a new place. `--target` fails loudly on a + name that does not exist, so the two flags validate each other and must be + kept in sync. `--output=type=cacheonly` skips the image export: nothing + consumes the image (the deliverable is an exit code), and exporting it cost + seconds per run and left a dangling image behind each time. - 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause (`fix/autosave-turn-budget-36`, closes #36). The failure text was captured @@ -733,7 +747,11 @@ is finished. 24 long lines wrapped or their comments tightened, control bytes in `term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The repo has no golangci-lint version pin to bump (no Dockerfile or CI; - `make lint` runs whatever `golangci-lint` is on the host). + `make lint` runs whatever `golangci-lint` is on the host). Superseded + 2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins + the linter image by digest and `script/lint` runs it in a container. See the + 2026-08-10 entry at the top of this section + (https://git.eeqj.de/sneak/rgoue/issues/41). - 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that @@ -885,9 +903,9 @@ is finished. 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. + required, along with the `.dockerignore` that scopes their build context: + 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 three + 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 index 5e3b1b4..b0ad2cf 100755 --- a/script/lint +++ b/script/lint @@ -8,13 +8,31 @@ # --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. +# +# --target lint and --no-cache-filter=lint must BOTH be present, and both +# must keep naming the stage that Dockerfile.lint calls `lint`. Do not +# "simplify" either one away. BuildKit silently ignores --no-cache-filter +# when no stage matches the name: rename or typo the stage and the filter +# becomes a no-op, the lint layer is served from cache, and script/lint +# reports green having linted nothing — the exact false green this whole +# setup exists to prevent. --target fails loudly on a name that does not +# exist, so the two flags validate each other's magic string. +# +# --output=type=cacheonly skips the image export. Nothing consumes the +# image — the deliverable of this build is an exit code — and exporting it +# costs seconds per run and leaves a dangling image behind every time. The +# lint stage still executes and a lint failure still exits non-zero. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache-filter=lint -f Dockerfile.lint . + docker build \ + --target lint \ + --no-cache-filter=lint \ + --output=type=cacheonly \ + -f Dockerfile.lint . } main "$@" -- 2.49.1 From 20cfb47912fa07056177019fb9788295ac213004 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 13:11:42 +0000 Subject: [PATCH 3/4] build: define the lint stage name once so the two flags cannot diverge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit claimed --target and --no-cache-filter "validate each other's magic string". They do not. --target validates only its own argument; a typo confined to --no-cache-filter left the build green and linting nothing: docker build --target lint --no-cache-filter=lnit ... #10 [lint 2/2] RUN golangci-lint run ... CACHED exit 0 Three of the four edit paths were caught and one was not, so the original false green survived in the narrow case. The duplication was the defect: the stage name appeared twice on one command line and nothing tied the copies together. Correcting only the prose would have left the hazard live and merely warned about, so the name is now written once, as `stage=lint`, and passed to both flags. Divergence is unrepresentable rather than documented — there is a single name to get wrong, and --target rejects it loudly when it is not a stage in Dockerfile.lint, which now covers the filter too because it is the same string. The comments in script/lint and Dockerfile.lint and the TODO.md entry drop the false "validate each other" claim and state the real property, along with the residual hazard that is genuinely unguarded: --target checks that the name exists, not that it names the stage which actually runs golangci-lint, and it stops the build there, so relocating the lint step or appending a stage after it would go unnoticed. --- Dockerfile.lint | 9 +++++++-- TODO.md | 34 ++++++++++++++++++++++++---------- script/lint | 39 ++++++++++++++++++++++++++------------- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Dockerfile.lint b/Dockerfile.lint index 443f421..28febae 100644 --- a/Dockerfile.lint +++ b/Dockerfile.lint @@ -19,8 +19,13 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -# Do not rename this stage without changing script/lint in the same commit: -# it passes both --target lint and --no-cache-filter=lint by this name. +# This stage must stay the one that runs golangci-lint, and its name must +# match $stage in script/lint, which passes that single name to both +# --target and --no-cache-filter. Renaming here without updating script/lint +# fails the build loudly (--target rejects a name that is not in this file), +# so a mismatch cannot pass silently — but moving the lint step to another +# stage, or adding a stage after this one, would not be caught. Change the +# two files together. FROM deps AS lint # Copy source code diff --git a/TODO.md b/TODO.md index 7e104e0..b979a44 100644 --- a/TODO.md +++ b/TODO.md @@ -66,16 +66,30 @@ is finished. from cache; wall-clock durations vary per host and per run, so they are not recorded here. - Hardened 2026-08-10 after review. `script/lint` now also passes - `--target lint` and `--output=type=cacheonly`. `--target` is what makes - `--no-cache-filter=lint` trustworthy: BuildKit silently ignores the filter - when no stage matches the name, so a rename or typo of the `lint` stage - would have left the lint layer cached and `script/lint` green having linted - nothing — the same false green in a new place. `--target` fails loudly on a - name that does not exist, so the two flags validate each other and must be - kept in sync. `--output=type=cacheonly` skips the image export: nothing - consumes the image (the deliverable is an exit code), and exporting it cost - seconds per run and left a dangling image behind each time. + Hardened 2026-08-10 after review. `script/lint` now also passes `--target` + and `--output=type=cacheonly`. `--target` is what makes `--no-cache-filter` + trustworthy: BuildKit silently ignores the filter when no stage matches its + argument, so a rename or typo of the `lint` stage would have left the lint + layer cached and `script/lint` green having linted nothing — the same false + green in a new place. `--target` fails loudly on a name that is not in the + file. `--output=type=cacheonly` skips the image export: nothing consumes the + image (the deliverable is an exit code), and exporting it cost seconds per + run and left a dangling image behind each time. + + Corrected 2026-08-10 after a second review, which was right to reject the + claim first made here that the two flags "validate each other". They did + not: `--target` validates only its own argument, so a typo confined to + `--no-cache-filter` still built `CACHED` at exit 0 — the original defect, + surviving in the narrow case. The duplicated stage name was the defect, so + it is now written once, as `stage=lint` in `script/lint`, and passed to both + flags. The true property is that there is only one name to get wrong, and + `--target` rejects it loudly if it is not a stage in `Dockerfile.lint`, so a + typo or a stale rename is a hard error rather than a silent skip. What + remains on the editor, and is not checked by anything: `$stage` must name + the stage that actually runs `golangci-lint`. `--target` verifies the name + exists, not that it is the right stage, and it stops the build there — so + moving the lint step to another stage, or adding a stage after it, would go + unnoticed. - 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause (`fix/autosave-turn-budget-36`, closes #36). The failure text was captured diff --git a/script/lint b/script/lint index b0ad2cf..94d31bf 100755 --- a/script/lint +++ b/script/lint @@ -5,18 +5,28 @@ # 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. +# --no-cache-filter 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. Both flags below must stay: do +# not "simplify" either one away. # -# --target lint and --no-cache-filter=lint must BOTH be present, and both -# must keep naming the stage that Dockerfile.lint calls `lint`. Do not -# "simplify" either one away. BuildKit silently ignores --no-cache-filter -# when no stage matches the name: rename or typo the stage and the filter -# becomes a no-op, the lint layer is served from cache, and script/lint -# reports green having linted nothing — the exact false green this whole -# setup exists to prevent. --target fails loudly on a name that does not -# exist, so the two flags validate each other's magic string. +# The stage name is written ONCE, in $stage, and passed to both --target +# and --no-cache-filter, so the two flags cannot come to name different +# stages. That is the whole point of the variable. BuildKit silently +# ignores --no-cache-filter when no stage matches its argument: a filter +# naming a stage that does not exist is a no-op, the lint layer is served +# from cache, and script/lint reports green having linted nothing — the +# false green this setup exists to prevent. --target, by contrast, fails +# loudly on a name that is not in the file. With a single shared name, a +# typo or a stale rename therefore becomes a hard error instead of a +# silent skip, because the one name reaches both flags. +# +# What the tooling does NOT check, and is left to whoever edits this: +# $stage must name the stage in Dockerfile.lint that actually runs +# golangci-lint. --target verifies that the name exists, not that it is +# the right stage, and it stops the build at that stage — so moving the +# lint step into a different stage, or adding a stage after this one, +# would not be caught here. Keep this file and Dockerfile.lint in sync. # # --output=type=cacheonly skips the image export. Nothing consumes the # image — the deliverable of this build is an exit code — and exporting it @@ -26,11 +36,14 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the stage name in Dockerfile.lint. +stage=lint + main() { cd "$ROOT" docker build \ - --target lint \ - --no-cache-filter=lint \ + --target "$stage" \ + --no-cache-filter="$stage" \ --output=type=cacheonly \ -f Dockerfile.lint . } -- 2.49.1 From 3eb9f81fc4737b9f572d196e9cea5e9098ed83b2 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 13:29:44 +0000 Subject: [PATCH 4/4] docs: name the two required flags, and record .dockerignore as part of the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments and documentation only; the docker build invocation and its flags are untouched, and .dockerignore's effective rules are unchanged. Two prose gaps from review. First, "both flags below must stay" had lost its anchor: it ended a paragraph naming only --no-cache-filter, --target was not introduced until the next one, and three flags follow on the command, so a reader could pick the wrong pair. It now names --target and --no-cache-filter explicitly. Second, the list of things the tooling does not check covered the $stage seam but not .dockerignore, which sits in the same trust boundary and is the more likely thing to be edited — the first review on this change actively suggested extending it for build artifacts. Only what reaches the container is linted, so excluding a Go source there removes it from the lint with no warning. Verified rather than asserted: a planted violation plus that one path in .dockerignore yields `0 issues.` at exit 0 with the violation still in the working tree, while excluding a file other code still references fails loudly on `undefined:` typecheck errors instead. The warning is recorded in script/lint alongside the $stage seam and in .dockerignore itself, where the edit would actually be made. --- .dockerignore | 6 ++++++ TODO.md | 7 ++++++- script/lint | 28 +++++++++++++++++++++------- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index e1128fa..8df76b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,9 @@ # 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. +# +# This file is part of the lint gate, not housekeeping: only what reaches +# the container gets linted, so excluding a Go source here silently drops +# it from the lint (a self-contained file yields `0 issues.` at exit 0 with +# the violation still in the tree; it fails loudly only if other code still +# references it). Never exclude Go sources, go.mod/go.sum or .golangci.yml. .git diff --git a/TODO.md b/TODO.md index b979a44..f14deb8 100644 --- a/TODO.md +++ b/TODO.md @@ -89,7 +89,12 @@ is finished. the stage that actually runs `golangci-lint`. `--target` verifies the name exists, not that it is the right stage, and it stops the build there — so moving the lint step to another stage, or adding a stage after it, would go - unnoticed. + unnoticed. The same applies to `.dockerignore`, which is part of this gate + rather than housekeeping: only what reaches the container is linted, so + excluding a Go source there drops it from the lint silently — verified, a + planted violation plus that one path in `.dockerignore` gives `0 issues.` at + exit 0 with the violation still in the tree, and it fails loudly only when + other code still references the excluded file. - 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause (`fix/autosave-turn-budget-36`, closes #36). The failure text was captured diff --git a/script/lint b/script/lint index 94d31bf..a4b837c 100755 --- a/script/lint +++ b/script/lint @@ -7,8 +7,9 @@ # # --no-cache-filter 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. Both flags below must stay: do -# not "simplify" either one away. +# so the module download is not repeated. It and --target must both stay, +# for the reason in the next paragraph: do not "simplify" either of those +# two away. # # The stage name is written ONCE, in $stage, and passed to both --target # and --no-cache-filter, so the two flags cannot come to name different @@ -22,11 +23,24 @@ # silent skip, because the one name reaches both flags. # # What the tooling does NOT check, and is left to whoever edits this: -# $stage must name the stage in Dockerfile.lint that actually runs -# golangci-lint. --target verifies that the name exists, not that it is -# the right stage, and it stops the build at that stage — so moving the -# lint step into a different stage, or adding a stage after this one, -# would not be caught here. Keep this file and Dockerfile.lint in sync. +# +# 1. $stage must name the stage in Dockerfile.lint that actually runs +# golangci-lint. --target verifies that the name exists, not that it is +# the right stage, and it stops the build at that stage — so moving the +# lint step into a different stage, or adding a stage after this one, +# would not be caught here. Keep this file and Dockerfile.lint in sync. +# +# 2. .dockerignore decides what reaches the container, and only what +# reaches it gets linted. Excluding a Go file there removes it from the +# lint with no warning: verified by planting a real violation and adding +# just that file's path to .dockerignore, which produced `0 issues.` at +# exit 0 with the violation still sitting in the working tree. It shows +# up only if the rest of the package still references the excluded file, +# in which case the build fails loudly on `undefined:` typecheck errors; +# a self-contained file drops out silently. So .dockerignore is part of +# this gate, not housekeeping — keep it to build inputs the lint does +# not read, and never exclude Go sources, go.mod/go.sum or +# .golangci.yml. # # --output=type=cacheonly skips the image export. Nothing consumes the # image — the deliverable of this build is an exit code — and exporting it -- 2.49.1