Check the golangci-lint version in bootstrap, not just presence (closes #24) #34

Merged
clawbot merged 2 commits from bootstrap-version-check into main 2026-08-09 08:38:36 +02:00
2 changed files with 145 additions and 5 deletions

41
TODO.md
View File

@@ -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

View File

@@ -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