diff --git a/TODO.md b/TODO.md index e42194b..c23b4f4 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,47 @@ # Completed Steps +- check the installed golangci-lint version in `script/bootstrap` + instead of only its presence (2026-08-09, branch + `bootstrap-version-check`, closes #24): `missing golangci-lint` meant + any linter already on `PATH` satisfied the check, so the pin was never + consulted and the v2.12.2 bump from #3 was inert on every host that + already had one — this host ran v2.10.1 against a v2.12.2 pin, + `make check` went green, and `make docker` then rejected the same + commit with findings the local gate never saw. The version now lives + in one place, `GOLANGCI_LINT_VERSION`, with the `go install` module + ref derived from it so a bump cannot half-apply; a + `golangci_lint_version` helper parses `golangci-lint --version` + (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. 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 1103c49..549a5f5 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,14 +4,28 @@ # installed tools are skipped. Base tooling comes from nix, apt, brew, # 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"). +# the Dockerfile lint stage uses (never "latest"), and is reinstalled +# 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)" # Pinned versions, 2026-08-07 (same version as the Dockerfile lint stage). -# golangci-lint v2.12.2 -GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2" +# This is the single source of truth for the linter version: the module +# ref below and the version comparison in main() are both derived from +# it, so a bump here cannot half-apply. Written without a leading "v", +# the way `golangci-lint --version` reports it. +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="" @@ -60,16 +74,101 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# Echo the installed golangci-lint version, or nothing when the tool is +# absent. The binary reports e.g. +# 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 + 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") { + v = $(i + 1) + sub(/^v/, "", v) + print v + exit + } + } + } + ' +} + +# 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" + # System tooling, deliberately unpinned: these come from the host + # package manager and whatever version it ships is what the host + # gets, so a presence check is the right check. The repo pins no + # system toolchain versions — the Go language version is governed by + # go.mod, and builds that must be reproducible run in the Docker + # image, whose base images are pinned by digest. if missing git; then pkg_install git git git git; fi if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi # Lint tooling, pinned via go install (installs into - # "$(go env GOPATH)/bin"; ensure that is on your PATH). - if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi + # "$(go env GOPATH)/bin"; ensure that is on your PATH). Unlike the + # system tools above this is version-checked, not presence-checked: + # 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, 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