Files
vaultik/script/lint
clawbot af607e3597
All checks were successful
check / check (push) Successful in 6s
Run the linter at the pinned version locally too (closes #78)
script/lint ran bare golangci-lint from PATH while CI and the Dockerfile
pinned v2.12.2 by digest, so make lint and CI could disagree about
findings. That drift ran both directions: it produced two false green
claims during the lint remediation, and on an ambient 2.10.1 it also
reported four gosec findings on a tree CI linted clean.

script/lint now extracts the image reference - tag and digest - from the
Dockerfile lint stage FROM line and runs that exact image under docker.
The Dockerfile FROM line is the single source of truth for the linter
version; the duplicate pins in the Makefile deps target and in
script/bootstrap are removed rather than kept in sync.

A golangci-lint on PATH is used only when its version exactly equals the
pin, which is what makes the in-container lint stage work (the Dockerfile
runs make lint inside the pinned image, where there is no docker daemon).
Any other version, or none, goes through docker. When docker is
unavailable the script fails with an actionable message and never falls
back to a different linter version.

script/lint-fix delegates to script/lint --fix so autofixes come from the
pinned linter too. The container mounts persistent build and module
caches and runs as the invoking uid/gid.

Verified by reinstating the four historical nolint directives that 2.10.1
requires and 2.12.2 reports as unused: the old script passed on that tree
and the new one fails with four nolintlint findings.
2026-08-09 04:52:22 +02:00

127 lines
3.6 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 a golangci-lint on PATH whose version is exactly equal to
# the pin: that is the same linter, so it is run directly. This is what
# happens inside the lint container itself (Dockerfile runs `make lint`,
# and there is no docker daemon in there). A PATH binary at any other
# version is never used - that silent substitution is the bug this
# script exists to prevent.
#
# 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"
# 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
installed_version() {
command -v golangci-lint >/dev/null 2>&1 || return 0
golangci-lint version 2>/dev/null | awk '
{
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) {
print $i
exit
}
}
}'
}
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, or install golangci-lint $(pinned_version) on PATH.
Linting with any other version is not supported: it is what lets a
local run pass while CI fails.
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), or
install golangci-lint $(pinned_version) on PATH. This script will not
fall back to a different linter version.
EOF
exit 1
fi
}
# Caches for the containerized linter. 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.
cache_root() {
echo "${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/vaultik-lint"
}
run_in_docker() {
image="$1"
shift
require_docker "$image"
cache="$(cache_root)"
mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint"
exec 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 "$@" ./...
}
main() {
cd "$ROOT"
image="$(lint_image)"
if [ -z "$image" ]; then
echo "lint: no lint stage found in $DOCKERFILE" >&2
exit 1
fi
if [ "$(installed_version)" = "$(pinned_version)" ]; then
exec golangci-lint run "$@" ./...
fi
run_in_docker "$image" "$@"
}
main "$@"