Isolate the lint cache per worktree and context-gate the native lint path #102

Merged
clawbot merged 1 commits from fix-lint-isolation into main 2026-08-09 17:15:07 +02:00
Collaborator

Closes #99. Closes #80.

One defect seen twice: script/lint decided whether it could skip the
pinned image by asking what version was on PATH rather than where it
was running, and cache isolation is part of that same question.

What changed

  1. Per-worktree cache (#99.1). ${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12-hex digest of $ROOT>,
    still persistent, so a warm run stays seconds. The old single
    per-repo directory is what let two checkouts with identical Go
    contents collide and replay each other's stored analysis.
  2. Foreign-path audit (#99.2) — new script/lint-audit, run on
    every lint. Rejects output citing any file not in the tree being
    linted: absolute paths outside the root, .. components, and
    relative paths that do not exist here. Runs on clean output too,
    because the unearned-pass direction is the silent one. It never
    certifies that a run passed — it does not look at whether there were
    findings — so it cannot itself become a gate reporting a green.
    Split out as its own script so it is directly exercisable against a
    saved capture, which is how it was verified.
  3. Lock error is a retry, not a verdict (#99.3). Per #88 a private
    cache does not remove the contention. parallel golangci-lint is running is retried up to 6 times, 15s apart; exhausting them fails
    saying explicitly that the tree was never analysed.
  4. Bounded growth (#99.4). Each cache records its worktree and is
    collected when that worktree disappears, so throwaway worktrees do
    not accumulate caches. The tree sits under XDG_CACHE_HOME and is
    disposable; the removal command is in the script's comment and in the
    audit's rejection message.
  5. Context gate (#80.1). The native path now requires
    VAULTIK_LINT_IN_CONTAINER=1, set only by the Dockerfile lint
    stage, and a matching version. /.dockerenv was rejected as the
    signal: dockerd creates it for docker run, but it is not reliably
    present during a BuildKit docker build, which is exactly the case
    the exception exists for. Inside the container a version mismatch is
    now a hard error rather than a fall-through — there is no daemon
    there to fall through to.
  6. Bootstrap (#80.2). Docker missing or its daemon unreachable is
    now a hard failure naming script/lint, script/check,
    script/precommit and script/cibuild, instead of a warning
    followed by bootstrap complete. Installing docker from bootstrap
    was rejected: root, a daemon, and on macOS a GUI cask, so the attempt
    would itself fail in the common case and trade one false success for
    a second failure mode.
  7. version --short (#80.3) replaces the banner scrape, which
    survives only as a fallback.
  8. #80.4/#80.5: TODO.md's "as trustworthy as script/cibuild"
    corrected to match README.md (only the lint leg is equivalent);
    README.md requirements gained docker and sqlite3.

Verification (reproduction, not inspection)

  • Two concurrent lints, two worktrees, differing cleanliness
    /tmp/impl-99 (clean) and /tmp/impl-99-dirty (one file with a
    revive and an unused finding), started together. Clean: 0 issues., exit 0. Dirty: exactly 2 findings, both citing
    internal/blobgen/lintbait.go in its own tree, exit 2. Neither log
    contained parallel golangci-lint is running. No cross-contamination
    in either direction.
  • Foreign-path guard, end to end — a real script/lint run in the
    dirty worktree made to report outside paths reproduced the #99
    signature (../impl-91/internal/blobgen/lintbait.go) and exited 1
    with the REJECTED diagnostic, instead of reporting the findings. The
    auditor was also run directly against the verbatim output from #99
    (exit 1), a foreign path without a .. component (exit 1), a real
    clean capture (exit 0), and a container-absolute /src/... path
    (exit 0).
  • Context gate — with a golangci-lint shim on PATH reporting
    2.12.2 and logging every invocation, make lint never invoked it
    and ran the pinned image. With VAULTIK_LINT_IN_CONTAINER=1 the shim
    was invoked (version --short, then run ./...), confirming that
    variable is the only door. A mismatched version with that variable set
    exits 1 naming both versions.
  • BootstrapPATH without docker: bootstrap: FAILED - docker is not installed., exit 1, zero occurrences of bootstrap complete. A
    docker shim whose info fails: bootstrap: FAILED - the docker daemon is not reachable., exit 1. Unchanged happy path on this host:
    exit 0, bootstrap complete.
  • In-container pathscript/cibuild exit 0, 3m24s wall. Cache
    control: no prune, no --no-cache; the ENV added to the lint stage
    invalidates everything below it, and the 11 CACHED layers are all
    dependency/module layers. [lint 9/9] ... make lint executed for
    78.7s inside the pinned image and printed 0 issues., which is what
    proves the native path still works with no daemon; [builder 9/10] ... make test executed with all 14 ok lines and no (cached).
  • make check — exit 0, 18.6s: 14 ok lines, zero (cached),
    0 issues., fmt-check clean.
  • Warm lint timing — before 2.9s, after 4.3s (cold 1m44s → 1m24s).
    The added ~1.4s is the tee capture plus the audit pass over the
    output; the inner loop is still seconds.
  • .golangci.yml unchanged, sha256 verified before push:
    021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.
    The lint-stage FROM line and digest, the ARG CHECK_EPOCH guard
    structure, REPO_POLICIES.md and script/test are untouched.

Known limitation, deliberately not papered over

script/lint bind-mounts the tree at /src, so two containerized runs
of different checkouts both call themselves /src. Contamination
between two container runs is therefore not distinguishable by path
alone, and the audit cannot catch it — which is why the per-worktree
cache key, not the audit, is the primary fix. The audit catches the
signature actually observed in #99 and any host-side path leakage. This
is documented at the top of script/lint-audit.

--allow-parallel-runners was considered instead of the retry and
rejected: it disables the lock that protects a cache shared by two runs
of the same worktree, which is a real configuration, and it would trade
a delay for corruption.

Closes #99. Closes #80. One defect seen twice: `script/lint` decided whether it could skip the pinned image by asking what version was on `PATH` rather than where it was running, and cache isolation is part of that same question. ## What changed 1. **Per-worktree cache (#99.1).** `${XDG_CACHE_HOME:-~/.cache}/vaultik-lint/<slug>-<12-hex digest of $ROOT>`, still persistent, so a warm run stays seconds. The old single per-repo directory is what let two checkouts with identical Go contents collide and replay each other's stored analysis. 2. **Foreign-path audit (#99.2)** — new `script/lint-audit`, run on every lint. Rejects output citing any file not in the tree being linted: absolute paths outside the root, `..` components, and relative paths that do not exist here. Runs on clean output too, because the unearned-**pass** direction is the silent one. It never certifies that a run passed — it does not look at whether there were findings — so it cannot itself become a gate reporting a green. Split out as its own script so it is directly exercisable against a saved capture, which is how it was verified. 3. **Lock error is a retry, not a verdict (#99.3).** Per #88 a private cache does not remove the contention. `parallel golangci-lint is running` is retried up to 6 times, 15s apart; exhausting them fails saying explicitly that the tree was never analysed. 4. **Bounded growth (#99.4).** Each cache records its worktree and is collected when that worktree disappears, so throwaway worktrees do not accumulate caches. The tree sits under `XDG_CACHE_HOME` and is disposable; the removal command is in the script's comment and in the audit's rejection message. 5. **Context gate (#80.1).** The native path now requires `VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile` lint stage, **and** a matching version. `/.dockerenv` was rejected as the signal: `dockerd` creates it for `docker run`, but it is not reliably present during a BuildKit `docker build`, which is exactly the case the exception exists for. Inside the container a version mismatch is now a hard error rather than a fall-through — there is no daemon there to fall through to. 6. **Bootstrap (#80.2).** Docker missing or its daemon unreachable is now a hard failure naming `script/lint`, `script/check`, `script/precommit` and `script/cibuild`, instead of a warning followed by `bootstrap complete`. Installing docker from bootstrap was rejected: root, a daemon, and on macOS a GUI cask, so the attempt would itself fail in the common case and trade one false success for a second failure mode. 7. **`version --short` (#80.3)** replaces the banner scrape, which survives only as a fallback. 8. **#80.4/#80.5**: `TODO.md`'s "as trustworthy as `script/cibuild`" corrected to match `README.md` (only the lint leg is equivalent); `README.md` requirements gained docker and `sqlite3`. ## Verification (reproduction, not inspection) * **Two concurrent lints, two worktrees, differing cleanliness** — `/tmp/impl-99` (clean) and `/tmp/impl-99-dirty` (one file with a `revive` and an `unused` finding), started together. Clean: `0 issues.`, exit 0. Dirty: exactly 2 findings, both citing `internal/blobgen/lintbait.go` in its own tree, exit 2. Neither log contained `parallel golangci-lint is running`. No cross-contamination in either direction. * **Foreign-path guard, end to end** — a real `script/lint` run in the dirty worktree made to report outside paths reproduced the #99 signature (`../impl-91/internal/blobgen/lintbait.go`) and exited **1** with the REJECTED diagnostic, instead of reporting the findings. The auditor was also run directly against the verbatim output from #99 (exit 1), a foreign path without a `..` component (exit 1), a real clean capture (exit 0), and a container-absolute `/src/...` path (exit 0). * **Context gate** — with a `golangci-lint` shim on `PATH` reporting `2.12.2` and logging every invocation, `make lint` never invoked it and ran the pinned image. With `VAULTIK_LINT_IN_CONTAINER=1` the shim was invoked (`version --short`, then `run ./...`), confirming that variable is the only door. A mismatched version with that variable set exits 1 naming both versions. * **Bootstrap** — `PATH` without docker: `bootstrap: FAILED - docker is not installed.`, exit 1, zero occurrences of `bootstrap complete`. A `docker` shim whose `info` fails: `bootstrap: FAILED - the docker daemon is not reachable.`, exit 1. Unchanged happy path on this host: exit 0, `bootstrap complete`. * **In-container path** — `script/cibuild` exit 0, 3m24s wall. Cache control: no prune, no `--no-cache`; the `ENV` added to the lint stage invalidates everything below it, and the 11 `CACHED` layers are all dependency/module layers. `[lint 9/9] ... make lint` executed for 78.7s inside the pinned image and printed `0 issues.`, which is what proves the native path still works with no daemon; `[builder 9/10] ... make test` executed with all 14 `ok` lines and no `(cached)`. * **`make check`** — exit 0, 18.6s: 14 `ok` lines, zero `(cached)`, `0 issues.`, `fmt-check` clean. * **Warm lint timing** — before 2.9s, after 4.3s (cold 1m44s → 1m24s). The added ~1.4s is the `tee` capture plus the audit pass over the output; the inner loop is still seconds. * `.golangci.yml` unchanged, sha256 verified before push: `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. The lint-stage `FROM` line and digest, the `ARG CHECK_EPOCH` guard structure, `REPO_POLICIES.md` and `script/test` are untouched. ## Known limitation, deliberately not papered over `script/lint` bind-mounts the tree at `/src`, so two containerized runs of different checkouts both call themselves `/src`. Contamination between two container runs is therefore not distinguishable by path alone, and the audit cannot catch it — which is why the per-worktree cache key, not the audit, is the primary fix. The audit catches the signature actually observed in #99 and any host-side path leakage. This is documented at the top of `script/lint-audit`. `--allow-parallel-runners` was considered instead of the retry and rejected: it disables the lock that protects a cache shared by two runs of the same worktree, which is a real configuration, and it would trade a delay for corruption.
clawbot added 1 commit 2026-08-09 16:48:32 +02:00
Isolate the lint cache per worktree and context-gate the native path (closes #99)
All checks were successful
check / check (pull_request) Successful in 3m11s
426a8d7645
Closes #80.

script/lint decided whether it could skip the pinned image by asking what
version was on PATH rather than where it was running, and cache isolation
is part of that same question. Both issues are that one defect.

The cache was one directory per repo, shared by every worktree on the
host. Two checkouts of this repo have identical Go file contents, so
their cache keys collide and golangci-lint replays the stored analysis,
including the paths recorded when it was produced. The loud direction of
that failure - a clean tree failed by a dirty sibling - is the harmless
one. The silent direction, a dirty tree passed by a clean sibling, is
another way for a gate here to report a green it did not earn.

The cache is now keyed on a digest of the worktree path, so a collision
is not possible, and it stays persistent per worktree: a warm run is
still seconds. Each cache records the worktree it belongs to and is
collected when that worktree is gone, so throwaway worktrees do not
accumulate caches; the tree lives under XDG_CACHE_HOME and is disposable
by definition.

script/lint-audit is the backstop, and runs on every lint: it rejects
output citing any file that is not in the tree being linted, so a result
built out of another checkout's analysis is a hard error instead of a
silent pass. It runs on clean output too, because that is the case
nobody investigates. It never certifies that a run passed - it does not
look at whether there were findings - so it cannot itself become a gate
that reports a green.

golangci-lint's "parallel golangci-lint is running" refusal is now a
bounded retry rather than a verdict. It is not a lint result, and
exiting non-zero on it is indistinguishable to a caller from real
findings; issue #88 measured that a private cache does not remove the
contention. Exhausting the retries fails with a message that says the
tree was never analysed.

The native path now requires VAULTIK_LINT_IN_CONTAINER=1, which only the
Dockerfile's lint stage sets, in addition to matching the pin. A
developer's locally installed 2.12.2 is a different build reached by a
different code path and no longer bypasses the digest pin. /.dockerenv
was considered and rejected as the signal: dockerd creates it for
`docker run`, but it is not reliably present during a BuildKit
`docker build`, which is exactly the case the exception exists for.
Inside the container a version mismatch is now a hard error rather than
a fall-through, since there is no daemon there to fall through to.
Version detection uses `golangci-lint version --short`, the interface
meant for it, keeping the banner scrape only as a fallback.

script/bootstrap no longer prints "bootstrap complete" on a machine that
cannot run the gate. Docker missing, or present with an unreachable
daemon, is a hard failure naming exactly what breaks. Installing docker
from bootstrap was rejected: it needs root, a daemon, and on macOS a GUI
cask, so the attempt would itself fail in the common case and trade one
false success for a second failure mode.

TODO.md's claim that `make check` became "as trustworthy as
script/cibuild" is corrected to what README.md already said: only the
lint leg is equivalent, while tests and gofmt still run against the host
toolchain. README.md's requirements section gains docker and sqlite3.

Verified by reproduction, not inspection: two concurrent lints from two
worktrees of differing cleanliness each reported only their own findings
with no lock error; a real run made to report paths outside its tree
exits 1; a matching linter shimmed onto PATH is never invoked while the
pinned image runs; a PATH without docker makes bootstrap fail. script/
cibuild exits 0 with the lint layer executing in the pinned image, which
is what proves the in-container path still works.
clawbot force-pushed fix-lint-isolation from 426a8d7645 to 043aabbd27 2026-08-09 16:54:34 +02:00 Compare
Author
Collaborator

Addendum to the verification above, because the cache-collection item
(#99.4) failed its own negative control before it passed one, and the
failure is worth recording rather than quietly fixing.

The first cut collected a stale cache with a plain rm -rf. Go's module
cache inside it is deliberately read-only, so rm could not unlink
files out of directories it may not write; it exited non-zero, set -e
took the whole script down, and make lint returned an error on a tree
with zero findings — a gate failing for a housekeeping reason, which is
its own bug and exactly the class this repo keeps paying for. It also
left a half-removed cache with no marker file, which every future run
would have skipped forever.

Fixed: the tree is made writable before removal, a removal that still
fails prints a warning and restores the marker so the cache stays
collectable, and no tidy-up failure can fail a lint.

Re-verified against a stale cache containing a read-only
module-cache-like tree with its marker pointing at a removed worktree:
the cache was collected and the lint exited 0 with 0 issues..
make check re-run: exit 0, 16.2s, 14 ok lines, zero (cached),
0 issues.. script/cibuild re-run: exit 0, 2m43s, [lint 9/9]
executed for 53.3s in the pinned image printing 0 issues., no
--no-cache and no prune used. .golangci.yml sha256 re-verified
before the force-push: 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.

The branch was force-updated to keep this as one commit (426a8d7 to
043aabb).

Addendum to the verification above, because the cache-collection item (#99.4) failed its own negative control before it passed one, and the failure is worth recording rather than quietly fixing. The first cut collected a stale cache with a plain `rm -rf`. Go's module cache inside it is deliberately read-only, so `rm` could not unlink files out of directories it may not write; it exited non-zero, `set -e` took the whole script down, and `make lint` returned an error on a tree with zero findings — a gate failing for a housekeeping reason, which is its own bug and exactly the class this repo keeps paying for. It also left a half-removed cache with no marker file, which every future run would have skipped forever. Fixed: the tree is made writable before removal, a removal that still fails prints a warning and restores the marker so the cache stays collectable, and no tidy-up failure can fail a lint. Re-verified against a stale cache containing a read-only module-cache-like tree with its marker pointing at a removed worktree: the cache was collected and the lint exited 0 with `0 issues.`. `make check` re-run: exit 0, 16.2s, 14 `ok` lines, zero `(cached)`, `0 issues.`. `script/cibuild` re-run: exit 0, 2m43s, `[lint 9/9]` executed for 53.3s in the pinned image printing `0 issues.`, no `--no-cache` and no prune used. `.golangci.yml` sha256 re-verified before the force-push: `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. The branch was force-updated to keep this as one commit (`426a8d7` to `043aabb`).
clawbot added the needs-review label 2026-08-09 16:56:01 +02:00
Author
Collaborator

Review: PASS

Independent review of #102 against
#99 and
#80. Verified by reproduction on this
host, not by inspection. Nothing found that can produce an unearned green; two
fail-closed defects and one accuracy nit below.

Probes run (each reproduced, not assumed)

  • The two-worktree reproduction. Two worktrees of this branch, one clean and
    one carrying a planted unused + nlreturn bait, make lint started
    concurrently under one shared XDG_CACHE_HOME: two distinct cache directories
    (...-9fdd462e03dc, ...-c679b0c15419), clean exit 0 / 0 issues., dirty
    exit 1 with exactly 2 findings both citing its own
    internal/blobgen/rv102bait.go, zero occurrences of the lock message, zero
    foreign paths in either direction. Issue
    #99 does not reproduce on this branch.
  • script/lint-audit, 23 vectors. Rejects the verbatim #99 signature,
    absolute-foreign with no .., mid-path ../, whitespace- and tab-prefixed
    foreign paths, and /src/- or $ROOT/-prefixed paths naming files not present
    here. Accepts /src/-, $ROOT/- and relative-prefixed paths that do exist,
    clean output, empty output. Crucially it also exits 0 on a capture containing
    three real findings, so the "never certifies a pass" design genuinely holds and
    it cannot become a second gate reporting green. Missing/extra arguments exit 2
    and audit_output treats any non-zero as reject, so the auditor fails closed.
  • Context gate. With a logging golangci-lint shim reporting 2.12.2 first
    on PATH, make lint never invoked it (the shim's log file was never created)
    and ran the pinned image. With VAULTIK_LINT_IN_CONTAINER=1 the shim was
    invoked (version --short, then run ./...). Var set plus shim reporting
    2.10.1: exit 1 naming both versions. Var set to true rather than 1: docker
    path. The variable is the only door. Rejecting /.dockerenv is sound - it is a
    dockerd runtime artifact, absent during a BuildKit docker build, which is
    the only case the exception serves.
  • Worth recording: this host has golangci-lint 2.12.2 on PATH, so on
    main script/lint execs the host binary against the global default
    ~/.cache/golangci-lint and never runs the pinned image at all. That is very
    likely the actual mechanism behind the ../impl-91/ paths in
    #99 - host, cwd-relative rendering of
    another worktree's absolute paths - and this PR closes it at the root, so the
    fix is stronger than the issue's own diagnosis.
  • Lock retry. Shim emitting the refusal on every attempt: 6 attempts, 75s,
    exit 1 with the explicit "NOT a lint verdict: the tree was never analysed"
    text. Non-zero and self-describing; not mistakable for a lint result.
  • script/cibuild: exit 0, 185s wall, no prune and no --no-cache. 14
    CACHED layers, all dependency/setup; the check layers executed - #16 make fmt-check, #17 make lint DONE 52.8s printing 0 issues. inside the pinned
    image with no daemon, #25 make test DONE 89.6s with 14 ok lines and 0
    (cached). Fresh CHECK_EPOCH in both stages. Disclosure: my first attempt
    exited 127 because /usr/bin/time is absent here; that run proved nothing and
    was discarded, the figures above are the re-run.
  • Nothing weakened: .golangci.yml sha256 021cc83f4e6f... matches;
    REPO_POLICIES.md, script/test, script/check, script/cibuild,
    script/precommit, script/fmt, Makefile and .gitea/ byte-identical to
    main; lint-stage FROM + digest and both ARG CHECK_EPOCH guards intact; no
    Go file touched; ENV VAULTIK_LINT_IN_CONTAINER=1 is confined to the lint
    stage and inherited by neither builder nor the runtime image.
  • CI green on 043aabb (3m11s); mergeable, merges cleanly against main at
    c51f693; make fmt a no-op; commit title ends (closes #99) and the body
    closes #80; no attribution trailers or vendor references anywhere; inclusive
    terminology, naming and idiom consistent with the surrounding scripts; no scope
    creep.

Non-blocking findings

1. script/lint:174 - a tidy-up failure can still fail a lint, contradicting
the commit message.

owner="$(cat "$dir/worktree")"

This is the one unguarded command in prune_dead_caches; every other operation
in the collection path is || true or if !-guarded. Under set -eu an
assignment whose command substitution fails aborts the script. The commit
message states the opposite ("never lets a failure to tidy up fail the lint"),
and this is the same class as the self-reported rm -rf anomaly.

Demonstrated: with a stale cache whose marker file is unreadable,
./script/lint exits 1 having printed only
cat: .../worktree: Permission denied, never reaching the linter. The realistic
trigger is two concurrent lints collecting the same stale cache - [ -f "$dir/worktree" ] true for B, then A's rm -rf unlinks the marker before B's
cat. I could not win that race in 36 concurrent runs, so the window is narrow,
and the failure direction is closed (a loud lint error, never a false green) -
which is why this is not blocking.

Acceptable: owner="$(cat "$dir/worktree" 2>/dev/null || true)". The existing
[ -z "$owner" ] check already handles the empty result.

2. script/lint-audit:63-66 - a genuine dependency error is reported as cache
contamination.

Any absolute path not under $ROOT or /src is classed foreign. Inside the
container GOMODCACHE=/cache/go-mod, so a typecheck finding citing a
dependency source file is rejected: verified,
/cache/go-mod/example.com/x@v1.0.0/a.go:3:1: msg (typecheck) exits 1 with "the
linter reported findings for files that are not in this tree ... clear this
tree's lint cache". That is the wrong diagnosis for a real dependency
compile error, and it sends the reader to clear a cache that is not the cause.
Fails closed, so not blocking.

Acceptable: exempt /cache/go-mod/ and /cache/go-build/ (the paths
script/lint itself sets) from the foreign set, or soften the message when every
foreign path lies under the cache mount.

Nit

  • The PR body and the #99 comment report
    the dirty worktree exiting 2. Observed here: exit 1 with 2 findings,
    which is golangci-lint's default issues-exit-code. Immaterial to the outcome,
    but a verification narrative in this repo should match what the tool actually
    did.
## Review: PASS Independent review of https://git.eeqj.de/sneak/vaultik/pulls/102 against https://git.eeqj.de/sneak/vaultik/issues/99 and https://git.eeqj.de/sneak/vaultik/issues/80. Verified by reproduction on this host, not by inspection. Nothing found that can produce an unearned green; two fail-closed defects and one accuracy nit below. ### Probes run (each reproduced, not assumed) - **The two-worktree reproduction.** Two worktrees of this branch, one clean and one carrying a planted `unused` + `nlreturn` bait, `make lint` started concurrently under one shared `XDG_CACHE_HOME`: two distinct cache directories (`...-9fdd462e03dc`, `...-c679b0c15419`), clean exit 0 / `0 issues.`, dirty exit 1 with exactly 2 findings both citing its own `internal/blobgen/rv102bait.go`, zero occurrences of the lock message, zero foreign paths in either direction. Issue https://git.eeqj.de/sneak/vaultik/issues/99 does not reproduce on this branch. - **`script/lint-audit`, 23 vectors.** Rejects the verbatim #99 signature, absolute-foreign with no `..`, mid-path `../`, whitespace- and tab-prefixed foreign paths, and `/src/`- or `$ROOT/`-prefixed paths naming files not present here. Accepts `/src/`-, `$ROOT/`- and relative-prefixed paths that do exist, clean output, empty output. Crucially it also exits 0 on a capture containing three real findings, so the "never certifies a pass" design genuinely holds and it cannot become a second gate reporting green. Missing/extra arguments exit 2 and `audit_output` treats any non-zero as reject, so the auditor fails closed. - **Context gate.** With a logging `golangci-lint` shim reporting `2.12.2` first on `PATH`, `make lint` never invoked it (the shim's log file was never created) and ran the pinned image. With `VAULTIK_LINT_IN_CONTAINER=1` the shim was invoked (`version --short`, then `run ./...`). Var set plus shim reporting `2.10.1`: exit 1 naming both versions. Var set to `true` rather than `1`: docker path. The variable is the only door. Rejecting `/.dockerenv` is sound - it is a `dockerd` runtime artifact, absent during a BuildKit `docker build`, which is the only case the exception serves. - **Worth recording:** this host has `golangci-lint` 2.12.2 on `PATH`, so on `main` `script/lint` `exec`s the host binary against the *global default* `~/.cache/golangci-lint` and never runs the pinned image at all. That is very likely the actual mechanism behind the `../impl-91/` paths in https://git.eeqj.de/sneak/vaultik/issues/99 - host, cwd-relative rendering of another worktree's absolute paths - and this PR closes it at the root, so the fix is stronger than the issue's own diagnosis. - **Lock retry.** Shim emitting the refusal on every attempt: 6 attempts, 75s, exit 1 with the explicit "NOT a lint verdict: the tree was never analysed" text. Non-zero and self-describing; not mistakable for a lint result. - **`script/cibuild`:** exit 0, 185s wall, no prune and no `--no-cache`. 14 `CACHED` layers, all dependency/setup; the check layers executed - `#16 make fmt-check`, `#17 make lint` DONE 52.8s printing `0 issues.` inside the pinned image with no daemon, `#25 make test` DONE 89.6s with 14 `ok` lines and **0** `(cached)`. Fresh `CHECK_EPOCH` in both stages. Disclosure: my first attempt exited 127 because `/usr/bin/time` is absent here; that run proved nothing and was discarded, the figures above are the re-run. - **Nothing weakened:** `.golangci.yml` sha256 `021cc83f4e6f...` matches; `REPO_POLICIES.md`, `script/test`, `script/check`, `script/cibuild`, `script/precommit`, `script/fmt`, `Makefile` and `.gitea/` byte-identical to `main`; lint-stage `FROM` + digest and both `ARG CHECK_EPOCH` guards intact; no Go file touched; `ENV VAULTIK_LINT_IN_CONTAINER=1` is confined to the `lint` stage and inherited by neither `builder` nor the runtime image. - CI green on `043aabb` (3m11s); mergeable, merges cleanly against `main` at `c51f693`; `make fmt` a no-op; commit title ends ` (closes #99)` and the body closes #80; no attribution trailers or vendor references anywhere; inclusive terminology, naming and idiom consistent with the surrounding scripts; no scope creep. ### Non-blocking findings **1. `script/lint:174` - a tidy-up failure can still fail a lint, contradicting the commit message.** ```sh owner="$(cat "$dir/worktree")" ``` This is the one unguarded command in `prune_dead_caches`; every other operation in the collection path is `|| true` or `if !`-guarded. Under `set -eu` an assignment whose command substitution fails aborts the script. The commit message states the opposite ("never lets a failure to tidy up fail the lint"), and this is the same class as the self-reported `rm -rf` anomaly. Demonstrated: with a stale cache whose marker file is unreadable, `./script/lint` exits 1 having printed only `cat: .../worktree: Permission denied`, never reaching the linter. The realistic trigger is two concurrent lints collecting the same stale cache - `[ -f "$dir/worktree" ]` true for B, then A's `rm -rf` unlinks the marker before B's `cat`. I could not win that race in 36 concurrent runs, so the window is narrow, and the failure direction is closed (a loud lint error, never a false green) - which is why this is not blocking. Acceptable: `owner="$(cat "$dir/worktree" 2>/dev/null || true)"`. The existing `[ -z "$owner" ]` check already handles the empty result. **2. `script/lint-audit:63-66` - a genuine dependency error is reported as cache contamination.** Any absolute path not under `$ROOT` or `/src` is classed foreign. Inside the container `GOMODCACHE=/cache/go-mod`, so a `typecheck` finding citing a dependency source file is rejected: verified, `/cache/go-mod/example.com/x@v1.0.0/a.go:3:1: msg (typecheck)` exits 1 with "the linter reported findings for files that are not in this tree ... clear this tree's lint cache". That is the wrong diagnosis for a real dependency compile error, and it sends the reader to clear a cache that is not the cause. Fails closed, so not blocking. Acceptable: exempt `/cache/go-mod/` and `/cache/go-build/` (the paths `script/lint` itself sets) from the foreign set, or soften the message when every foreign path lies under the cache mount. ### Nit - The PR body and the https://git.eeqj.de/sneak/vaultik/issues/99 comment report the dirty worktree exiting **2**. Observed here: exit **1** with 2 findings, which is golangci-lint's default `issues-exit-code`. Immaterial to the outcome, but a verification narrative in this repo should match what the tool actually did.
clawbot added merge-ready and removed needs-review labels 2026-08-09 17:14:52 +02:00
clawbot merged commit b6e4a218a3 into main 2026-08-09 17:15:07 +02:00
clawbot deleted branch fix-lint-isolation 2026-08-09 17:15:07 +02:00
Sign in to join this conversation.