1 Commits

Author SHA1 Message Date
clawbot
be59376522 Make the pinned golangci-lint actually reach the host (closes #28)
All checks were successful
check / check (push) Successful in 11s
REPO_POLICIES.md now carries the canonical script/bootstrap snippet for Go
repos alongside the .golangci.yml bullet, where the pinned linter version
already lives.

The guard it replaces, `if missing golangci-lint; then go install ...; fi`,
tests PATH presence and never version, so on any already-provisioned machine
the pin is inert and a version bump is a no-op. The Dockerfile installs
unconditionally into a clean image, so CI and local then disagree about what
the linter is: a local `make check` green while `make docker` rejects the same
commit, and a container run surfacing findings the host run cannot see.

Comparing versions alone is not enough. `go install` writes to GOBIN (or
GOPATH/bin) while callers resolve through PATH, so a shadowing binary earlier
in PATH lets the install succeed and change nothing a caller ever sees, while
bootstrap prints success. The canonical form therefore compares the installed
version against the pin, re-resolves through PATH after installing and asserts
the pin, and treats any unparseable --version output as a mismatch so the
failure direction is a redundant install rather than a skipped one.

When the assert fails the diagnosis is derived from the resolved path rather
than asserted: a path outside the install directory is shadowing and the
operator is told to remove it or reorder PATH; a path inside it is not, and
saying so would send them after a fault that does not exist; no resolution at
all means the install directory is simply absent from PATH.

The snippet ends with a call site. Two function definitions and no invocation
are a silent no-op with exactly the shape this change exists to close, and the
adoption instructions say to paste the block verbatim.

The version helper ends in `|| true` so a --version that exits non-zero cannot
kill the script through `set -e` under `set -o pipefail` before the diagnostic
is printed, which the styleguide's bash form would otherwise do.

The policy text states each of those as a requirement rather than leaving them
implicit in the code, records why the commit-pinned `go install` ref satisfies
the hash-pinning rule (a commit hash is not a mutable tag, and the checksum
database verifies the fetch; no repo go.sum is consulted, since `go install
pkg@version` ignores the go.mod in the current directory or any parent), and
requires the pin to be a tagged release commit: an untagged pin yields a
pseudo-version that the parser truncates at the first `-`, so no value of
GOLANGCI_LINT_VERSION derived from what --version prints can ever match.

Verification requires two things the previous round lacked: a negative control
against a shadowing binary, and running the controls against the block as a
consuming repo would adopt it, pasted into a script/bootstrap-shaped file and
executed, rather than sourcing it and calling the function directly.

The node and yarn handling described earlier in the document is untouched.
2026-08-09 15:41:09 +00:00

View File

@@ -381,29 +381,25 @@ style conventions are in separate documents:
The canonical form, placed in `script/bootstrap` after Go itself is present: The canonical form, placed in `script/bootstrap` after Go itself is present:
```sh ```sh
# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly # golangci-lint v2.12.2, 2026-05-06. The ref must resolve to a TAGGED
# what `golangci-lint --version` prints for this ref; update both together. # release; see the note on untagged pins below.
GOLANGCI_LINT_VERSION="2.12.2" GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it. # The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints # Prints nothing when the binary is absent, exits non-zero, or prints
# something unparseable: all of those must read as "does not match". # something unparseable: all of those must read as "does not match".
# The capture is the whole version token, not just its numeric prefix.
# Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2
# and skip the install, which is the defect this whole rule exists to close.
# The trailing `|| true` is required, not tidiness. Under `set -o pipefail` # The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
# a non-zero --version would otherwise propagate out of the pipeline and # a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed. # kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() { golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0 command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint --version 2>/dev/null | head -n 1 | golangci-lint --version 2>/dev/null | head -n 1 |
sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true sed -n 's/.*has version v\{0,1\}\([0-9][0-9.]*\).*/\1/p' || true
} }
ensure_golangci_lint() { ensure_golangci_lint() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
return 0 return 0
fi fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION" echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
@@ -417,20 +413,10 @@ style conventions are in separate documents:
hash -r 2>/dev/null || true hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)" gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
"and PATH resolves it"
return 0 return 0
fi fi
gcl_bin="$(go env GOBIN)" gcl_bin="$(go env GOBIN)"
[ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin" [ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
# Strip a trailing slash: GOBIN=/x/ would otherwise make the
# "$gcl_bin"/* test below miss and misreport shadowing.
while :; do
case "$gcl_bin" in
*/) gcl_bin="${gcl_bin%/}" ;;
*) break ;;
esac
done
gcl_found="$(command -v golangci-lint 2>/dev/null || true)" gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \ echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2 "$gcl_bin, but that is not what callers will get." >&2
@@ -466,11 +452,7 @@ style conventions are in separate documents:
fails green: fails green:
- **Compare the installed version against the pin**, never test presence. - **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the **whole** version token, exactly: a parser some golangci-lint.
that stops at the first `-` reports `2.12.2` for a host running
`2.12.2-rc1`, which compares equal to a `2.12.2` pin and skips the install
— the original defect, reintroduced through the comparison meant to fix
it.
- **After installing, re-resolve the binary the way callers resolve it** — - **After installing, re-resolve the binary the way callers resolve it** —
through `PATH`, not the path `go install` wrote to — and assert through `PATH`, not the path `go install` wrote to — and assert
`--version` reports the pin. When it does not, fail non-zero and name the `--version` reports the pin. When it does not, fail non-zero and name the
@@ -486,13 +468,9 @@ style conventions are in separate documents:
Absent binary, non-zero exit, empty output, and unrecognised output all Absent binary, non-zero exit, empty output, and unrecognised output all
yield an empty string, which compares unequal to the pin. The failure yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one. direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** Two function definitions with no call - **Call it.** Two function definitions with no call site are a silent no-op
site are a silent no-op that reproduces the original defect exactly: exit that reproduces the original defect exactly: exit 0, nothing installed, no
0, nothing installed, no output, stale linter still resolved. A success output, stale linter still resolved.
path that prints nothing is byte-identical to that no-op — same exit
status, same empty output — so both success branches must print a
confirmation naming the version. In a change about undetectable no-ops,
"it printed nothing and exited 0" must not be the healthy signal.
Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`.
@@ -505,29 +483,22 @@ style conventions are in separate documents:
`go.sum` is consulted for this install; the checksum database is what `go.sum` is consulted for this install; the checksum database is what
verifies it. The linter is a bootstrap prerequisite rather than part of any verifies it. The linter is a bootstrap prerequisite rather than part of any
repo's module graph, which is why the canonical form installs it by repo's module graph, which is why the canonical form installs it by
commit-pinned ref instead of declaring it in `go.mod`. Whether a `go.mod` commit-pinned ref instead of declaring it in `go.mod`.
tool dependency — which would pin the hash in a committed, reviewable file
instead — should replace this is an open decision, tracked at
[prompts#37](https://git.eeqj.de/sneak/prompts/issues/37).
**Keep `GOLANGCI_LINT_VERSION` and the ref in sync.** The ref is a hash and **The pin must be a tagged release commit.** `GOLANGCI_LINT_VERSION` is a
carries no readable version, so the expected version is a separate string, separate string because the ref is a hash and carries no readable version,
and it must be exactly what `--version` prints for that ref — the comparison so the two must be updated together. It works out to a clean `2.12.2` only
is an exact match on the whole version token. When the pinned commit carries because that commit is the `v2.12.2` tag commit: the go command resolves a
a release tag the go command resolves the hash to that tag, so the string is commit query to a semver tag on that commit when one exists, so the built
simply the release number, `2.12.2` here. When it does not, the go command binary reports `2.12.2`. Pin to an **untagged** commit and the go command
falls back to a pseudo-version and the binary reports something like falls back to a pseudo-version the binary then reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`; that compares exactly like any other `2.12.3-0.20260506110758-c0d3ddc9cf3f`, and this comparison cannot express
string, so it works, but it cannot be known without building the binary once it: the parser above stops at the first `-`, so the value to match would
and reading `--version` off it. Prefer pins on tagged releases for that have to be the truncated numeric prefix `2.12.3`, which is also the number
reason — the expected string is then derivable from the ref — not because of a real future release. Setting `GOLANGCI_LINT_VERSION` to the full string
the comparison cannot handle the alternative. `--version` prints can never match, and bootstrap would then reinstall on
every run and exit 1 blaming a shadowing binary that does not exist. Keep
Because the comparison covers the whole token, a pre-release is never pins on tagged releases.
confused with its release: a host carrying `2.12.2-rc1` against a `2.12.2`
pin compares unequal and gets reinstalled. This matters more than it looks,
because a pre-release tag is still a tag, so a rule requiring merely that
the pin be tagged would not catch it.
**Verifying a change to this logic requires a negative control run in an **Verifying a change to this logic requires a negative control run in an
environment where a shadowing binary exists earlier in `PATH` than the environment where a shadowing binary exists earlier in `PATH` than the