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

Closed
opened 2026-08-10 13:15:37 +02:00 by clawbot · 3 comments
Collaborator

Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the script/ entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way.

Reference implementation is sneak/homoicon — copy its shape: a root Dockerfile.lint built FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240, which COPYs the repo in and runs golangci-lint run --config .golangci.yml ./... as a build step, with script/lint reduced to building it. Linting as a build step means a successful build IS a clean lint.

This 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: sneak/prompts#40

Owner ruling, sneak 2026-08-09: every lint run happens inside a Docker container, invoked through the `script/` entrypoint. Docker is always available. Linting runs independently and does not need a cache. He has directed a PR for every repo not already set up this way. Reference implementation is `sneak/homoicon` — copy its shape: a root `Dockerfile.lint` built `FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, which COPYs the repo in and runs `golangci-lint run --config .golangci.yml ./...` as a build step, with `script/lint` reduced to building it. Linting as a build step means a successful build IS a clean lint. **This 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
Author
Collaborator

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

  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 (#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: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 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
Author
Collaborator

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

  1. New root Dockerfile.lintFROM 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 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.

  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 §Entrypointsscript/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.
Author
Collaborator

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:

#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
    #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.
Sign in to join this conversation.