Files
vaultik/script/lint
sneak 426a8d7645
All checks were successful
check / check (pull_request) Successful in 3m11s
Isolate the lint cache per worktree and context-gate the native path (closes #99)
Closes #80.

script/lint decided whether it could skip the pinned image by asking what
version was on PATH rather than where it was running, and cache isolation
is part of that same question. Both issues are that one defect.

The cache was one directory per repo, shared by every worktree on the
host. Two checkouts of this repo have identical Go file contents, so
their cache keys collide and golangci-lint replays the stored analysis,
including the paths recorded when it was produced. The loud direction of
that failure - a clean tree failed by a dirty sibling - is the harmless
one. The silent direction, a dirty tree passed by a clean sibling, is
another way for a gate here to report a green it did not earn.

The cache is now keyed on a digest of the worktree path, so a collision
is not possible, and it stays persistent per worktree: a warm run is
still seconds. Each cache records the worktree it belongs to and is
collected when that worktree is gone, so throwaway worktrees do not
accumulate caches; the tree lives under XDG_CACHE_HOME and is disposable
by definition.

script/lint-audit is the backstop, and runs on every lint: it rejects
output citing any file that is not in the tree being linted, so a result
built out of another checkout's analysis is a hard error instead of a
silent pass. It runs on clean output too, because that is the case
nobody investigates. It never certifies that a run passed - it does not
look at whether there were findings - so it cannot itself become a gate
that reports a green.

golangci-lint's "parallel golangci-lint is running" refusal is now a
bounded retry rather than a verdict. It is not a lint result, and
exiting non-zero on it is indistinguishable to a caller from real
findings; issue #88 measured that a private cache does not remove the
contention. Exhausting the retries fails with a message that says the
tree was never analysed.

The native path now requires VAULTIK_LINT_IN_CONTAINER=1, which only the
Dockerfile's lint stage sets, in addition to matching the pin. A
developer's locally installed 2.12.2 is a different build reached by a
different code path and no longer bypasses the digest pin. /.dockerenv
was considered and rejected as the signal: dockerd creates it for
`docker run`, but it is not reliably present during a BuildKit
`docker build`, which is exactly the case the exception exists for.
Inside the container a version mismatch is now a hard error rather than
a fall-through, since there is no daemon there to fall through to.
Version detection uses `golangci-lint version --short`, the interface
meant for it, keeping the banner scrape only as a fallback.

script/bootstrap no longer prints "bootstrap complete" on a machine that
cannot run the gate. Docker missing, or present with an unreachable
daemon, is a hard failure naming exactly what breaks. Installing docker
from bootstrap was rejected: it needs root, a daemon, and on macOS a GUI
cask, so the attempt would itself fail in the common case and trade one
false success for a second failure mode.

TODO.md's claim that `make check` became "as trustworthy as
script/cibuild" is corrected to what README.md already said: only the
lint leg is equivalent, while tests and gofmt still run against the host
toolchain. README.md's requirements section gains docker and sqlite3.

Verified by reproduction, not inspection: two concurrent lints from two
worktrees of differing cleanliness each reported only their own findings
with no lock error; a real run made to report paths outside its tree
exits 1; a matching linter shimmed onto PATH is never invoked while the
pinned image runs; a PATH without docker makes bootstrap fail. script/
cibuild exits 0 with the lint layer executing in the pinned image, which
is what proves the in-container path still works.
2026-08-09 14:47:51 +00:00

320 lines
11 KiB
Bash
Executable File

#!/bin/sh
# script/lint: run the linter.
#
# The linter always runs at the version pinned by the Dockerfile's lint
# stage, so a local run and a CI run of the same tree cannot disagree.
# That FROM line (image tag plus digest) is the single source of truth
# for the linter version in this repo: bump it there and nothing else
# needs editing.
#
# Normally that means running the pinned image with docker. The one
# exception is running INSIDE that image: the Dockerfile's lint stage
# runs `make lint`, and there is no docker daemon in there. That stage
# sets VAULTIK_LINT_IN_CONTAINER=1, and only when that variable is set
# is a golangci-lint on PATH used directly - and then only if its
# version is exactly the pin. Version equality alone is deliberately NOT
# enough: it also matches a developer's locally installed copy of the
# same version, which is a different build with a different Go
# toolchain, reached by a different code path, and it would bypass the
# digest pin this script exists to enforce. /.dockerenv was considered
# as the context signal and rejected: dockerd creates it for `docker
# run`, but it is not reliably present during a BuildKit `docker build`,
# which is exactly the case the exception exists for.
#
# The linter's output is checked before it is believed: every run is
# audited by script/lint-audit for findings that cannot belong to this
# tree, and a run refused by golangci-lint's cross-process lock is
# retried rather than reported as a verdict. See the lock-retry loop in
# main and the header of script/lint-audit.
#
# Extra arguments are passed through to `golangci-lint run`, before
# `./...` (see script/lint-fix).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
DOCKERFILE="$ROOT/Dockerfile"
# golangci-lint takes a cross-process lock and refuses to start while
# another instance holds it. That refusal is not a lint result, and
# exiting non-zero on it is indistinguishable to a caller from real
# findings - so it is retried rather than reported. Bounded, because a
# lock that is never released must fail rather than hang.
LOCK_MESSAGE="parallel golangci-lint is running"
LOCK_ATTEMPTS=6
LOCK_SLEEP=15
# The image reference of the Dockerfile's lint stage, tag and digest
# included, e.g.
# golangci/golangci-lint:v2.12.2-alpine@sha256:91b2...
lint_image() {
awk '$1 == "FROM" && $3 == "AS" && $4 == "lint" { print $2; exit }' \
"$DOCKERFILE"
}
# The bare version that image reference pins, e.g. 2.12.2
pinned_version() {
lint_image | sed -e 's/@.*//' -e 's/.*://' -e 's/^v//' -e 's/-.*//'
}
# The version of the golangci-lint on PATH, if any, e.g. 2.12.2
#
# `version --short` prints the bare version and is the interface meant
# for this (checked against 2.10.1 and 2.12.2). The banner scrape below
# it is a fallback for a release where --short is absent or silent; the
# banner's exact wording is not a stable interface, which is why it is
# no longer the primary parse.
installed_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
short="$(golangci-lint version --short 2>/dev/null |
tr -d '[:space:]' | sed -e 's/^v//')"
case "$short" in
*[0-9].[0-9]*.[0-9]*)
echo "$short"
return 0
;;
esac
golangci-lint version 2>/dev/null | awk '
{
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) {
print $i
exit
}
}
}'
}
# True inside the Dockerfile's lint stage, which sets this. Nothing else
# sets it: setting it by hand on a host is an explicit, visible decision
# to lint with an unpinned binary, not something reached by accident.
in_lint_container() {
[ "${VAULTIK_LINT_IN_CONTAINER:-}" = "1" ]
}
require_docker() {
image="$1"
if ! command -v docker >/dev/null 2>&1; then
cat >&2 <<EOF
lint: docker is required to run the pinned linter.
pinned image: $image
Install docker. Linting with any other golangci-lint is not supported:
it is what lets a local run pass while CI fails. An installed
golangci-lint on PATH is not used, whatever its version; only the lint
stage of the Dockerfile itself runs the linter natively.
EOF
exit 1
fi
if ! docker info >/dev/null 2>&1; then
cat >&2 <<EOF
lint: the docker daemon is not reachable, so the pinned linter cannot
run.
pinned image: $image
Start the daemon (and check DOCKER_HOST / your group membership). This
script will not fall back to a different linter version or to an
unpinned binary on PATH.
EOF
exit 1
fi
}
# Where the per-worktree caches live.
cache_home() {
echo "${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/vaultik-lint"
}
# A short, stable digest of this worktree's path.
path_digest() {
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$ROOT" | sha256sum | cut -c1-12
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$ROOT" | shasum -a 256 | cut -c1-12
else
printf '%s' "$ROOT" | cksum | tr -cd '0-9' | cut -c1-12
fi
}
# Caches for the containerized linter, private to THIS worktree.
#
# Keeping them out of the repo and persisting them between runs is what
# keeps the inner loop fast: a warm run costs about the same as a native
# one plus container startup. Keeping them keyed on the worktree path is
# what keeps them correct. One shared cache for the whole repo was the
# defect in issue #99: two worktrees of this repo have identical file
# contents, so their cache keys collide, and golangci-lint replays the
# stored results - including the file paths recorded when they were
# produced. That silently reports one worktree's findings, or one
# worktree's clean bill of health, for another.
cache_dir() {
slug="$(printf '%s' "$(basename "$ROOT")" | tr -c 'A-Za-z0-9._-' '-')"
echo "$(cache_home)/$slug-$(path_digest)"
}
# One cache per worktree means throwaway worktrees would otherwise leave
# caches behind forever. Each cache records the worktree it belongs to,
# and any cache whose worktree no longer exists is collected here, so
# growth is bounded by the number of worktrees that actually exist. The
# whole tree also sits under XDG_CACHE_HOME (~/.cache by default), so it
# is disposable by definition: `rm -rf "${XDG_CACHE_HOME:-~/.cache}/vaultik-lint"`
# costs nothing but the next run's cold cache.
prune_dead_caches() {
home="$(cache_home)"
if [ ! -d "$home" ]; then
return 0
fi
for dir in "$home"/*; do
if [ ! -f "$dir/worktree" ]; then
continue
fi
owner="$(cat "$dir/worktree")"
if [ -z "$owner" ]; then
continue
fi
if [ ! -d "$owner" ]; then
rm -rf "$dir"
fi
done
}
prepare_cache() {
cache="$1"
mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint"
echo "$ROOT" >"$cache/worktree"
}
# Run the linter, wherever it is that this script is allowed to run it.
run_linter() {
if in_lint_container; then
golangci-lint run "$@" ./...
return $?
fi
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env GOFLAGS=-buildvcs=false \
--env GOCACHE=/cache/go-build \
--env GOMODCACHE=/cache/go-mod \
--env GOLANGCI_LINT_CACHE=/cache/golangci-lint \
--volume "$ROOT:/src" \
--volume "$CACHE:/cache" \
--workdir /src \
"$IMAGE" \
golangci-lint run "$@" ./...
}
# Run the linter, streaming its combined output while also capturing it,
# and hand back its exit status. The output has to be inspected before
# it is believed, which is why this script no longer just execs the
# linter. `tee` would swallow the status, so it is smuggled out through
# a file: there is no pipefail in POSIX sh.
run_capture() {
capture="$1"
shift
rm -f "$capture.status"
{
rc=0
# `set -e` is in force inside this subshell too, so the status
# has to be caught here: an unguarded non-zero exit (which is
# what "the linter found something" looks like) would abort the
# subshell before the status was ever written.
run_linter "$@" 2>&1 || rc=$?
echo "$rc" >"$capture.status"
} | tee "$capture"
if [ ! -s "$capture.status" ]; then
echo "lint: the linter did not report an exit status" >&2
exit 1
fi
read -r captured_status <"$capture.status"
rm -f "$capture.status"
return "$captured_status"
}
# Reject output that cannot describe this tree. See script/lint-audit
# for what that means and why: in short, a finding citing a file that is
# not here means the result being reported was produced somewhere else,
# and a PASS built out of another checkout's analysis is silent (issue
# #99). The audit therefore runs on clean output as well.
audit_output() {
capture="$1"
if ! "$ROOT/script/lint-audit" "$capture"; then
if [ -n "$CACHE" ]; then
echo " this tree's lint cache: $CACHE" >&2
fi
exit 1
fi
}
main() {
cd "$ROOT"
IMAGE="$(lint_image)"
if [ -z "$IMAGE" ]; then
echo "lint: no lint stage found in $DOCKERFILE" >&2
exit 1
fi
CACHE=""
if in_lint_container; then
# No docker daemon in here, so there is no fallback: a mismatch
# is a hard error rather than a quiet substitution.
installed="$(installed_version)"
pinned="$(pinned_version)"
if [ -z "$installed" ] || [ "$installed" != "$pinned" ]; then
cat >&2 <<EOF
lint: VAULTIK_LINT_IN_CONTAINER is set, so this is expected to be
running inside the Dockerfile's pinned lint image, but the golangci-lint
on PATH does not match the pin.
pinned: $pinned ($IMAGE)
installed: ${installed:-<none>}
EOF
exit 1
fi
else
require_docker "$IMAGE"
prune_dead_caches
CACHE="$(cache_dir)"
prepare_cache "$CACHE"
fi
capture="$(mktemp "${TMPDIR:-/tmp}/vaultik-lint.XXXXXX")"
trap 'rm -f "$capture" "$capture.status"' EXIT HUP INT TERM
attempt=1
while :; do
status=0
run_capture "$capture" "$@" || status=$?
if grep -Fq "$LOCK_MESSAGE" "$capture"; then
if [ "$attempt" -lt "$LOCK_ATTEMPTS" ]; then
echo "lint: another golangci-lint holds the lock;" \
"retrying in ${LOCK_SLEEP}s" \
"(attempt $attempt of $LOCK_ATTEMPTS)" >&2
sleep "$LOCK_SLEEP"
attempt=$((attempt + 1))
continue
fi
cat >&2 <<EOF
lint: gave up after $LOCK_ATTEMPTS attempts, each blocked by another
golangci-lint holding the cross-process lock. This is NOT a lint
verdict: the tree was never analysed. Re-run when the other run has
finished.
EOF
exit 1
fi
audit_output "$capture"
exit "$status"
done
}
main "$@"