diff --git a/TODO.md b/TODO.md index 6be7f1a..c23b4f4 100644 --- a/TODO.md +++ b/TODO.md @@ -43,15 +43,33 @@ (taking the field after the word `version` and tolerating an optional leading `v`, which the module ref carries and the binary's output does not), and any version that is not the pin — older, newer, absent or - unparseable — is reinstalled. `git`, `make` and `go` keep their - presence-only checks and now say why in a comment: they are host - package-manager tools the repo deliberately does not pin, with - `go.mod` governing the language version and the digest-pinned images - covering reproducible builds. Verified on this host by bootstrapping - from v2.10.1 to v2.12.2 and running it again to a no-op, plus stub - runs under `dash` covering the absent, older, newer, image-style and - leading-`v` cases; `make check` and `make lint` are clean at v2.12.2, - so v2.10.1 was not hiding any findings on `main` + unparseable — is reinstalled. The install is then verified against the + binary `PATH` actually resolves: `go install` writes into `GOBIN` (or + `GOPATH/bin`) while `make lint` runs whichever `golangci-lint` comes + first on `PATH`, so a wrong-version one sitting ahead of it — nix, + apt, brew, apk, or the `/usr/local/bin` copy the `Dockerfile` builder + stage makes — would swallow the install and leave the local gate + disagreeing with CI under an affirmative `bootstrap complete`. + Bootstrap now re-reads the effective version after installing and, on + a mismatch, prints both paths and both versions to stderr and exits + non-zero instead of claiming success; it does not reorder anyone's + `PATH` or delete their binary. The `--version` call keeps its stderr + connected, so a present-but-broken binary says why rather than + reinstalling forever in silence, and is bounded by `timeout(1)` where + that exists, so a wedged binary cannot hang bootstrap. `git`, `make` + and `go` keep their presence-only checks and now say why in a + comment: they are host package-manager tools the repo deliberately + does not pin, with `go.mod` governing the language version and the + digest-pinned images covering reproducible builds. Verified on this + host by bootstrapping from v2.10.1 to v2.12.2 and running it again to + a no-op, plus stub runs of the real script under `dash` covering a + thirteen-input parse matrix (absent, older, newer, host-style, + image-style, leading-`v`, stderr-only, empty, non-zero exit, impostor + binary, `(devel)`, trailing `version`), a shadowed install that must + exit non-zero, an install destination not on `PATH` at all, `GOBIN` + set, and a wedged binary that must hit the timeout; `make check` and + `make lint` are clean at v2.12.2, so v2.10.1 was not hiding any + findings on `main` - unwind the hash worker pool on the error path (2026-08-09, branch `hash-pool-cleanup`, closes #6): `hashPhase` used to return the moment `recordRun` failed and abandon the pool — the feeder parked diff --git a/script/bootstrap b/script/bootstrap index 78709fc..549a5f5 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -5,7 +5,10 @@ # or apk (detected in that order); assumes nothing is present. # golangci-lint is installed via `go install` pinned to the same version # the Dockerfile lint stage uses (never "latest"), and is reinstalled -# whenever the installed version differs from that pin. +# whenever the installed version differs from that pin. The install is +# then verified against the binary PATH actually resolves: if the pin is +# still not what would run, bootstrap fails instead of reporting +# success. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -19,6 +22,11 @@ GOLANGCI_LINT_VERSION="2.12.2" GOLANGCI_LINT_MODULE="github.com/golangci/golangci-lint/v2/cmd/golangci-lint" GOLANGCI_LINT_REF="$GOLANGCI_LINT_MODULE@v$GOLANGCI_LINT_VERSION" +# Seconds to allow `golangci-lint --version` to run. Bootstrap now +# executes the binary rather than merely locating it, so a wedged one +# must not hang the script. +GOLANGCI_LINT_VERSION_TIMEOUT="30" + PKGMGR="" SUDO="" APT_UPDATED="" @@ -68,13 +76,24 @@ missing() { # Echo the installed golangci-lint version, or nothing when the tool is # absent. The binary reports e.g. -# golangci-lint has version 2.12.2 built with go1.26.5 from abc1234 ... +# golangci-lint has version X.Y.Z built with go1.26.5 from abc1234 ... # so the version is the field after the literal word "version", and it # carries no leading "v" (the module ref does). Some builds do print a # leading "v", so strip one if present and compare bare versions. +# +# Only stdout is parsed; the binary's stderr is deliberately left +# connected to ours so that a present-but-broken linter (missing shared +# library, wrong architecture) says why instead of silently yielding the +# empty string. The call is bounded by timeout(1) where that exists — +# stock macOS has no timeout(1), and there the call runs unbounded, as +# it did before this check was version-aware. golangci_lint_version() { command -v golangci-lint >/dev/null 2>&1 || return 0 - golangci-lint --version 2>/dev/null | awk ' + if command -v timeout >/dev/null 2>&1; then + timeout "$GOLANGCI_LINT_VERSION_TIMEOUT" golangci-lint --version + else + golangci-lint --version + fi | awk ' { for (i = 1; i < NF; i++) { if ($i == "version") { @@ -88,6 +107,40 @@ golangci_lint_version() { ' } +# Confirm that the golangci-lint just installed is the one that will +# actually run. `go install` writes into "$(go env GOBIN)" (or +# "$(go env GOPATH)/bin"), but `make lint` runs whatever PATH resolves +# first. When a wrong-version binary sits ahead of that directory — a +# nix profile, apt, brew, apk, or a tarball in /usr/local/bin — the +# install lands behind the shadow and changes nothing the gate uses. +# Exiting 0 there would leave the local gate linting against a different +# ruleset than CI while claiming success, which is the failure this +# whole check exists to prevent. Diagnose and stop: naming both paths is +# what makes it fixable. Reordering PATH or deleting someone else's +# binary is not bootstrap's call. +verify_golangci_lint() { + # Forget any remembered command locations first: the install may have + # created a binary in a directory the shell already searched. + hash -r 2>/dev/null || true + + goinstalldir="$(go env GOBIN)" + if [ -z "$goinstalldir" ]; then + goinstalldir="$(go env GOPATH)/bin" + fi + resolved="$(command -v golangci-lint 2>/dev/null || true)" + effective="$(golangci_lint_version)" + if [ "$effective" != "$GOLANGCI_LINT_VERSION" ]; then + echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \ + "$goinstalldir, but the golangci-lint on PATH is" \ + "${resolved:-not resolvable} and reports" \ + "${effective:-no parseable version}" >&2 + echo "bootstrap: the install is shadowed or unreachable; put" \ + "$goinstalldir ahead of it on PATH (or remove the shadowing" \ + "binary) and re-run" >&2 + exit 1 + fi +} + main() { cd "$ROOT" @@ -107,12 +160,14 @@ main() { # the Dockerfile lint stage runs a digest-pinned linter, so a host # running any other version lints against different rules and # `make check` can go green on a commit CI then rejects. Any version - # that is not the pin — older or newer — is reinstalled. + # that is not the pin — older or newer — is reinstalled, and the + # install is then verified to be the binary PATH resolves. installed="$(golangci_lint_version)" if [ "$installed" != "$GOLANGCI_LINT_VERSION" ]; then echo "bootstrap: golangci-lint ${installed:-absent or unparseable}," \ "want $GOLANGCI_LINT_VERSION; installing" go install "$GOLANGCI_LINT_REF" + verify_golangci_lint fi go mod download