script/lint and script/fmt invoke golangci-lint and goimports by bare name, so a PATH shadow still overrides the pin #133

Open
opened 2026-08-09 17:04:56 +02:00 by clawbot · 0 comments
Collaborator

Follow-up to PR #131 (closes #117), from its review.

#117 made script/bootstrap install the pinned golangci-lint and goimports unconditionally. It did not change how they are invoked: script/lint and script/fmt still call them by bare name, resolved through PATH.

So an older copy earlier on PATH is still what make lint and make fmt actually run. Installing the pin is necessary and not sufficient — pinning the install without pinning the invocation leaves the guarantee half-built.

Why this matters more than it sounds

A wrong linter version does not fail loudly. It silently disagrees with CI, which runs the pinned binary in-container. The fleet has already seen both directions of this: a host reporting golangci-lint 2.10.1 at one point and 2.12.2 later with no repo change, and a sibling repo whose container linter surfaced 13 findings its host linter missed. That is version skew, and no amount of cache isolation (#121) touches it.

PR #131 added a non-fatal warning when command -v resolves either tool outside the go install destination. That is the right call for bootstrap — the remedy is the operator's PATH, and hard-failing would break script/setup on machines it has no business failing. But a warning printed once during provisioning does not protect a lint run that happens hours later in a different shell.

Also in scope: the shadow check is string-fragile

script/bootstrap compares command -v output against "$2/$1" by string equality. A trailing slash on the PATH entry (/dir//tool), a symlinked directory, or a relative PATH entry produces a spurious warning even when the pinned binary is what would run. Reproduced during review.

It never misses a real shadow, so it fails safe — but a warning that cries wolf gets ignored, which defeats the point. Canonicalise both sides (e.g. via pwd -P on the directory) before comparing.

Definition of done

  1. script/lint and script/fmt invoke the pinned binaries at their go install destination rather than by bare name — resolve go env GOBIN, falling back to $(go env GOPATH)/bin, the same way PR #131's check does.
  2. This must not break the Docker build. The Dockerfile installs both tools with go install in the builder stage, so the destination must resolve correctly there too, where HOME/GOPATH differ from a developer machine. Verify with a real container build, not by reading the script — and note script/cibuild can report a false green on a byte-identical tree (#115, fixed in unmerged PR #122), so force a real build and say which form you ran.
  3. Behave sensibly when the pinned binary is genuinely absent — a clear error telling the user to run script/bootstrap, not a bare "command not found" or a silent fallback to whatever is on PATH. A silent fallback would reintroduce exactly this bug.
  4. The shadow check in script/bootstrap canonicalises paths before comparing, so a trailing slash, symlinked directory, or relative PATH entry no longer produces a spurious warning.
  5. Both scripts stay POSIX sh (#!/bin/sh, set -eu, no bashisms — they run in alpine with no bash); sh -n clean; keep the $(cd "$(dirname "$0")/.." && pwd -P) idiom.
  6. Verify with a negative control, the same method that established #117. Put a different golangci-lint version in a scratch GOBIN first on PATH, run make lint, and confirm the pinned version runs — not the shadow. Do the equivalent for goimports and make fmt. Use an isolated scratch directory; do not disturb anything shared on this host. Report what you planted and what ran.
  7. make check green; TODO.md updated in the same commit.

Commit title ends with (closes #N).

Constraints

  • Do not change the pins. golangci-lint stays c0d3ddc9cf3faa61a4e378e879ece580256d76e5; goimports stays 009367f5c17a8d4c45a961a3a509277190a9a6f0. Keep script/bootstrap and the Dockerfile identical to each other.
  • Do not modify .golangci.yml — sha256 must stay 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.
  • Do not suppress, narrow, or disable any lint check.
  • DNS is never mocked in this repository; nothing here touches test behaviour.
  • make targets and script/ entrypoints only, except that go env, go install into a scratch GOBIN, and golangci-lint --version are permitted as the measurement for item 6 — say so when you use them.

Environment hazards

  • Lint runs can be VOID (#121): void if the output contains parallel golangci-lint is running, or names any path beginning with ../, or any absolute path outside your worktree. Fix is in unmerged PR #128; isolate manually with GOLANGCI_LINT_CACHE/TMPDIR meanwhile.
  • make hooks fails in a linked worktree (#129) — committing with --no-verify plus an explicit make check is expected.

Sequencing

Land after PR #131 (introduces the shadow check being refined) and after PR #128 (also edits script/lint). #130 and #119 touch the same two scripts — keep them separate PRs and rebase whichever lands later.

Upstream

Both scripts are byte-identical across repos, so this belongs in the shared template alongside the pinned-install fix (prompts #28). The vaultik manager described the same shape there as a "context-ungated escape hatch": a fix applied to one invocation path and not another leaves the hole open on the other.

Follow-up to [PR #131](https://git.eeqj.de/sneak/dnswatcher/pulls/131) (closes [#117](https://git.eeqj.de/sneak/dnswatcher/issues/117)), from its review. [#117](https://git.eeqj.de/sneak/dnswatcher/issues/117) made `script/bootstrap` **install** the pinned `golangci-lint` and `goimports` unconditionally. It did not change how they are **invoked**: `script/lint` and `script/fmt` still call them by bare name, resolved through `PATH`. So an older copy earlier on `PATH` is still what `make lint` and `make fmt` actually run. Installing the pin is necessary and not sufficient — pinning the install without pinning the invocation leaves the guarantee half-built. ## Why this matters more than it sounds A wrong linter version does not fail loudly. It silently disagrees with CI, which runs the pinned binary in-container. The fleet has already seen both directions of this: a host reporting golangci-lint 2.10.1 at one point and 2.12.2 later with no repo change, and a sibling repo whose container linter surfaced 13 findings its host linter missed. That is version skew, and no amount of cache isolation ([#121](https://git.eeqj.de/sneak/dnswatcher/issues/121)) touches it. [PR #131](https://git.eeqj.de/sneak/dnswatcher/pulls/131) added a non-fatal warning when `command -v` resolves either tool outside the `go install` destination. That is the right call for bootstrap — the remedy is the operator's `PATH`, and hard-failing would break `script/setup` on machines it has no business failing. But a warning printed once during provisioning does not protect a lint run that happens hours later in a different shell. ## Also in scope: the shadow check is string-fragile `script/bootstrap` compares `command -v` output against `"$2/$1"` by string equality. A trailing slash on the `PATH` entry (`/dir//tool`), a symlinked directory, or a relative `PATH` entry produces a **spurious** warning even when the pinned binary is what would run. Reproduced during review. It never misses a real shadow, so it fails safe — but a warning that cries wolf gets ignored, which defeats the point. Canonicalise both sides (e.g. via `pwd -P` on the directory) before comparing. ## Definition of done 1. `script/lint` and `script/fmt` invoke the pinned binaries at their `go install` destination rather than by bare name — resolve `go env GOBIN`, falling back to `$(go env GOPATH)/bin`, the same way [PR #131](https://git.eeqj.de/sneak/dnswatcher/pulls/131)'s check does. 2. **This must not break the Docker build.** The `Dockerfile` installs both tools with `go install` in the builder stage, so the destination must resolve correctly there too, where `HOME`/`GOPATH` differ from a developer machine. Verify with a real container build, not by reading the script — and note `script/cibuild` can report a false green on a byte-identical tree ([#115](https://git.eeqj.de/sneak/dnswatcher/issues/115), fixed in unmerged [PR #122](https://git.eeqj.de/sneak/dnswatcher/pulls/122)), so force a real build and say which form you ran. 3. Behave sensibly when the pinned binary is genuinely absent — a clear error telling the user to run `script/bootstrap`, not a bare "command not found" or a silent fallback to whatever is on `PATH`. **A silent fallback would reintroduce exactly this bug.** 4. The shadow check in `script/bootstrap` canonicalises paths before comparing, so a trailing slash, symlinked directory, or relative `PATH` entry no longer produces a spurious warning. 5. Both scripts stay POSIX `sh` (`#!/bin/sh`, `set -eu`, no bashisms — they run in alpine with no bash); `sh -n` clean; keep the `$(cd "$(dirname "$0")/.." && pwd -P)` idiom. 6. **Verify with a negative control, the same method that established [#117](https://git.eeqj.de/sneak/dnswatcher/issues/117).** Put a *different* golangci-lint version in a scratch `GOBIN` first on `PATH`, run `make lint`, and confirm the **pinned** version runs — not the shadow. Do the equivalent for `goimports` and `make fmt`. Use an isolated scratch directory; do not disturb anything shared on this host. Report what you planted and what ran. 7. `make check` green; `TODO.md` updated in the same commit. Commit title ends with ` (closes #N)`. ## Constraints - **Do not change the pins.** golangci-lint stays `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`; goimports stays `009367f5c17a8d4c45a961a3a509277190a9a6f0`. Keep `script/bootstrap` and the `Dockerfile` identical to each other. - **Do not modify `.golangci.yml`** — sha256 must stay `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. - Do not suppress, narrow, or disable any lint check. - DNS is never mocked in this repository; nothing here touches test behaviour. - `make` targets and `script/` entrypoints only, except that `go env`, `go install` into a scratch `GOBIN`, and `golangci-lint --version` are permitted as the measurement for item 6 — say so when you use them. ## Environment hazards - Lint runs can be VOID ([#121](https://git.eeqj.de/sneak/dnswatcher/issues/121)): void if the output contains `parallel golangci-lint is running`, or names any path beginning with `../`, or any absolute path outside your worktree. Fix is in unmerged [PR #128](https://git.eeqj.de/sneak/dnswatcher/pulls/128); isolate manually with `GOLANGCI_LINT_CACHE`/`TMPDIR` meanwhile. - `make hooks` fails in a linked worktree ([#129](https://git.eeqj.de/sneak/dnswatcher/issues/129)) — committing with `--no-verify` plus an explicit `make check` is expected. ## Sequencing Land after [PR #131](https://git.eeqj.de/sneak/dnswatcher/pulls/131) (introduces the shadow check being refined) and after [PR #128](https://git.eeqj.de/sneak/dnswatcher/pulls/128) (also edits `script/lint`). [#130](https://git.eeqj.de/sneak/dnswatcher/issues/130) and [#119](https://git.eeqj.de/sneak/dnswatcher/issues/119) touch the same two scripts — keep them separate PRs and rebase whichever lands later. ## Upstream Both scripts are byte-identical across repos, so this belongs in the shared template alongside the pinned-install fix (`prompts` [#28](https://git.eeqj.de/sneak/prompts/issues/28)). The vaultik manager described the same shape there as a "context-ungated escape hatch": a fix applied to one invocation path and not another leaves the hole open on the other.
clawbot added this to the 1.0 milestone 2026-08-09 17:04:56 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/dnswatcher#133