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:
```sh
# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly
# what `golangci-lint --version` prints for this ref; update both together.
# golangci-lint v2.12.2, 2026-05-06. The ref must resolve to a TAGGED
# release; see the note on untagged pins below.
GOLANGCI_LINT_VERSION="2.12.2"
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# The version golangci-lint reports, resolved the way callers resolve it.
# Prints nothing when the binary is absent, exits non-zero, or prints
# 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`
# a non-zero --version would otherwise propagate out of the pipeline and
# kill the script through `set -e` before the diagnostic below is printed.
golangci_lint_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
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() {
if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
return 0
fi
echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
@@ -417,20 +413,10 @@ style conventions are in separate documents:
hash -r 2>/dev/null || true
gcl_got="$(golangci_lint_version)"
if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
"and PATH resolves it"
return 0
fi
gcl_bin="$(go env GOBIN)"
[ -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)"
echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
"$gcl_bin, but that is not what callers will get." >&2
@@ -466,11 +452,7 @@ style conventions are in separate documents:
fails green:
- **Compare the installed version against the pin**, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the **whole** version token, exactly: a parser
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.
some golangci-lint.
- **After installing, re-resolve the binary the way callers resolve it** —
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
@@ -486,13 +468,9 @@ style conventions are in separate documents:
Absent binary, non-zero exit, empty output, and unrecognised output all
yield an empty string, which compares unequal to the pin. The failure
direction is always a redundant install, never a skipped one.
- **Call it, and say so on success.** Two function definitions with no call
site are a silent no-op that reproduces the original defect exactly: exit
0, nothing installed, no output, stale linter still resolved. A success
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.
- **Call it.** Two function definitions with no call site are a silent no-op
that reproduces the original defect exactly: exit 0, nothing installed, no
output, stale linter still resolved.
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
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
commit-pinned ref instead of declaring it in `go.mod`. Whether a `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).
commit-pinned ref instead of declaring it in `go.mod`.
**Keep `GOLANGCI_LINT_VERSION` and the ref in sync.** The ref is a hash and
carries no readable version, so the expected version is a separate string,
and it must be exactly what `--version` prints for that ref — the comparison
is an exact match on the whole version token. When the pinned commit carries
a release tag the go command resolves the hash to that tag, so the string is
simply the release number, `2.12.2` here. When it does not, the go command
falls back to a pseudo-version and the binary reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`; that compares exactly like any other
string, so it works, but it cannot be known without building the binary once
and reading `--version` off it. Prefer pins on tagged releases for that
reason — the expected string is then derivable from the ref — not because
the comparison cannot handle the alternative.
Because the comparison covers the whole token, a pre-release is never
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.
**The pin must be a tagged release commit.** `GOLANGCI_LINT_VERSION` is a
separate string because the ref is a hash and carries no readable version,
so the two must be updated together. It works out to a clean `2.12.2` only
because that commit is the `v2.12.2` tag commit: the go command resolves a
commit query to a semver tag on that commit when one exists, so the built
binary reports `2.12.2`. Pin to an **untagged** commit and the go command
falls back to a pseudo-version the binary then reports something like
`2.12.3-0.20260506110758-c0d3ddc9cf3f`, and this comparison cannot express
it: the parser above stops at the first `-`, so the value to match would
have to be the truncated numeric prefix `2.12.3`, which is also the number
of a real future release. Setting `GOLANGCI_LINT_VERSION` to the full string
`--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
pins on tagged releases.
**Verifying a change to this logic requires a negative control run in an
environment where a shadowing binary exists earlier in `PATH` than the