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 supersedes the bootstrap version-verification work scoped here. That fix existed to make the host linter match the pin; with nothing running on the host, the whole problem — including the go install writing to $GOPATH/bin while command -v resolves PATH — disappears. Remove the golangci-lint install from script/bootstrap rather than hardening it.
It also resolves the cross-worktree contamination observed here, where a run reported ten findings against paths under a worktree that had already been deleted.
Preserve the repo's non-root Dockerfile quirk where it applies to the main image; the lint image is separate and does not need it.
Two things to get right:
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, so force the lint layers to execute.
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.
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 supersedes the bootstrap version-verification work scoped here.** That fix existed to make the host linter match the pin; with nothing running on the host, the whole problem — including the `go install` writing to `$GOPATH/bin` while `command -v` resolves `PATH` — disappears. Remove the golangci-lint install from `script/bootstrap` rather than hardening it.
It also resolves the cross-worktree contamination observed here, where a run reported ten findings against paths under a worktree that had already been deleted.
Preserve the repo's non-root Dockerfile quirk where it applies to the main image; the lint image is separate and does not need it.
Two things to get right:
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, 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
Implementation requirements (manager). This issue absorbs #45, which scoped the same work
before the owner ruling fixed the shape; #45's acceptance criteria are
folded in below and it is being closed as superseded.
Shape
New root Dockerfile.lint, copying sneak/homoicon's: FROM the
digest-pinned golangci/golangci-lint:v2.12.2@sha256:5ccee...5240
with the version/date comment REPO_POLICIES requires, COPY go.mod/go.sum, go mod download, COPY . ., then the linter as
a build step. A successful build is a clean lint.
script/lint reduced to building it. No host golangci-lint path
may remain anywhere.
script/bootstrap: delete the go install, the pin constants, the
version parser and verify_golangci_lint outright — do not harden
them. Keep the git/make/go presence checks and go mod download. Warn (do not fail) when docker is absent, as
homoicon does. Update the header comment so it no longer describes
an install that does not happen.
The two traps
A cached lint build lints nothing. This repo has already been
bitten twice (#32, #39). Caching is waived by
ruling, so force the lint layers to execute: an ARG referenced
inside each lint RUN (the value must appear in the expanded
command — BuildKit hashes that, not the declaration), with script/lint passing "$(date +%s)-$$". Homoicon has no such
guard; do not inherit that gap. Evidence required: two consecutive script/lint runs on an unchanged tree, both showing the linter
actually executing under BUILDKIT_PROGRESS=plain, with timings.
Nested Docker. Once script/lint shells out to docker build,
nothing inside an image build may call make lint or make check.
The main Dockerfile's lint stage currently runs both make fmt-check and make lint, and its build stage runs make check.
Fix by following homoicon: inside the image build, invoke the gates
directly rather than through the aggregate — the build stage runs
the test and fmt-check entrypoints, never script/check.
Main Dockerfile
Keep its lint stage, invoking golangci-lint directly instead of make lint, so script/docker and script/cibuild remain complete
gates and lint still fails the build before compilation starts.
Consequences to handle:
The linter digest now appears in two files. Two independent pins
with nothing syncing them is exactly what #42 made a build failure;
add one guard that fails when the FROM line in the Dockerfile
lint stage and the one in Dockerfile.lint differ (image and
digest). One guard, not a third copy of the pin.
script/verify-linter-pin loses its subject: no linter binary is
copied between stages and bootstrap pins nothing. Retire it and its
README entry, replaced by the guard above — record that reasoning in
the commit and in TODO.md, do not drop it silently.
The COPY --from=lint of the linter binary was the only edge
forcing BuildKit to finish linting before the build stage starts.
Removing it without replacing the edge ends fail-fast linting
silently and the build still exits 0. Keep an ordering edge
(homoicon uses COPY --from=lint /src/go.sum /dev/null) and prove
it holds.
ENV PATH=/home/builder/go/bin:$PATH and its comment exist because
bootstrap's go install landed there. Re-justify or remove.
Preserve the non-root quirk. The build stage must still chown
and drop to builder before the tests run: as root, TestScanHardlinkRunFailsTogether passes when it should fail.
Re-verify with --user 0:0and the Go test cache disabled — a
cached ok from the build-time run reads as a false pass.
CHECK_EPOCH stays on the remaining gate RUNs in both stages.
golangci-lint config verify
Homoicon runs it; it fetches its JSON schema over an unpinned live
HTTPS call. Ruling: include it, and state the network requirement in
the README entry. If it turns out to fail closed without network, say
so on this issue rather than working around it.
Definition of done
make lint works on a machine with Docker and no golangci-lint
installed — verify with the host binary masked off PATH, and say
how you masked it.
Two consecutive script/lint runs on an unchanged tree both
demonstrably execute the linter; timings reported. Do not weaken the
no-cache rule to hit a time budget; report the cost instead.
Negative control: plant a lint violation, confirm script/lint
fails with that specific finding and that script/docker fails at
the lint stage before the build stage starts, revert, confirm clean.
Drift guard: prove it fails when the two FROM lines disagree.
make check green; make docker green with every gate
demonstrably executing (no cached-gate green); --user 0:0 still
fails TestScanHardlinkRunFailsTogether with the test cache off.
README §Entrypoints updated: script/lint, script/bootstrap, the
retired script/verify-linter-pin, and the Docker requirement for
linting.
TODO.md entry at the top of Completed Steps, in the same commit.
Implementation requirements (manager). This issue absorbs
https://git.eeqj.de/sneak/sfdupes/issues/45, which scoped the same work
before the owner ruling fixed the shape; #45's acceptance criteria are
folded in below and it is being closed as superseded.
## Shape
1. New root `Dockerfile.lint`, copying `sneak/homoicon`'s: `FROM` the
digest-pinned `golangci/golangci-lint:v2.12.2@sha256:5ccee...5240`
with the version/date comment REPO_POLICIES requires, `COPY`
`go.mod`/`go.sum`, `go mod download`, `COPY . .`, then the linter as
a build step. A successful build is a clean lint.
2. `script/lint` reduced to building it. No host `golangci-lint` path
may remain anywhere.
3. `script/bootstrap`: delete the `go install`, the pin constants, the
version parser and `verify_golangci_lint` outright — do not harden
them. Keep the `git`/`make`/`go` presence checks and
`go mod download`. Warn (do not fail) when `docker` is absent, as
homoicon does. Update the header comment so it no longer describes
an install that does not happen.
## The two traps
- **A cached lint build lints nothing.** This repo has already been
bitten twice (https://git.eeqj.de/sneak/sfdupes/issues/32,
https://git.eeqj.de/sneak/sfdupes/issues/39). Caching is waived by
ruling, so force the lint layers to execute: an `ARG` referenced
inside each lint `RUN` (the value must appear in the expanded
command — BuildKit hashes that, not the declaration), with
`script/lint` passing `"$(date +%s)-$$"`. Homoicon has no such
guard; do not inherit that gap. Evidence required: two consecutive
`script/lint` runs on an unchanged tree, both showing the linter
actually executing under `BUILDKIT_PROGRESS=plain`, with timings.
- **Nested Docker.** Once `script/lint` shells out to `docker build`,
nothing inside an image build may call `make lint` or `make check`.
The main `Dockerfile`'s lint stage currently runs both `make
fmt-check` and `make lint`, and its build stage runs `make check`.
Fix by following homoicon: inside the image build, invoke the gates
directly rather than through the aggregate — the build stage runs
the test and fmt-check entrypoints, never `script/check`.
## Main Dockerfile
Keep its lint stage, invoking `golangci-lint` directly instead of
`make lint`, so `script/docker` and `script/cibuild` remain complete
gates and lint still fails the build before compilation starts.
Consequences to handle:
- The linter digest now appears in two files. Two independent pins
with nothing syncing them is exactly what
https://git.eeqj.de/sneak/sfdupes/issues/42 made a build failure;
add one guard that fails when the `FROM` line in the `Dockerfile`
lint stage and the one in `Dockerfile.lint` differ (image and
digest). One guard, not a third copy of the pin.
- `script/verify-linter-pin` loses its subject: no linter binary is
copied between stages and bootstrap pins nothing. Retire it and its
README entry, replaced by the guard above — record that reasoning in
the commit and in `TODO.md`, do not drop it silently.
- The `COPY --from=lint` of the linter binary was the only edge
forcing BuildKit to finish linting before the build stage starts.
Removing it without replacing the edge ends fail-fast linting
silently and the build still exits 0. Keep an ordering edge
(homoicon uses `COPY --from=lint /src/go.sum /dev/null`) and prove
it holds.
- `ENV PATH=/home/builder/go/bin:$PATH` and its comment exist because
bootstrap's `go install` landed there. Re-justify or remove.
- **Preserve the non-root quirk.** The build stage must still `chown`
and drop to `builder` before the tests run: as root,
`TestScanHardlinkRunFailsTogether` passes when it should fail.
Re-verify with `--user 0:0` **and the Go test cache disabled** — a
cached `ok` from the build-time run reads as a false pass.
- `CHECK_EPOCH` stays on the remaining gate `RUN`s in both stages.
## `golangci-lint config verify`
Homoicon runs it; it fetches its JSON schema over an unpinned live
HTTPS call. Ruling: include it, and state the network requirement in
the README entry. If it turns out to fail closed without network, say
so on this issue rather than working around it.
## Definition of done
- `make lint` works on a machine with Docker and no `golangci-lint`
installed — verify with the host binary masked off `PATH`, and say
how you masked it.
- Two consecutive `script/lint` runs on an unchanged tree both
demonstrably execute the linter; timings reported. Do not weaken the
no-cache rule to hit a time budget; report the cost instead.
- Negative control: plant a lint violation, confirm `script/lint`
fails with that specific finding and that `script/docker` fails at
the lint stage before the build stage starts, revert, confirm clean.
- Drift guard: prove it fails when the two `FROM` lines disagree.
- `make check` green; `make docker` green with every gate
demonstrably executing (no cached-gate green); `--user 0:0` still
fails `TestScanHardlinkRunFailsTogether` with the test cache off.
- README §Entrypoints updated: `script/lint`, `script/bootstrap`, the
retired `script/verify-linter-pin`, and the Docker requirement for
linting.
- `TODO.md` entry at the top of Completed Steps, in the same commit.
clawbot
added this to the 1.0.0 milestone 2026-08-10 14:28:59 +02:00
Implementation plan, per the spec in #46 (comment). One
commit on next (created from origin/main, which is where the branch
does not yet exist).
Files
New root Dockerfile.lint — FROM the digest-pinned golangci/golangci-lint:v2.12.2@sha256:5ccee...5240 with the
version/date comment, WORKDIR /src, COPY go.mod go.sum ./, RUN go mod download, COPY . ., then ARG CHECK_EPOCH and the
gates. Same ARG name as the main Dockerfile — one concept, one
name — with the value referenced inside each gate RUN (echo "gate ..., epoch ${CHECK_EPOCH}" && ...), because BuildKit hashes
the expanded command and not the declaration. Gates: the drift
guard, golangci-lint config verify, golangci-lint run.
script/lint — reduced to docker build --build-arg CHECK_EPOCH="$(date +%s)-$$" -f Dockerfile.lint .. The -$$ matters here specifically: two script/lint runs land inside the same second easily, and date +%s alone would cache the second one. No host golangci-lint invocation remains anywhere.
script/bootstrap — delete GOLANGCI_LINT_VERSION, GOLANGCI_LINT_MODULE, GOLANGCI_LINT_REF, GOLANGCI_LINT_VERSION_TIMEOUT, golangci_lint_version(), verify_golangci_lint() and the go install outright. Keep the git/make/go presence checks and go mod download. Warn (not
fail) when docker is absent. Header comment rewritten.
Main Dockerfile — lint stage keeps make fmt-check (a gate,
not the aggregate, and it shells out to no docker) and replaces make lint with golangci-lint run --config .golangci.yml ./...
plus the drift guard. Build stage: COPY --from=lint /usr/bin/golangci-lint is replaced by COPY --from=lint /src/go.sum /dev/null, which is what canonical REPO_POLICIES.md:107 prescribes as the ordering edge; the
fail-fast property is then proven, not assumed. RUN script/verify-linter-pin and ENV PATH=/home/builder/go/bin:$PATH
both go, the latter because bootstrap no longer go installs
anything into it. make check becomes make test and make fmt-check. CHECK_EPOCH stays on every remaining gate RUN
in both stages, and the chown plus USER builder still precede
the tests.
New script/verify-lint-image-pin — the one drift guard. Reads
the golangci/golangci-lintFROM reference out of Dockerfile
and out of Dockerfile.lint and fails naming both when they
differ, image or digest. It restates neither pin, so it is a guard
and not a third copy. Invoked from the gates in both files, so make lint, make check and make docker all catch drift.
script/verify-linter-pin retired — deleted, with its README
entry. Its subject is gone: no linter binary crosses between
stages and bootstrap pins nothing. The reasoning is recorded in the
commit message and at the top of TODO.md's Completed Steps rather
than dropped silently.
README §Entrypoints — script/bootstrap, script/lint, script/check, script/docker, the retired script/verify-linter-pin, the new guard, and the statement that
linting (hence make check and the pre-commit hook) now requires a
working Docker plus network access for config verify.
Two intended deviations, flagged now
The build stage will run make test and make fmt-check rather
than script/test and script/fmt-check directly. Both are the
same code — the targets are thin shims — but the Makefile export CGO_ENABLED = 0 only applies when entry is through make, and today's make check gets it. Calling the scripts bare
would silently change the test build's cgo setting. This still
satisfies "the gates directly, never the aggregate": check is the
aggregate, test and fmt-check are not.
Canonical REPO_POLICIES.md:92 says all Dockerfiles must run make check, and its worked example at line 123 shows RUN make lint in the lint stage. Both become impossible once script/lint
shells out to docker build. I am taking the owner ruling and this
issue's spec as superseding those two lines and am not editing the
vendored policy file; raising it here so the divergence is on the
record rather than discovered later.
Verification I will run and quote
Under BUILDKIT_PROGRESS=plain, with wall-clock timings, and with no
global prune of any kind (invalidation scoped to CHECK_EPOCH):
make lint with the host golangci-lint masked off PATH,
stating the masking method.
Two consecutive script/lint runs on an untouched tree, quoting
the linter lines from both.
Negative control: a planted violation failing script/lint with
that exact finding, and failing script/docker at the lint stage
with the build stage never starting; then reverted and clean.
Drift guard failing on deliberately disagreeing FROM lines.
make check green; make docker green with every gate shown
executing.
--user 0:0 still failing TestScanHardlinkRunFailsTogether with
the Go test cache disabled.
Whether golangci-lint config verify fails closed without network,
reported here either way.
Implementation plan, per the spec in
https://git.eeqj.de/sneak/sfdupes/issues/46#issuecomment-53177. One
commit on `next` (created from `origin/main`, which is where the branch
does not yet exist).
## Files
1. **New root `Dockerfile.lint`** — `FROM` the digest-pinned
`golangci/golangci-lint:v2.12.2@sha256:5ccee...5240` with the
version/date comment, `WORKDIR /src`, `COPY go.mod go.sum ./`,
`RUN go mod download`, `COPY . .`, then `ARG CHECK_EPOCH` and the
gates. Same ARG name as the main `Dockerfile` — one concept, one
name — with the value referenced inside each gate `RUN` (`echo
"gate ..., epoch ${CHECK_EPOCH}" && ...`), because BuildKit hashes
the expanded command and not the declaration. Gates: the drift
guard, `golangci-lint config verify`, `golangci-lint run`.
2. **`script/lint`** — reduced to
`docker build --build-arg CHECK_EPOCH="$(date +%s)-$$" -f
Dockerfile.lint .`. The `-$$` matters here specifically: two
`script/lint` runs land inside the same second easily, and
`date +%s` alone would cache the second one. No host
`golangci-lint` invocation remains anywhere.
3. **`script/bootstrap`** — delete `GOLANGCI_LINT_VERSION`,
`GOLANGCI_LINT_MODULE`, `GOLANGCI_LINT_REF`,
`GOLANGCI_LINT_VERSION_TIMEOUT`, `golangci_lint_version()`,
`verify_golangci_lint()` and the `go install` outright. Keep the
`git`/`make`/`go` presence checks and `go mod download`. Warn (not
fail) when `docker` is absent. Header comment rewritten.
4. **Main `Dockerfile`** — lint stage keeps `make fmt-check` (a gate,
not the aggregate, and it shells out to no docker) and replaces
`make lint` with `golangci-lint run --config .golangci.yml ./...`
plus the drift guard. Build stage: `COPY --from=lint
/usr/bin/golangci-lint` is replaced by `COPY --from=lint
/src/go.sum /dev/null`, which is what canonical
`REPO_POLICIES.md:107` prescribes as the ordering edge; the
fail-fast property is then proven, not assumed. `RUN
script/verify-linter-pin` and `ENV PATH=/home/builder/go/bin:$PATH`
both go, the latter because bootstrap no longer `go install`s
anything into it. `make check` becomes `make test` and
`make fmt-check`. `CHECK_EPOCH` stays on every remaining gate `RUN`
in both stages, and the `chown` plus `USER builder` still precede
the tests.
5. **New `script/verify-lint-image-pin`** — the one drift guard. Reads
the `golangci/golangci-lint` `FROM` reference out of `Dockerfile`
and out of `Dockerfile.lint` and fails naming both when they
differ, image or digest. It restates neither pin, so it is a guard
and not a third copy. Invoked from the gates in both files, so
`make lint`, `make check` and `make docker` all catch drift.
6. **`script/verify-linter-pin` retired** — deleted, with its README
entry. Its subject is gone: no linter binary crosses between
stages and bootstrap pins nothing. The reasoning is recorded in the
commit message and at the top of `TODO.md`'s Completed Steps rather
than dropped silently.
7. **README §Entrypoints** — `script/bootstrap`, `script/lint`,
`script/check`, `script/docker`, the retired
`script/verify-linter-pin`, the new guard, and the statement that
linting (hence `make check` and the pre-commit hook) now requires a
working Docker plus network access for `config verify`.
## Two intended deviations, flagged now
- The build stage will run `make test` and `make fmt-check` rather
than `script/test` and `script/fmt-check` directly. Both are the
same code — the targets are thin shims — but the `Makefile`
`export CGO_ENABLED = 0` only applies when entry is through
`make`, and today's `make check` gets it. Calling the scripts bare
would silently change the test build's cgo setting. This still
satisfies "the gates directly, never the aggregate": `check` is the
aggregate, `test` and `fmt-check` are not.
- Canonical `REPO_POLICIES.md:92` says all Dockerfiles must run
`make check`, and its worked example at line 123 shows `RUN make
lint` in the lint stage. Both become impossible once `script/lint`
shells out to `docker build`. I am taking the owner ruling and this
issue's spec as superseding those two lines and am not editing the
vendored policy file; raising it here so the divergence is on the
record rather than discovered later.
## Verification I will run and quote
Under `BUILDKIT_PROGRESS=plain`, with wall-clock timings, and with no
global prune of any kind (invalidation scoped to `CHECK_EPOCH`):
- `make lint` with the host `golangci-lint` masked off `PATH`,
stating the masking method.
- Two consecutive `script/lint` runs on an untouched tree, quoting
the linter lines from both.
- Negative control: a planted violation failing `script/lint` with
that exact finding, and failing `script/docker` at the lint stage
with the build stage never starting; then reverted and clean.
- Drift guard failing on deliberately disagreeing `FROM` lines.
- `make check` green; `make docker` green with every gate shown
executing.
- `--user 0:0` still failing `TestScanHardlinkRunFailsTogether` with
the Go test cache disabled.
- Whether `golangci-lint config verify` fails closed without network,
reported here either way.
Landed on next as beb865ae6819d8ffd85939142dd835262197c4dd — "Run all linting in Docker
via Dockerfile.lint (closes#46)". next did not exist on origin and
was created from origin/main at e6a91711; git fetch origin
immediately before the commit and again before the push showed no origin/next, so there was nothing to merge. No PR opened, per
instruction.
Files: new Dockerfile.lint and script/verify-lint-image-pin;
rewritten script/lint and script/bootstrap; Dockerfile, README.md, TODO.md updated; script/verify-linter-pin deleted.
All builds below ran under BUILDKIT_PROGRESS=plain. No prune of any
kind was run; invalidation was scoped to CHECK_EPOCH.
make lint with no host golangci-lint
Masking method: every PATH entry containing an executable golangci-lint was dropped and the name was then shown not to resolve.
Three directories had one:
MASKED OUT: /home/user/go/bin
MASKED OUT: /home/user/.local/bin
MASKED OUT: /usr/local/bin
=== after masking ===
command -v golangci-lint: []
which -a golangci-lint: []
=== running: make lint ===
That run exited 0. Every make lint and make check quoted below was
run through the same masking.
Two consecutive runs on an unchanged tree
Run 1, 42.2s wall:
#12 [8/8] RUN echo "gate lint, epoch 1786365328-1079117" && golangci-lint run --config .golangci.yml ./...
#12 0.128 gate lint, epoch 1786365328-1079117
#12 24.87 0 issues.
#12 DONE 27.7s
real 0m42.225s
Run 2, immediately after, no file touched, 41.8s wall. Note #9 CACHED
— that is the COPY . . layer being served from cache, which is
exactly the condition that produced the false greens in #32 and #39. The gates below it still
ran, under a different epoch:
27.7s and 28.7s of linter execution, not a sub-second cached exit. The
cost of the no-cache rule is therefore about 40s per make lint; it
was not weakened to reduce that.
Negative control
Planted var unusedIssue46Sentinel = 1 at the end of report.go.
make lint failed in 45.3s with that specific finding:
39.01 report.go:173:5: var unusedIssue46Sentinel is unused (unused)
39.01 var unusedIssue46Sentinel = 1
39.01 1 issues:
39.01 * unused: 1
ERROR: failed to build: failed to solve: process "/bin/sh -c echo \"gate lint, epoch ${CHECK_EPOCH}\" && golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
make: *** [Makefile:31: lint] Error 1
make docker failed in 50.4s at the lint stage, and the ordering edge
held — the build stage never reached its COPY --from=lint:
#19 [lint 9/9] RUN echo "gate lint, epoch 1786365615" && golangci-lint run --config .golangci.yml ./...
#19 41.34 report.go:173:5: var unusedIssue46Sentinel is unused (unused)
ERROR: failed to build: failed to solve: ... exit code: 1
Builder-stage steps that ran, complete list:
[builder 1/12] FROM docker.io/library/golang@sha256:56961d79...
[builder 2/12] RUN adduser -D -u 1000 builder
[builder 3/12] WORKDIR /src
Step 4/12 is COPY --from=lint /src/go.sum /dev/null. Occurrences in
that log of COPY --from=lint, RUN script/bootstrap, gate test and make build: 0, 0, 0, 0.
Reverted; make lint back to 0 issues. in 32.0s wall.
Drift guard
Three cases, all failing and naming both sides. Tag-only disagreement
(v2.12.1 against v2.12.2, identical digest), run inside the real
gate:
#11 [6/8] RUN echo "gate lint-image-pin, epoch 1786365738-1385288" && script/verify-lint-image-pin
#11 0.221 verify-lint-image-pin: the linter image is pinned twice and the two pins disagree:
#11 0.221 verify-lint-image-pin: Dockerfile.lint: golangci/golangci-lint:v2.12.1@sha256:5cceeef0...
#11 0.221 verify-lint-image-pin: Dockerfile: golangci/golangci-lint:v2.12.2@sha256:5cceeef0...
#11 ERROR: process "/bin/sh -c echo \"gate lint-image-pin, epoch ${CHECK_EPOCH}\" && script/verify-lint-image-pin" did not complete successfully: exit code: 1
Digest-only disagreement (identical v2.12.2 tag, zeroed digest): exit
1, same message. Unreadable reference (the FROM line removed
altogether): exit 1 with expected exactly one golangci/golangci-lint FROM reference, found 0 — it fails rather than comparing two empty
strings and passing.
Restored, guard passes: Dockerfile.lint and Dockerfile agree on golangci/golangci-lint:v2.12.2@sha256:5cceeef0...
make check and make docker
make check green, 51.4s wall, with the linter masked off PATH: ok sneak.berlin/go/sfdupes 1.877s coverage: 88.3% of statements, and 0 issues. after 33.9s in the lint step.
make docker green, 5m34.9s wall. All six gates executed under one
epoch, none cached:
#18 [lint 6/9] RUN echo "gate fmt-check, epoch 1786365763" && make fmt-check DONE 0.7s
#19 [lint 7/9] RUN echo "gate lint-image-pin, epoch 1786365763" && ... DONE 0.4s
#20 [lint 8/9] RUN echo "gate config verify, epoch 1786365763" && ... DONE 1.7s
#21 [lint 9/9] RUN echo "gate lint, epoch 1786365763" && golangci-lint run ... DONE 37.6s
#22 [builder 4/12] COPY --from=lint /src/go.sum /dev/null DONE 1.3s
#25 [builder 7/12] RUN script/bootstrap DONE 8.5s
#28 [builder 10/12] RUN echo "gate test, epoch 1786365763" && make test DONE 25.2s
#29 [builder 11/12] RUN echo "gate fmt-check, epoch 1786365763" && make fmt-check DONE 2.7s
#30 [builder 12/12] RUN make build DONE 7.8s
The test gate genuinely ran rather than reporting a cached ok:
#28 24.97 ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5% of statements
Bootstrap in the build stage behaves as intended and its warning path
fires (the builder image has no docker), and the >/dev/null
redirections it uses throughout still work despite step 4/12 copying a
file to that path — the runtime remounts /dev per RUN:
#25 1.844 bootstrap: WARNING: docker not found; make lint, make check
#25 7.241 bootstrap complete
Non-root quirk
Built --target builder and ran the test directly with the Go test
cache disabled (-count=1, plus GOFLAGS=-count=1), so no cached ok
could be mistaken for a pass:
default builder user (uid=1000), same command: exit 0, ok sneak.berlin/go/sfdupes 0.018s
The drop to the unprivileged user is still load-bearing.
golangci-lint config verify: it does not need network
Included per the ruling. The premise that it makes a live unpinned
HTTPS call turns out not to hold for the pinned image, so I measured it
rather than documenting a requirement that does not exist. Running the
pinned image directly, valid config:
with network: exit 0
--network none: exit 0
And with a config containing bogus-key-that-does-not-exist, to rule
out its silently skipping validation offline — identical output and
exit code both ways:
jsonschema: "linters" does not validate with "/properties/linters/additionalProperties": additional properties 'bogus-key-that-does-not-exist' not allowed
The command is terminated due to an error: the configuration contains invalid elements
exit=3
So the binary validates against a schema it embeds for its own version.
The README states that linting needs a working docker and nothing
more once the pinned image is local, rather than asserting a network
requirement. (A --network=none whole-build test is not a valid probe
here, incidentally: BuildKit includes the network mode in the layer
cache key, so it re-runs and fails at go mod download before reaching
the gate.)
Decided differently from the spec
The build stage runs make test and make fmt-check, not script/test and script/fmt-check. Same code — the targets are
thin shims — but the Makefile's export CGO_ENABLED = 0 reaches
only what make invokes, and today's make check gets it. Calling
the scripts bare would have silently changed the test build's cgo
setting as a side effect of this refactor. The spec's actual
requirement, never the check aggregate inside an image build, is
met.
The README does not state a network requirement for config verify, because as measured above there is none. See the
section directly above.
The ARG in Dockerfile.lint is named CHECK_EPOCH, matching
the main Dockerfile rather than introducing a second name for one
concept. script/lint passes "$(date +%s)-$$" as specified; script/docker and script/cibuild keep their bare date +%s,
since a multi-minute image build cannot collide with itself inside
one second the way two 40-second lint runs can.
Two things noted, neither fixed here:
Canonical REPO_POLICIES.md line 92 requires every Dockerfile to
run make check, and its worked example at line 115 shows RUN make lint in the lint stage. Both are now unreachable, since make lint shells out to docker build and nothing inside an
image build may do that. I took the owner ruling and this issue's
spec as superseding those two lines and left the vendored policy
file untouched; flagging it so the divergence is on the record.
golangci-lint still warns that the gomodguard linter is
deprecated in favour of gomodguard_v2. Already tracked at #26, so no new issue filed
and nothing changed in .golangci.yml here.
Landed on `next` as
`beb865ae6819d8ffd85939142dd835262197c4dd` — "Run all linting in Docker
via Dockerfile.lint (closes #46)". `next` did not exist on `origin` and
was created from `origin/main` at `e6a91711`; `git fetch origin`
immediately before the commit and again before the push showed no
`origin/next`, so there was nothing to merge. No PR opened, per
instruction.
Files: new `Dockerfile.lint` and `script/verify-lint-image-pin`;
rewritten `script/lint` and `script/bootstrap`; `Dockerfile`,
`README.md`, `TODO.md` updated; `script/verify-linter-pin` deleted.
All builds below ran under `BUILDKIT_PROGRESS=plain`. No prune of any
kind was run; invalidation was scoped to `CHECK_EPOCH`.
## `make lint` with no host golangci-lint
Masking method: every `PATH` entry containing an executable
`golangci-lint` was dropped and the name was then shown not to resolve.
Three directories had one:
```
MASKED OUT: /home/user/go/bin
MASKED OUT: /home/user/.local/bin
MASKED OUT: /usr/local/bin
=== after masking ===
command -v golangci-lint: []
which -a golangci-lint: []
=== running: make lint ===
```
That run exited 0. Every `make lint` and `make check` quoted below was
run through the same masking.
## Two consecutive runs on an unchanged tree
Run 1, 42.2s wall:
```
#12 [8/8] RUN echo "gate lint, epoch 1786365328-1079117" && golangci-lint run --config .golangci.yml ./...
#12 0.128 gate lint, epoch 1786365328-1079117
#12 24.87 0 issues.
#12 DONE 27.7s
real 0m42.225s
```
Run 2, immediately after, no file touched, 41.8s wall. Note `#9 CACHED`
— that is the `COPY . .` layer being served from cache, which is
exactly the condition that produced the false greens in
https://git.eeqj.de/sneak/sfdupes/issues/32 and
https://git.eeqj.de/sneak/sfdupes/issues/39. The gates below it still
ran, under a different epoch:
```
#9 CACHED
#12 [8/8] RUN echo "gate lint, epoch 1786365385-1121077" && golangci-lint run --config .golangci.yml ./...
#12 0.262 gate lint, epoch 1786365385-1121077
#12 26.79 0 issues.
#12 DONE 28.7s
real 0m41.835s
```
27.7s and 28.7s of linter execution, not a sub-second cached exit. The
cost of the no-cache rule is therefore about 40s per `make lint`; it
was not weakened to reduce that.
## Negative control
Planted `var unusedIssue46Sentinel = 1` at the end of `report.go`.
`make lint` failed in 45.3s with that specific finding:
```
39.01 report.go:173:5: var unusedIssue46Sentinel is unused (unused)
39.01 var unusedIssue46Sentinel = 1
39.01 1 issues:
39.01 * unused: 1
ERROR: failed to build: failed to solve: process "/bin/sh -c echo \"gate lint, epoch ${CHECK_EPOCH}\" && golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
make: *** [Makefile:31: lint] Error 1
```
`make docker` failed in 50.4s at the lint stage, and the ordering edge
held — the build stage never reached its `COPY --from=lint`:
```
#19 [lint 9/9] RUN echo "gate lint, epoch 1786365615" && golangci-lint run --config .golangci.yml ./...
#19 41.34 report.go:173:5: var unusedIssue46Sentinel is unused (unused)
ERROR: failed to build: failed to solve: ... exit code: 1
```
Builder-stage steps that ran, complete list:
```
[builder 1/12] FROM docker.io/library/golang@sha256:56961d79...
[builder 2/12] RUN adduser -D -u 1000 builder
[builder 3/12] WORKDIR /src
```
Step 4/12 is `COPY --from=lint /src/go.sum /dev/null`. Occurrences in
that log of `COPY --from=lint`, `RUN script/bootstrap`, `gate test` and
`make build`: 0, 0, 0, 0.
Reverted; `make lint` back to `0 issues.` in 32.0s wall.
## Drift guard
Three cases, all failing and naming both sides. Tag-only disagreement
(`v2.12.1` against `v2.12.2`, identical digest), run inside the real
gate:
```
#11 [6/8] RUN echo "gate lint-image-pin, epoch 1786365738-1385288" && script/verify-lint-image-pin
#11 0.221 verify-lint-image-pin: the linter image is pinned twice and the two pins disagree:
#11 0.221 verify-lint-image-pin: Dockerfile.lint: golangci/golangci-lint:v2.12.1@sha256:5cceeef0...
#11 0.221 verify-lint-image-pin: Dockerfile: golangci/golangci-lint:v2.12.2@sha256:5cceeef0...
#11 ERROR: process "/bin/sh -c echo \"gate lint-image-pin, epoch ${CHECK_EPOCH}\" && script/verify-lint-image-pin" did not complete successfully: exit code: 1
```
Digest-only disagreement (identical `v2.12.2` tag, zeroed digest): exit
1, same message. Unreadable reference (the `FROM` line removed
altogether): exit 1 with `expected exactly one golangci/golangci-lint
FROM reference, found 0` — it fails rather than comparing two empty
strings and passing.
Restored, guard passes:
`Dockerfile.lint and Dockerfile agree on golangci/golangci-lint:v2.12.2@sha256:5cceeef0...`
## `make check` and `make docker`
`make check` green, 51.4s wall, with the linter masked off `PATH`:
`ok sneak.berlin/go/sfdupes 1.877s coverage: 88.3% of statements`, and
`0 issues.` after 33.9s in the lint step.
`make docker` green, 5m34.9s wall. All six gates executed under one
epoch, none cached:
```
#18 [lint 6/9] RUN echo "gate fmt-check, epoch 1786365763" && make fmt-check DONE 0.7s
#19 [lint 7/9] RUN echo "gate lint-image-pin, epoch 1786365763" && ... DONE 0.4s
#20 [lint 8/9] RUN echo "gate config verify, epoch 1786365763" && ... DONE 1.7s
#21 [lint 9/9] RUN echo "gate lint, epoch 1786365763" && golangci-lint run ... DONE 37.6s
#22 [builder 4/12] COPY --from=lint /src/go.sum /dev/null DONE 1.3s
#25 [builder 7/12] RUN script/bootstrap DONE 8.5s
#28 [builder 10/12] RUN echo "gate test, epoch 1786365763" && make test DONE 25.2s
#29 [builder 11/12] RUN echo "gate fmt-check, epoch 1786365763" && make fmt-check DONE 2.7s
#30 [builder 12/12] RUN make build DONE 7.8s
```
The test gate genuinely ran rather than reporting a cached `ok`:
```
#28 24.97 ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5% of statements
```
Bootstrap in the build stage behaves as intended and its warning path
fires (the builder image has no docker), and the `>/dev/null`
redirections it uses throughout still work despite step 4/12 copying a
file to that path — the runtime remounts `/dev` per `RUN`:
```
#25 1.844 bootstrap: WARNING: docker not found; make lint, make check
#25 7.241 bootstrap complete
```
## Non-root quirk
Built `--target builder` and ran the test directly with the Go test
cache disabled (`-count=1`, plus `GOFLAGS=-count=1`), so no cached `ok`
could be mistaken for a pass:
- `--user 0:0`: exit 1,
`--- FAIL: TestScanHardlinkRunFailsTogether (0.01s)`,
`scan_test.go:817: stats = {added:2 updated:0 removed:0 unchanged:0 skipped:0}, want both hardlink paths skipped`
- default `builder` user (`uid=1000`), same command: exit 0,
`ok sneak.berlin/go/sfdupes 0.018s`
The drop to the unprivileged user is still load-bearing.
## `golangci-lint config verify`: it does not need network
Included per the ruling. The premise that it makes a live unpinned
HTTPS call turns out not to hold for the pinned image, so I measured it
rather than documenting a requirement that does not exist. Running the
pinned image directly, valid config:
- with network: exit 0
- `--network none`: exit 0
And with a config containing `bogus-key-that-does-not-exist`, to rule
out its silently skipping validation offline — identical output and
exit code both ways:
```
jsonschema: "linters" does not validate with "/properties/linters/additionalProperties": additional properties 'bogus-key-that-does-not-exist' not allowed
The command is terminated due to an error: the configuration contains invalid elements
exit=3
```
So the binary validates against a schema it embeds for its own version.
The README states that linting needs a working `docker` and nothing
more once the pinned image is local, rather than asserting a network
requirement. (A `--network=none` whole-build test is not a valid probe
here, incidentally: BuildKit includes the network mode in the layer
cache key, so it re-runs and fails at `go mod download` before reaching
the gate.)
## Decided differently from the spec
1. **The build stage runs `make test` and `make fmt-check`, not
`script/test` and `script/fmt-check`.** Same code — the targets are
thin shims — but the `Makefile`'s `export CGO_ENABLED = 0` reaches
only what `make` invokes, and today's `make check` gets it. Calling
the scripts bare would have silently changed the test build's cgo
setting as a side effect of this refactor. The spec's actual
requirement, never the `check` aggregate inside an image build, is
met.
2. **The README does not state a network requirement for
`config verify`**, because as measured above there is none. See the
section directly above.
3. **The `ARG` in `Dockerfile.lint` is named `CHECK_EPOCH`**, matching
the main `Dockerfile` rather than introducing a second name for one
concept. `script/lint` passes `"$(date +%s)-$$"` as specified;
`script/docker` and `script/cibuild` keep their bare `date +%s`,
since a multi-minute image build cannot collide with itself inside
one second the way two 40-second lint runs can.
Two things noted, neither fixed here:
- Canonical `REPO_POLICIES.md` line 92 requires every Dockerfile to
run `make check`, and its worked example at line 115 shows
`RUN make lint` in the lint stage. Both are now unreachable, since
`make lint` shells out to `docker build` and nothing inside an
image build may do that. I took the owner ruling and this issue's
spec as superseding those two lines and left the vendored policy
file untouched; flagging it so the divergence is on the record.
- `golangci-lint` still warns that the `gomodguard` linter is
deprecated in favour of `gomodguard_v2`. Already tracked at
https://git.eeqj.de/sneak/sfdupes/issues/26, so no new issue filed
and nothing changed in `.golangci.yml` here.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 rootDockerfile.lintbuiltFROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runsgolangci-lint run --config .golangci.yml ./...as a build step, withscript/lintreduced to building it. Linting as a build step means a successful build IS a clean lint.This supersedes the bootstrap version-verification work scoped here. That fix existed to make the host linter match the pin; with nothing running on the host, the whole problem — including the
go installwriting to$GOPATH/binwhilecommand -vresolvesPATH— disappears. Remove the golangci-lint install fromscript/bootstraprather than hardening it.It also resolves the cross-worktree contamination observed here, where a run reported ten findings against paths under a worktree that had already been deleted.
Preserve the repo's non-root Dockerfile quirk where it applies to the main image; the lint image is separate and does not need it.
Two things to get right:
golangci-lint config verifyfetches its JSON schema over an unpinned live HTTPS call. Decide deliberately whether to include it.Definition of done
script/lintruns the linter only in Docker; no host golangci-lint path remains.script/lintruns on an unchanged tree both demonstrably execute the linter.make checkstill green.Canonical tracking issue: sneak/prompts#40
Implementation requirements (manager). This issue absorbs
#45, which scoped the same work
before the owner ruling fixed the shape; #45's acceptance criteria are
folded in below and it is being closed as superseded.
Shape
Dockerfile.lint, copyingsneak/homoicon's:FROMthedigest-pinned
golangci/golangci-lint:v2.12.2@sha256:5ccee...5240with the version/date comment REPO_POLICIES requires,
COPYgo.mod/go.sum,go mod download,COPY . ., then the linter asa build step. A successful build is a clean lint.
script/lintreduced to building it. No hostgolangci-lintpathmay remain anywhere.
script/bootstrap: delete thego install, the pin constants, theversion parser and
verify_golangci_lintoutright — do not hardenthem. Keep the
git/make/gopresence checks andgo mod download. Warn (do not fail) whendockeris absent, ashomoicon does. Update the header comment so it no longer describes
an install that does not happen.
The two traps
bitten twice (#32,
#39). Caching is waived by
ruling, so force the lint layers to execute: an
ARGreferencedinside each lint
RUN(the value must appear in the expandedcommand — BuildKit hashes that, not the declaration), with
script/lintpassing"$(date +%s)-$$". Homoicon has no suchguard; do not inherit that gap. Evidence required: two consecutive
script/lintruns on an unchanged tree, both showing the linteractually executing under
BUILDKIT_PROGRESS=plain, with timings.script/lintshells out todocker build,nothing inside an image build may call
make lintormake check.The main
Dockerfile's lint stage currently runs bothmake fmt-checkandmake lint, and its build stage runsmake check.Fix by following homoicon: inside the image build, invoke the gates
directly rather than through the aggregate — the build stage runs
the test and fmt-check entrypoints, never
script/check.Main Dockerfile
Keep its lint stage, invoking
golangci-lintdirectly instead ofmake lint, soscript/dockerandscript/cibuildremain completegates and lint still fails the build before compilation starts.
Consequences to handle:
with nothing syncing them is exactly what
#42 made a build failure;
add one guard that fails when the
FROMline in theDockerfilelint stage and the one in
Dockerfile.lintdiffer (image anddigest). One guard, not a third copy of the pin.
script/verify-linter-pinloses its subject: no linter binary iscopied between stages and bootstrap pins nothing. Retire it and its
README entry, replaced by the guard above — record that reasoning in
the commit and in
TODO.md, do not drop it silently.COPY --from=lintof the linter binary was the only edgeforcing BuildKit to finish linting before the build stage starts.
Removing it without replacing the edge ends fail-fast linting
silently and the build still exits 0. Keep an ordering edge
(homoicon uses
COPY --from=lint /src/go.sum /dev/null) and proveit holds.
ENV PATH=/home/builder/go/bin:$PATHand its comment exist becausebootstrap's
go installlanded there. Re-justify or remove.chownand drop to
builderbefore the tests run: as root,TestScanHardlinkRunFailsTogetherpasses when it should fail.Re-verify with
--user 0:0and the Go test cache disabled — acached
okfrom the build-time run reads as a false pass.CHECK_EPOCHstays on the remaining gateRUNs in both stages.golangci-lint config verifyHomoicon runs it; it fetches its JSON schema over an unpinned live
HTTPS call. Ruling: include it, and state the network requirement in
the README entry. If it turns out to fail closed without network, say
so on this issue rather than working around it.
Definition of done
make lintworks on a machine with Docker and nogolangci-lintinstalled — verify with the host binary masked off
PATH, and sayhow you masked it.
script/lintruns on an unchanged tree bothdemonstrably execute the linter; timings reported. Do not weaken the
no-cache rule to hit a time budget; report the cost instead.
script/lintfails with that specific finding and that
script/dockerfails atthe lint stage before the build stage starts, revert, confirm clean.
FROMlines disagree.make checkgreen;make dockergreen with every gatedemonstrably executing (no cached-gate green);
--user 0:0stillfails
TestScanHardlinkRunFailsTogetherwith the test cache off.script/lint,script/bootstrap, theretired
script/verify-linter-pin, and the Docker requirement forlinting.
TODO.mdentry at the top of Completed Steps, in the same commit.Implementation plan, per the spec in
#46 (comment). One
commit on
next(created fromorigin/main, which is where the branchdoes not yet exist).
Files
New root
Dockerfile.lint—FROMthe digest-pinnedgolangci/golangci-lint:v2.12.2@sha256:5ccee...5240with theversion/date comment,
WORKDIR /src,COPY go.mod go.sum ./,RUN go mod download,COPY . ., thenARG CHECK_EPOCHand thegates. Same ARG name as the main
Dockerfile— one concept, onename — with the value referenced inside each gate
RUN(echo "gate ..., epoch ${CHECK_EPOCH}" && ...), because BuildKit hashesthe expanded command and not the declaration. Gates: the drift
guard,
golangci-lint config verify,golangci-lint run.script/lint— reduced todocker build --build-arg CHECK_EPOCH="$(date +%s)-$$" -f Dockerfile.lint .. The-$$matters here specifically: twoscript/lintruns land inside the same second easily, anddate +%salone would cache the second one. No hostgolangci-lintinvocation remains anywhere.script/bootstrap— deleteGOLANGCI_LINT_VERSION,GOLANGCI_LINT_MODULE,GOLANGCI_LINT_REF,GOLANGCI_LINT_VERSION_TIMEOUT,golangci_lint_version(),verify_golangci_lint()and thego installoutright. Keep thegit/make/gopresence checks andgo mod download. Warn (notfail) when
dockeris absent. Header comment rewritten.Main
Dockerfile— lint stage keepsmake fmt-check(a gate,not the aggregate, and it shells out to no docker) and replaces
make lintwithgolangci-lint run --config .golangci.yml ./...plus the drift guard. Build stage:
COPY --from=lint /usr/bin/golangci-lintis replaced byCOPY --from=lint /src/go.sum /dev/null, which is what canonicalREPO_POLICIES.md:107prescribes as the ordering edge; thefail-fast property is then proven, not assumed.
RUN script/verify-linter-pinandENV PATH=/home/builder/go/bin:$PATHboth go, the latter because bootstrap no longer
go installsanything into it.
make checkbecomesmake testandmake fmt-check.CHECK_EPOCHstays on every remaining gateRUNin both stages, and the
chownplusUSER builderstill precedethe tests.
New
script/verify-lint-image-pin— the one drift guard. Readsthe
golangci/golangci-lintFROMreference out ofDockerfileand out of
Dockerfile.lintand fails naming both when theydiffer, image or digest. It restates neither pin, so it is a guard
and not a third copy. Invoked from the gates in both files, so
make lint,make checkandmake dockerall catch drift.script/verify-linter-pinretired — deleted, with its READMEentry. Its subject is gone: no linter binary crosses between
stages and bootstrap pins nothing. The reasoning is recorded in the
commit message and at the top of
TODO.md's Completed Steps ratherthan dropped silently.
README §Entrypoints —
script/bootstrap,script/lint,script/check,script/docker, the retiredscript/verify-linter-pin, the new guard, and the statement thatlinting (hence
make checkand the pre-commit hook) now requires aworking Docker plus network access for
config verify.Two intended deviations, flagged now
make testandmake fmt-checkratherthan
script/testandscript/fmt-checkdirectly. Both are thesame code — the targets are thin shims — but the
Makefileexport CGO_ENABLED = 0only applies when entry is throughmake, and today'smake checkgets it. Calling the scripts barewould silently change the test build's cgo setting. This still
satisfies "the gates directly, never the aggregate":
checkis theaggregate,
testandfmt-checkare not.REPO_POLICIES.md:92says all Dockerfiles must runmake check, and its worked example at line 123 showsRUN make lintin the lint stage. Both become impossible oncescript/lintshells out to
docker build. I am taking the owner ruling and thisissue's spec as superseding those two lines and am not editing the
vendored policy file; raising it here so the divergence is on the
record rather than discovered later.
Verification I will run and quote
Under
BUILDKIT_PROGRESS=plain, with wall-clock timings, and with noglobal prune of any kind (invalidation scoped to
CHECK_EPOCH):make lintwith the hostgolangci-lintmasked offPATH,stating the masking method.
script/lintruns on an untouched tree, quotingthe linter lines from both.
script/lintwiththat exact finding, and failing
script/dockerat the lint stagewith the build stage never starting; then reverted and clean.
FROMlines.make checkgreen;make dockergreen with every gate shownexecuting.
--user 0:0still failingTestScanHardlinkRunFailsTogetherwiththe Go test cache disabled.
golangci-lint config verifyfails closed without network,reported here either way.
Landed on
nextasbeb865ae6819d8ffd85939142dd835262197c4dd— "Run all linting in Dockervia Dockerfile.lint (closes #46)".
nextdid not exist onoriginandwas created from
origin/mainate6a91711;git fetch originimmediately before the commit and again before the push showed no
origin/next, so there was nothing to merge. No PR opened, perinstruction.
Files: new
Dockerfile.lintandscript/verify-lint-image-pin;rewritten
script/lintandscript/bootstrap;Dockerfile,README.md,TODO.mdupdated;script/verify-linter-pindeleted.All builds below ran under
BUILDKIT_PROGRESS=plain. No prune of anykind was run; invalidation was scoped to
CHECK_EPOCH.make lintwith no host golangci-lintMasking method: every
PATHentry containing an executablegolangci-lintwas dropped and the name was then shown not to resolve.Three directories had one:
That run exited 0. Every
make lintandmake checkquoted below wasrun through the same masking.
Two consecutive runs on an unchanged tree
Run 1, 42.2s wall:
Run 2, immediately after, no file touched, 41.8s wall. Note
#9 CACHED— that is the
COPY . .layer being served from cache, which isexactly the condition that produced the false greens in
#32 and
#39. The gates below it still
ran, under a different epoch:
27.7s and 28.7s of linter execution, not a sub-second cached exit. The
cost of the no-cache rule is therefore about 40s per
make lint; itwas not weakened to reduce that.
Negative control
Planted
var unusedIssue46Sentinel = 1at the end ofreport.go.make lintfailed in 45.3s with that specific finding:make dockerfailed in 50.4s at the lint stage, and the ordering edgeheld — the build stage never reached its
COPY --from=lint:Builder-stage steps that ran, complete list:
Step 4/12 is
COPY --from=lint /src/go.sum /dev/null. Occurrences inthat log of
COPY --from=lint,RUN script/bootstrap,gate testandmake build: 0, 0, 0, 0.Reverted;
make lintback to0 issues.in 32.0s wall.Drift guard
Three cases, all failing and naming both sides. Tag-only disagreement
(
v2.12.1againstv2.12.2, identical digest), run inside the realgate:
Digest-only disagreement (identical
v2.12.2tag, zeroed digest): exit1, same message. Unreadable reference (the
FROMline removedaltogether): exit 1 with
expected exactly one golangci/golangci-lint FROM reference, found 0— it fails rather than comparing two emptystrings and passing.
Restored, guard passes:
Dockerfile.lint and Dockerfile agree on golangci/golangci-lint:v2.12.2@sha256:5cceeef0...make checkandmake dockermake checkgreen, 51.4s wall, with the linter masked offPATH:ok sneak.berlin/go/sfdupes 1.877s coverage: 88.3% of statements, and0 issues.after 33.9s in the lint step.make dockergreen, 5m34.9s wall. All six gates executed under oneepoch, none cached:
The test gate genuinely ran rather than reporting a cached
ok:Bootstrap in the build stage behaves as intended and its warning path
fires (the builder image has no docker), and the
>/dev/nullredirections it uses throughout still work despite step 4/12 copying a
file to that path — the runtime remounts
/devperRUN:Non-root quirk
Built
--target builderand ran the test directly with the Go testcache disabled (
-count=1, plusGOFLAGS=-count=1), so no cachedokcould be mistaken for a pass:
--user 0:0: exit 1,--- FAIL: TestScanHardlinkRunFailsTogether (0.01s),scan_test.go:817: stats = {added:2 updated:0 removed:0 unchanged:0 skipped:0}, want both hardlink paths skippedbuilderuser (uid=1000), same command: exit 0,ok sneak.berlin/go/sfdupes 0.018sThe drop to the unprivileged user is still load-bearing.
golangci-lint config verify: it does not need networkIncluded per the ruling. The premise that it makes a live unpinned
HTTPS call turns out not to hold for the pinned image, so I measured it
rather than documenting a requirement that does not exist. Running the
pinned image directly, valid config:
--network none: exit 0And with a config containing
bogus-key-that-does-not-exist, to ruleout its silently skipping validation offline — identical output and
exit code both ways:
So the binary validates against a schema it embeds for its own version.
The README states that linting needs a working
dockerand nothingmore once the pinned image is local, rather than asserting a network
requirement. (A
--network=nonewhole-build test is not a valid probehere, incidentally: BuildKit includes the network mode in the layer
cache key, so it re-runs and fails at
go mod downloadbefore reachingthe gate.)
Decided differently from the spec
make testandmake fmt-check, notscript/testandscript/fmt-check. Same code — the targets arethin shims — but the
Makefile'sexport CGO_ENABLED = 0reachesonly what
makeinvokes, and today'smake checkgets it. Callingthe scripts bare would have silently changed the test build's cgo
setting as a side effect of this refactor. The spec's actual
requirement, never the
checkaggregate inside an image build, ismet.
config verify, because as measured above there is none. See thesection directly above.
ARGinDockerfile.lintis namedCHECK_EPOCH, matchingthe main
Dockerfilerather than introducing a second name for oneconcept.
script/lintpasses"$(date +%s)-$$"as specified;script/dockerandscript/cibuildkeep their baredate +%s,since a multi-minute image build cannot collide with itself inside
one second the way two 40-second lint runs can.
Two things noted, neither fixed here:
REPO_POLICIES.mdline 92 requires every Dockerfile torun
make check, and its worked example at line 115 showsRUN make lintin the lint stage. Both are now unreachable, sincemake lintshells out todocker buildand nothing inside animage build may do that. I took the owner ruling and this issue's
spec as superseding those two lines and left the vendored policy
file untouched; flagging it so the divergence is on the record.
golangci-lintstill warns that thegomodguardlinter isdeprecated in favour of
gomodguard_v2. Already tracked at#26, so no new issue filed
and nothing changed in
.golangci.ymlhere.