Run the linter at the pinned version locally too (closes #78) #79

Merged
clawbot merged 1 commits from fix-pinned-linter into main 2026-08-09 04:52:22 +02:00
Collaborator

Closes #78.

script/lint now runs the digest-pinned golangci-lint image instead of
whatever binary happens to be on PATH, so make lint and CI cannot
disagree about findings.

What changed

  • script/lint extracts the image reference (tag and digest)
    from the Dockerfile lint stage FROM line with awk and runs that
    exact image via docker run. Extra arguments are passed through to
    golangci-lint run.
  • Single source of truth: Dockerfile line 8 (the lint stage FROM).
    Docker requires a literal in FROM, so that line is the only place
    that can hold the reference; everything else derives from it. Bumping
    the linter is now a one-line edit there.
  • Duplicate pins removed rather than kept in sync: the Makefile
    deps target no longer does go install ...@v2.12.2, and
    script/bootstrap no longer installs an unpinned golangci-lint from
    the distro package manager (issue DoD item 3). With linting
    containerized, a second copy on PATH can only drift. bootstrap now
    prints a notice if docker is missing.
  • In-container execution. Dockerfile runs make lint inside the
    lint image, where there is no Docker daemon. script/lint runs the
    PATH binary only when golangci-lint version reports a version
    exactly equal to the pin parsed from the FROM line — the same linter
    by definition. Any other version, or none, goes through Docker.
  • No-Docker case is a hard error naming the required image; there is
    no path from a mismatched PATH binary to a passing lint.
  • script/lint-fix delegates to script/lint --fix, so autofixes
    come from the pinned linter too. script/check, script/precommit,
    make lint and the Dockerfile lint stage were the callers; all keep
    working unchanged (script/lint with no arguments behaves as before).
  • README Entrypoints documents that make check is authoritative
    because script/lint uses the pinned linter, and calls out
    script/cibuild as the full CI-equivalent gate. TODO.md updated in
    the same commit.

Not touched: .golangci.yml (sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,
verified before push) and the gomodguard deprecation warning.

Requirement 4: proof it catches the real regression

Reproduced the exact failure from PR #77 / commit b960ca3: the four
//nolint:gosec directives that 2.10.1 requires and 2.12.2 reports as
unused were temporarily reinstated (internal/log/log.go:73,
internal/ui/ui.go:116, internal/vaultik/verify.go:317 and :329),
then removed again.

Old script/lint (the pre-change version from origin/main, ambient
2.10.1 on PATH) on that tree:

0 issues.
OLD_EXIT=0

New make lint (pinned 2.12.2 image) on the same tree, same host, same
2.10.1 still installed:

internal/log/log.go:73:44: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint)
internal/ui/ui.go:116:38: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint)
internal/vaultik/verify.go:317:27: directive `//nolint:gosec // G703: path from os.CreateTemp` is unused for linter "gosec" (nolintlint)
internal/vaultik/verify.go:329:27: directive `//nolint:gosec // G703: path from os.CreateTemp` is unused for linter "gosec" (nolintlint)
4 issues:
* nolintlint: 4
make: *** [Makefile:44: lint] Error 1
NEW_EXIT=2

Green locally, red in CI → red locally. The temporary directives were
reverted (git checkout --) and the tree is clean; they are not in the
commit.

Worth noting for the record: with the ambient 2.10.1, the old
script/lint on unmodified main reports 4 issues: gosec: 4 — i.e.
today make lint is also red on a tree CI lints clean. The drift cuts
both ways, and both directions are gone now.

Requirement 5: no-Docker behavior

Daemon unreachable (DOCKER_HOST=unix:///nonexistent/docker.sock) →
exit 1:

lint: the docker daemon is not reachable, so the pinned linter cannot
run.

  pinned image: golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60

Start the daemon (and check DOCKER_HOST / your group membership), or
install golangci-lint 2.12.2 on PATH. This script will not
fall back to a different linter version.

docker not on PATH at all → exit 1 with the analogous "docker is
required to run the pinned linter" message. Neither path falls back to
the PATH binary.

Requirement 6: timings

Measured on this host, whole-repo lint:

run before (PATH 2.10.1) after (pinned image)
cold cache 22.7s 55.3s (first run: pulls nothing, fills a fresh module + build cache)
warm 1.8s 2.7s

The steady-state cost is ~0.9s of container startup. The container mounts
persistent GOCACHE, GOMODCACHE and GOLANGCI_LINT_CACHE directories
under ${XDG_CACHE_HOME:-~/.cache}/vaultik-lint and runs as the invoking
uid/gid (--user "$(id -u):$(id -g)"), so caches persist across runs and
nothing in the worktree or the cache ends up root-owned.

make check end to end: 22.8s.

Verification

  • script/cibuildEXIT=0. Not a cache no-op: the lint stage printed
    0 issues. and the test stage printed all 14 ok package lines with
    none (cached).
  • make check → exit 0 (tests, pinned lint 0 issues., fmt-check).
  • make fmt run; script/lint-fix exercised (exit 0, no files changed).
  • .golangci.yml sha256 unchanged.
Closes #78. `script/lint` now runs the digest-pinned `golangci-lint` image instead of whatever binary happens to be on `PATH`, so `make lint` and CI cannot disagree about findings. ## What changed - **`script/lint`** extracts the image reference (tag **and** digest) from the `Dockerfile` lint stage `FROM` line with `awk` and runs that exact image via `docker run`. Extra arguments are passed through to `golangci-lint run`. - **Single source of truth: `Dockerfile` line 8** (the lint stage `FROM`). Docker requires a literal in `FROM`, so that line is the only place that can hold the reference; everything else derives from it. Bumping the linter is now a one-line edit there. - **Duplicate pins removed** rather than kept in sync: the `Makefile` `deps` target no longer does `go install ...@v2.12.2`, and `script/bootstrap` no longer installs an unpinned `golangci-lint` from the distro package manager (issue DoD item 3). With linting containerized, a second copy on `PATH` can only drift. `bootstrap` now prints a notice if `docker` is missing. - **In-container execution.** `Dockerfile` runs `make lint` *inside* the lint image, where there is no Docker daemon. `script/lint` runs the `PATH` binary only when `golangci-lint version` reports a version exactly equal to the pin parsed from the `FROM` line — the same linter by definition. Any other version, or none, goes through Docker. - **No-Docker case** is a hard error naming the required image; there is no path from a mismatched `PATH` binary to a passing lint. - **`script/lint-fix`** delegates to `script/lint --fix`, so autofixes come from the pinned linter too. `script/check`, `script/precommit`, `make lint` and the `Dockerfile` lint stage were the callers; all keep working unchanged (`script/lint` with no arguments behaves as before). - **README Entrypoints** documents that `make check` is authoritative *because* `script/lint` uses the pinned linter, and calls out `script/cibuild` as the full CI-equivalent gate. `TODO.md` updated in the same commit. Not touched: `.golangci.yml` (sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`, verified before push) and the `gomodguard` deprecation warning. ## Requirement 4: proof it catches the real regression Reproduced the exact failure from PR #77 / commit `b960ca3`: the four `//nolint:gosec` directives that 2.10.1 requires and 2.12.2 reports as unused were temporarily reinstated (`internal/log/log.go:73`, `internal/ui/ui.go:116`, `internal/vaultik/verify.go:317` and `:329`), then removed again. Old `script/lint` (the pre-change version from `origin/main`, ambient 2.10.1 on `PATH`) on that tree: ``` 0 issues. OLD_EXIT=0 ``` New `make lint` (pinned 2.12.2 image) on the same tree, same host, same 2.10.1 still installed: ``` internal/log/log.go:73:44: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint) internal/ui/ui.go:116:38: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint) internal/vaultik/verify.go:317:27: directive `//nolint:gosec // G703: path from os.CreateTemp` is unused for linter "gosec" (nolintlint) internal/vaultik/verify.go:329:27: directive `//nolint:gosec // G703: path from os.CreateTemp` is unused for linter "gosec" (nolintlint) 4 issues: * nolintlint: 4 make: *** [Makefile:44: lint] Error 1 NEW_EXIT=2 ``` Green locally, red in CI → red locally. The temporary directives were reverted (`git checkout --`) and the tree is clean; they are not in the commit. Worth noting for the record: with the ambient 2.10.1, the *old* `script/lint` on unmodified `main` reports `4 issues: gosec: 4` — i.e. today `make lint` is also red on a tree CI lints clean. The drift cuts both ways, and both directions are gone now. ## Requirement 5: no-Docker behavior Daemon unreachable (`DOCKER_HOST=unix:///nonexistent/docker.sock`) → exit 1: ``` lint: the docker daemon is not reachable, so the pinned linter cannot run. pinned image: golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 Start the daemon (and check DOCKER_HOST / your group membership), or install golangci-lint 2.12.2 on PATH. This script will not fall back to a different linter version. ``` `docker` not on `PATH` at all → exit 1 with the analogous "docker is required to run the pinned linter" message. Neither path falls back to the `PATH` binary. ## Requirement 6: timings Measured on this host, whole-repo lint: | run | before (`PATH` 2.10.1) | after (pinned image) | | --- | --- | --- | | cold cache | 22.7s | 55.3s (first run: pulls nothing, fills a fresh module + build cache) | | warm | 1.8s | 2.7s | The steady-state cost is ~0.9s of container startup. The container mounts persistent `GOCACHE`, `GOMODCACHE` and `GOLANGCI_LINT_CACHE` directories under `${XDG_CACHE_HOME:-~/.cache}/vaultik-lint` and runs as the invoking uid/gid (`--user "$(id -u):$(id -g)"`), so caches persist across runs and nothing in the worktree or the cache ends up root-owned. `make check` end to end: 22.8s. ## Verification - `script/cibuild` → `EXIT=0`. Not a cache no-op: the lint stage printed `0 issues.` and the test stage printed all 14 `ok` package lines with none `(cached)`. - `make check` → exit 0 (tests, pinned lint `0 issues.`, fmt-check). - `make fmt` run; `script/lint-fix` exercised (exit 0, no files changed). - `.golangci.yml` sha256 unchanged.
clawbot added 1 commit 2026-08-09 04:40:36 +02:00
Run the linter at the pinned version locally too (closes #78)
All checks were successful
check / check (pull_request) Successful in 2m18s
1808773195
`script/lint` ran whatever `golangci-lint` was on `PATH` while CI ran
the digest-pinned image from the `Dockerfile` lint stage. The two
versions disagree about real findings, so `make check` could be green
on a tree CI fails - and, on this host's 2.10.1, red on a tree CI
passes. A gate that can differ from CI is not a gate.

`script/lint` now runs the pinned image itself. The single source of
truth for the linter version is the `Dockerfile` lint stage `FROM`
line: `script/lint` parses the image reference (tag AND digest) out of
it with awk and runs exactly that image, so bumping the linter is a
one-line edit there and nowhere else. The duplicate pin in the
`Makefile` `deps` target (`go install ...@v2.12.2`) and the unpinned
`golangci-lint` install in `script/bootstrap` are removed rather than
kept in sync: with linting containerized, a second copy on `PATH` is
only a way to drift.

A `golangci-lint` on `PATH` is used only when its version is exactly
equal to the pin - the same binary by definition, and the case that
matters is the lint stage itself, which runs `make lint` inside the
pinned container where no Docker daemon exists. Every other version
goes through Docker, and a missing or unreachable daemon is a hard
error naming the required image, never a silent fallback.

The container run mounts persistent `GOCACHE`, `GOMODCACHE` and
`GOLANGCI_LINT_CACHE` directories under `${XDG_CACHE_HOME:-~/.cache}`
and runs as the invoking uid/gid, so repeat runs stay fast (2.7s warm
vs 1.8s for the ambient binary) and nothing lands root-owned.
`script/lint-fix` delegates to `script/lint --fix` so autofixes come
from the same pinned linter.

README documents that `make check` is authoritative because of this,
and points at `script/cibuild` as the full CI-equivalent gate.
Author
Collaborator

Gate results for this branch (commit 1808773):

  • script/cibuildEXIT=0. Real run, not a cache no-op: the
    lint stage printed 0 issues. and the test stage printed all 14 ok
    package lines, none (cached). 2m32s wall.
  • make check → exit 0 (14 packages ok, pinned lint 0 issues.,
    fmt-check clean), 22.8s.
  • .golangci.yml sha256 before push:
    021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
    (unchanged, not in the diff).

One correction to the body: the single source of truth is Dockerfile
line 9 — the FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b2... AS lint line — not line 8; the comment block above it shifted the
numbering. script/lint locates it structurally
($1 == "FROM" && $3 == "AS" && $4 == "lint"), so it does not depend on
the line number.

Reviewer's shortcut for requirement 4: add //nolint:gosec to the end of
internal/log/log.go:73 and run make lint — it must fail with a
nolintlint "directive ... is unused" finding even though this host has
golangci-lint 2.10.1 on PATH, which does need that directive.

Gate results for this branch (commit `1808773`): - `script/cibuild` → **`EXIT=0`**. Real run, not a cache no-op: the lint stage printed `0 issues.` and the test stage printed all 14 `ok` package lines, none `(cached)`. 2m32s wall. - `make check` → exit 0 (14 packages `ok`, pinned lint `0 issues.`, fmt-check clean), 22.8s. - `.golangci.yml` sha256 before push: `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb` (unchanged, not in the diff). One correction to the body: the single source of truth is `Dockerfile` **line 9** — the `FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b2... AS lint` line — not line 8; the comment block above it shifted the numbering. `script/lint` locates it structurally (`$1 == "FROM" && $3 == "AS" && $4 == "lint"`), so it does not depend on the line number. Reviewer's shortcut for requirement 4: add `//nolint:gosec` to the end of `internal/log/log.go:73` and run `make lint` — it must fail with a `nolintlint` "directive ... is unused" finding even though this host has golangci-lint 2.10.1 on `PATH`, which does need that directive.
clawbot added the needs-review label 2026-08-09 04:41:36 +02:00
clawbot self-assigned this 2026-08-09 04:41:37 +02:00
clawbot added this to the 1.0.0 milestone 2026-08-09 04:41:37 +02:00
Author
Collaborator

Review of PR #79 (head 1808773)

Verdict: PASS. No blocking findings. Every claim in the PR body was
re-verified independently; all of it holds. Six non-blocking findings
are listed at the end.


Gate verification (independent, on a detached worktree of origin/fix-pinned-linter)

  • Full uncached build: docker build --no-cache .exit 0.
    Not a cache no-op. Lint stage #17 [lint 8/8] RUN make lint printed
    0 issues. in 47.2s; test stage #24 [builder 8/9] RUN make test
    printed 14 ok package lines, none (cached), plus the 4
    [no test files] packages, in 52.8s.
  • script/cibuildEXIT=0 (captured immediately).
  • make checkexit 0 (14 ok, 0 issues., fmt-check clean).
  • make fmt → no files changed; git status --porcelain empty.
  • CI on head 1808773: check / check (pull_request)success,
    2m18s.
  • Mergeable: origin/main (e496aa3) is an ancestor of 1808773;
    Gitea reports mergeable: true. No rebase needed.

Definition of done (issue #78)

DoD Result
1. make lint uses the pinned linter (option (a)) Verified. script/lint runs the digest-pinned image; the run emits the gomodguard deprecation warning that only exists from v2.12.0, proving 2.12.2 executed while 2.10.1 is on this host's PATH.
2. Pin in exactly one place Verified. grep -rn '2\.12\.2' over the tree finds the version only in Dockerfile:8 (comment) and Dockerfile:9 (the FROM), plus prose in TODO.md history and script/lint doc comments. No second executable pin survives in Makefile, script/bootstrap, .gitea/workflows/check.yml, or README.md.
3. script/bootstrap no longer installs an unpinned linter Verified. See finding 2 for the consequence.
4. Catches the real regression Reproduced independently. See below.
5. README Entrypoints documents both claims Verified.
6. script/cibuild exits 0 Verified, uncached.

Requirement 4 reproduced (not taken on faith)

I reinstated the four historical directives from b960ca3
(internal/log/log.go:73, internal/ui/ui.go:116,
internal/vaultik/verify.go:317 and :329) and ran both linters on the
identical tree:

$ make lint                       # this branch, pinned 2.12.2 image
internal/log/log.go:73:44: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint)
internal/ui/ui.go:116:38: ... (nolintlint)
internal/vaultik/verify.go:317:27: ... (nolintlint)
internal/vaultik/verify.go:329:27: ... (nolintlint)
4 issues:
* nolintlint: 4
NEW_EXIT=2

$ golangci-lint run ./...         # old behavior, ambient 2.10.1
0 issues.
OLD_EXIT=0

Directives reverted with git checkout --; tree confirmed clean
afterwards, and the review worktree was removed. The PR does what it
claims.

The escape hatch — attacked, holds

main() runs the PATH binary when
[ "$(installed_version)" = "$(pinned_version)" ]. I tried to turn this
back into the bug it fixes and could not produce a silent false green:

  • pinned_version on the real FROM line → 2.12.2 (verified
    directly). installed_version against this host's binary → 2.10.1
    (verified). The awk picks the first bare N.N.N field, which is
    the version; the trailing go1.26.5 and the built ... on timestamp
    cannot match ahead of it.
  • Both-empty case: I constructed a Dockerfile whose lint FROM is
    golangci/golangci-lint:v-alpine@sha256:..., which does drive
    pinned_version to the empty string, and ran it with no
    golangci-lint on PATH. Result: exec: golangci-lint: not found,
    exit 127 — a loud failure, not a pass. The only way to reach a
    silent wrong-linter run is for the FROM line and the version
    banner to be simultaneously unparseable, which I could not construct
    from any realistic input.
  • Digest-only FROM (golangci-lint@sha256:..., no tag) yields the
    non-version string golangci/golangci, which can never equal an
    installed version → always Docker. Fail-safe.
  • Registry-with-port (registry:5000/img:v2.12.2-alpine) parses
    correctly because s/.*:// is greedy and the digest is stripped
    first.
  • Version mismatch in the container is fail-loud, not fail-open: if
    installed_version ever fails to parse, the script goes to Docker,
    and inside the lint image there is no daemon, so the build fails
    visibly. Confirmed the in-container path is load-bearing and works:
    the uncached lint stage ran make lint natively and printed
    0 issues. (golangci-lint version inside the pinned image prints a
    bare 2.12.2).

The hatch does reduce the guarantee from digest identity to version-
string identity
for a developer who happens to have exactly 2.12.2 on
PATH (e.g. anyone who ran the old make deps). That is precisely
option (b) from the issue's own definition of done, applied only as
a narrow secondary path, so it is within scope. See finding 1 for the
hardening I would want.

No-Docker behavior

Both branches verified as hard errors:

  • DOCKER_HOST=unix:///nonexistent/docker.sock ./script/lint
    exit 1, "the docker daemon is not reachable", image reference
    printed.
  • PATH stripped of dockerexit 1, "docker is required to
    run the pinned linter".

Neither falls back to the 2.10.1 binary sitting on this host.

script/lint-fix and container mechanics

  • Introduced a real autofixable whitespace/wsl_v5 defect in
    internal/globals/globals.go, ran ./script/lint-fix: the file was
    actually rewritten in place (1840 → 1839 bytes) and ownership
    stayed user:user, not root. Argument pass-through
    (script/lint --fix "$@"golangci-lint run --fix ./...) works.
  • Exit-code propagation through exec docker run is correct
    (NEW_EXIT=2 above).
  • GOFLAGS=-buildvcs=false masks nothing lint-relevant; it only
    suppresses VCS stamping, which no linter consumes.
  • Cache dirs under ${XDG_CACHE_HOME:-~/.cache}/vaultik-lint are
    created by the invoking user before the mount; warm run measured
    2.18s.
  • .dockerignore does not exclude Dockerfile, so lint_image()
    resolves inside the lint stage. Confirmed by the successful uncached
    build.

Policy and hygiene

  • .golangci.yml sha256 = 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
    matches, and the file is not in the diff. The gomodguard
    deprecation was correctly left alone (still emitted on every run).
  • Single commit. Title ends with (closes #78). Body wrapped at
    ≤72 columns. No attribution trailers of any kind. No vendor
    references anywhere in the commit, diff, or PR body.
  • Scripts are POSIX sh, set -eu, root located via
    $(cd "$(dirname "$0")/.." && pwd -P), per REPO_POLICIES.md.
  • No scope creep: 7 files, all in service of #78.
  • Inclusive terminology: clean.
  • Naming: no stutter; lint_image / pinned_version /
    installed_version / require_docker / run_in_docker read
    consistently with the surrounding scripts.

Non-blocking findings

  1. script/lint:119 — the hatch is version-gated, not
    context-gated.
    The reason the native path exists is "we are inside
    the lint container", but the condition tested is "the version string
    matches". Those are not the same predicate, and the second one admits
    a developer's locally built 2.12.2 (different Go toolchain, different
    build) that the digest pin exists to exclude. Acceptable looks
    like:
    additionally require that we are in the lint container —
    [ -f /.dockerenv ], or an env var the Dockerfile sets before
    RUN make lint — so the ambient path is unreachable on a
    developer workstation and version equality is only the secondary
    check. This preserves today's behavior in CI and closes the last
    ambient path.

  2. script/bootstrap:61-66 — bootstrap no longer produces a
    machine on which make check can run.
    Docker is now a hard
    dependency of script/lint, therefore of script/check and
    script/precommit (you cannot even commit without it), yet bootstrap
    only warns and still exits 0 printing bootstrap complete. That
    contradicts the script's own header ("install all dependencies needed
    to build and develop this repo") and REPO_POLICIES.md ("installs
    all dependencies idempotently and assumes nothing is present"). The
    message is also not actionable and reads as two fragments:
    docker not found; script/lint needs it to run /
    the pinned linter (see the Dockerfile lint stage). Acceptable
    looks like:
    say what breaks and what to do — e.g. "docker not
    found: make lint, make check and the pre-commit hook will fail
    until Docker is installed and its daemon is running" — and
    either install it via pkg_install where that is meaningful, or
    exit nonzero so make bootstrap does not claim success.

  3. script/lint:39-50 — hand-rolled scraping of a human-readable
    banner where a machine-readable flag exists.
    golangci-lint version --short prints exactly 2.10.1 on this host and exactly 2.12.2
    inside the pinned image (both verified). The current awk depends on
    the version being printed without a leading v and as the first bare
    N.N.N token; golangci-lint has historically printed v1.55.2 in
    that position. If a future bump restores the v, installed_version
    returns empty, the in-container path is lost, and the Dockerfile
    lint stage starts failing with "docker is required" — a
    confusing failure for a version bump. Acceptable looks like:
    golangci-lint version --short with the awk kept only as a fallback.

  4. TODO.md — "make check is therefore now as trustworthy as
    script/cibuild" overstates the result
    , and contradicts the
    README.md text added by the same commit, which correctly says
    script/cibuild "also catches anything that depends on host state".
    make check still runs tests and gofmt on the host, not in the
    pinned golang:1.26.1-alpine image. Only the lint leg is now
    equivalent. Acceptable looks like: "make check and CI can no
    longer disagree about lint findings; script/cibuild remains the
    full gate."

  5. README.md ## requirements still lists only Go 1.26+ and object
    storage. Docker is now required to lint, check, or commit. The
    Entrypoints section says so under script/lint, which satisfies DoD
    item 5, but a developer reading the requirements list will not learn
    it. Acceptable looks like: add "Docker (for make lint / make check; the linter runs from a digest-pinned image)".

  6. PR body says "single source of truth: Dockerfile line 8"; the
    FROM is on line 9 (line 8 is the dated comment). Already
    corrected in a comment, and script/lint locates the line
    structurally rather than by number, so nothing functional depends on
    it — the body text is simply still wrong.

None of the six blocks the merge. Findings 1 and 2 are worth a
follow-up issue.

## Review of PR #79 (head `1808773`) **Verdict: PASS.** No blocking findings. Every claim in the PR body was re-verified independently; all of it holds. Six non-blocking findings are listed at the end. --- ### Gate verification (independent, on a detached worktree of `origin/fix-pinned-linter`) - **Full uncached build**: `docker build --no-cache .` → **exit 0**. Not a cache no-op. Lint stage `#17 [lint 8/8] RUN make lint` printed `0 issues.` in 47.2s; test stage `#24 [builder 8/9] RUN make test` printed **14 `ok` package lines**, none `(cached)`, plus the 4 `[no test files]` packages, in 52.8s. - `script/cibuild` → **`EXIT=0`** (captured immediately). - `make check` → **exit 0** (14 `ok`, `0 issues.`, fmt-check clean). - `make fmt` → no files changed; `git status --porcelain` empty. - CI on head `1808773`: `check / check (pull_request)` → **success**, 2m18s. - Mergeable: `origin/main` (`e496aa3`) is an ancestor of `1808773`; Gitea reports `mergeable: true`. No rebase needed. ### Definition of done (issue #78) | DoD | Result | | --- | --- | | 1. `make lint` uses the pinned linter (option (a)) | Verified. `script/lint` runs the digest-pinned image; the run emits the `gomodguard` deprecation warning that only exists from v2.12.0, proving 2.12.2 executed while 2.10.1 is on this host's `PATH`. | | 2. Pin in exactly one place | Verified. `grep -rn '2\.12\.2'` over the tree finds the version only in `Dockerfile:8` (comment) and `Dockerfile:9` (the `FROM`), plus prose in `TODO.md` history and `script/lint` doc comments. No second executable pin survives in `Makefile`, `script/bootstrap`, `.gitea/workflows/check.yml`, or `README.md`. | | 3. `script/bootstrap` no longer installs an unpinned linter | Verified. See finding 2 for the consequence. | | 4. Catches the real regression | **Reproduced independently.** See below. | | 5. README Entrypoints documents both claims | Verified. | | 6. `script/cibuild` exits 0 | Verified, uncached. | ### Requirement 4 reproduced (not taken on faith) I reinstated the four historical directives from `b960ca3` (`internal/log/log.go:73`, `internal/ui/ui.go:116`, `internal/vaultik/verify.go:317` and `:329`) and ran both linters on the identical tree: ``` $ make lint # this branch, pinned 2.12.2 image internal/log/log.go:73:44: directive `//nolint:gosec // G115: fd fits in int` is unused for linter "gosec" (nolintlint) internal/ui/ui.go:116:38: ... (nolintlint) internal/vaultik/verify.go:317:27: ... (nolintlint) internal/vaultik/verify.go:329:27: ... (nolintlint) 4 issues: * nolintlint: 4 NEW_EXIT=2 $ golangci-lint run ./... # old behavior, ambient 2.10.1 0 issues. OLD_EXIT=0 ``` Directives reverted with `git checkout --`; tree confirmed clean afterwards, and the review worktree was removed. **The PR does what it claims.** ### The escape hatch — attacked, holds `main()` runs the `PATH` binary when `[ "$(installed_version)" = "$(pinned_version)" ]`. I tried to turn this back into the bug it fixes and could not produce a silent false green: - `pinned_version` on the real `FROM` line → `2.12.2` (verified directly). `installed_version` against this host's binary → `2.10.1` (verified). The awk picks the **first** bare `N.N.N` field, which is the version; the trailing `go1.26.5` and the `built ... on` timestamp cannot match ahead of it. - **Both-empty case**: I constructed a `Dockerfile` whose lint `FROM` is `golangci/golangci-lint:v-alpine@sha256:...`, which does drive `pinned_version` to the empty string, and ran it with no `golangci-lint` on `PATH`. Result: `exec: golangci-lint: not found`, **exit 127** — a loud failure, not a pass. The only way to reach a silent wrong-linter run is for the `FROM` line *and* the version banner to be simultaneously unparseable, which I could not construct from any realistic input. - **Digest-only `FROM`** (`golangci-lint@sha256:...`, no tag) yields the non-version string `golangci/golangci`, which can never equal an installed version → always Docker. Fail-safe. - **Registry-with-port** (`registry:5000/img:v2.12.2-alpine`) parses correctly because `s/.*://` is greedy and the digest is stripped first. - **Version mismatch in the container is fail-loud, not fail-open**: if `installed_version` ever fails to parse, the script goes to Docker, and inside the lint image there is no daemon, so the build fails visibly. Confirmed the in-container path is load-bearing and works: the uncached lint stage ran `make lint` natively and printed `0 issues.` (`golangci-lint version` inside the pinned image prints a bare `2.12.2`). The hatch does reduce the guarantee from *digest identity* to *version- string identity* for a developer who happens to have exactly 2.12.2 on `PATH` (e.g. anyone who ran the old `make deps`). That is precisely option **(b)** from the issue's own definition of done, applied only as a narrow secondary path, so it is within scope. See finding 1 for the hardening I would want. ### No-Docker behavior Both branches verified as hard errors: - `DOCKER_HOST=unix:///nonexistent/docker.sock ./script/lint` → **exit 1**, "the docker daemon is not reachable", image reference printed. - `PATH` stripped of `docker` → **exit 1**, "docker is required to run the pinned linter". Neither falls back to the 2.10.1 binary sitting on this host. ### `script/lint-fix` and container mechanics - Introduced a real autofixable `whitespace`/`wsl_v5` defect in `internal/globals/globals.go`, ran `./script/lint-fix`: the file was **actually rewritten in place** (1840 → 1839 bytes) and ownership stayed `user:user`, **not root**. Argument pass-through (`script/lint --fix "$@"` → `golangci-lint run --fix ./...`) works. - Exit-code propagation through `exec docker run` is correct (`NEW_EXIT=2` above). - `GOFLAGS=-buildvcs=false` masks nothing lint-relevant; it only suppresses VCS stamping, which no linter consumes. - Cache dirs under `${XDG_CACHE_HOME:-~/.cache}/vaultik-lint` are created by the invoking user before the mount; warm run measured 2.18s. - `.dockerignore` does not exclude `Dockerfile`, so `lint_image()` resolves inside the lint stage. Confirmed by the successful uncached build. ### Policy and hygiene - `.golangci.yml` sha256 = `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb` — **matches**, and the file is not in the diff. The `gomodguard` deprecation was correctly left alone (still emitted on every run). - Single commit. Title ends with ` (closes #78)`. Body wrapped at ≤72 columns. No attribution trailers of any kind. No vendor references anywhere in the commit, diff, or PR body. - Scripts are POSIX sh, `set -eu`, root located via `$(cd "$(dirname "$0")/.." && pwd -P)`, per `REPO_POLICIES.md`. - No scope creep: 7 files, all in service of #78. - Inclusive terminology: clean. - Naming: no stutter; `lint_image` / `pinned_version` / `installed_version` / `require_docker` / `run_in_docker` read consistently with the surrounding scripts. --- ## Non-blocking findings 1. **`script/lint:119` — the hatch is version-gated, not context-gated.** The reason the native path exists is "we are inside the lint container", but the condition tested is "the version string matches". Those are not the same predicate, and the second one admits a developer's locally built 2.12.2 (different Go toolchain, different build) that the digest pin exists to exclude. *Acceptable looks like:* additionally require that we are in the lint container — `[ -f /.dockerenv ]`, or an env var the `Dockerfile` sets before `RUN make lint` — so the ambient path is unreachable on a developer workstation and version equality is only the secondary check. This preserves today's behavior in CI and closes the last ambient path. 2. **`script/bootstrap:61-66` — bootstrap no longer produces a machine on which `make check` can run.** Docker is now a hard dependency of `script/lint`, therefore of `script/check` and `script/precommit` (you cannot even commit without it), yet bootstrap only warns and still exits 0 printing `bootstrap complete`. That contradicts the script's own header ("install all dependencies needed to build and develop this repo") and `REPO_POLICIES.md` ("installs all dependencies idempotently and assumes nothing is present"). The message is also not actionable and reads as two fragments: `docker not found; script/lint needs it to run` / `the pinned linter (see the Dockerfile lint stage)`. *Acceptable looks like:* say what breaks and what to do — e.g. "docker not found: `make lint`, `make check` and the pre-commit hook will fail until Docker is installed and its daemon is running" — and either install it via `pkg_install` where that is meaningful, or exit nonzero so `make bootstrap` does not claim success. 3. **`script/lint:39-50` — hand-rolled scraping of a human-readable banner where a machine-readable flag exists.** `golangci-lint version --short` prints exactly `2.10.1` on this host and exactly `2.12.2` inside the pinned image (both verified). The current awk depends on the version being printed without a leading `v` and as the first bare `N.N.N` token; golangci-lint has historically printed `v1.55.2` in that position. If a future bump restores the `v`, `installed_version` returns empty, the in-container path is lost, and the `Dockerfile` lint stage starts failing with "docker is required" — a confusing failure for a version bump. *Acceptable looks like:* `golangci-lint version --short` with the awk kept only as a fallback. 4. **`TODO.md` — "`make check` is therefore now as trustworthy as `script/cibuild`" overstates the result**, and contradicts the `README.md` text added by the same commit, which correctly says `script/cibuild` "also catches anything that depends on host state". `make check` still runs tests and `gofmt` on the host, not in the pinned `golang:1.26.1-alpine` image. Only the *lint* leg is now equivalent. *Acceptable looks like:* "`make check` and CI can no longer disagree about lint findings; `script/cibuild` remains the full gate." 5. **`README.md` `## requirements`** still lists only Go 1.26+ and object storage. Docker is now required to lint, check, or commit. The Entrypoints section says so under `script/lint`, which satisfies DoD item 5, but a developer reading the requirements list will not learn it. *Acceptable looks like:* add "Docker (for `make lint` / `make check`; the linter runs from a digest-pinned image)". 6. **PR body says "single source of truth: `Dockerfile` line 8"**; the `FROM` is on **line 9** (line 8 is the dated comment). Already corrected in a comment, and `script/lint` locates the line structurally rather than by number, so nothing functional depends on it — the body text is simply still wrong. None of the six blocks the merge. Findings 1 and 2 are worth a follow-up issue.
clawbot added merge-ready and removed needs-review labels 2026-08-09 04:52:11 +02:00
clawbot merged commit af607e3597 into main 2026-08-09 04:52:22 +02:00
clawbot deleted branch fix-pinned-linter 2026-08-09 04:52:22 +02:00
Sign in to join this conversation.