Run the linter at the pinned version locally too (closes #78)
All checks were successful
check / check (push) Successful in 6s

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.
This commit was merged in pull request #79.
This commit is contained in:
2026-08-09 04:52:22 +02:00
parent e496aa334b
commit af607e3597
7 changed files with 167 additions and 17 deletions

View File

@@ -1,4 +1,10 @@
# Lint stage
#
# This FROM line is the single source of truth for the linter version:
# script/lint parses the image reference out of it and runs that exact
# image, so a local `make lint` and CI use the same linter. Bump the
# linter here (tag AND digest) and nowhere else.
#
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint

View File

@@ -56,10 +56,13 @@ clean:
rm -f vaultik
go clean
# Install dependencies.
# Install dependencies. The linter is deliberately not installed here:
# script/lint runs the digest-pinned golangci-lint image declared by the
# Dockerfile's lint stage, which is the single source of truth for the
# linter version. A second, separately pinned copy on PATH could drift
# from it and make a local `make lint` disagree with CI.
deps:
go mod download
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
# Run tests with coverage.
test-coverage:

View File

@@ -564,23 +564,34 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
* `script/bootstrap` — install all development dependencies (go,
golangci-lint, Go module download)
* `script/bootstrap` — install all development dependencies (go, sqlite3,
Go module download). It deliberately does not install `golangci-lint`;
see `script/lint` below.
* `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit`
* `script/projectname` — print the project name (used for the Docker
image tag)
* `script/test` — run the test suite (verbose rerun on failure)
* `script/lint` — run `golangci-lint run ./...`
* `script/lint-fix` — apply the linter's autofixes (rewrites files)
* `script/lint` — run `golangci-lint run ./...` at the exact version CI
uses, by running the digest-pinned `golangci-lint` image declared by
the `Dockerfile` lint stage (requires Docker; it fails loudly rather
than falling back to a differently versioned `golangci-lint` on
`PATH`). That `FROM` line is the single source of truth for the linter
version — bump it there and nowhere else.
* `script/lint-fix` — apply the linter's autofixes (rewrites files),
using the same pinned linter
* `script/fmt` — format all code (writes)
* `script/fmt-check` — check formatting (read-only)
* `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`
`script/fmt-check`. This is authoritative *because* `script/lint` uses
the pinned linter: a local `make check` and CI cannot disagree about
lint findings.
* `script/docker` — build the Docker image tagged via
`script/projectname`
* `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks)
runs the checks). This is the full CI-equivalent gate — it runs the
checks in the same containers CI does, from a clean copy of the tree,
so it also catches anything that depends on host state.
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
not change files), then `script/check`
* `script/install-precommit` — install the git pre-commit hook that

11
TODO.md
View File

@@ -19,6 +19,17 @@ or delete the branch.
# Completed Steps
- 2026-08-09: Closed the gap between `make lint` and CI (issue #78).
`script/lint` now runs the digest-pinned `golangci-lint` image taken
from the `Dockerfile` lint stage, which is the single source of truth
for the linter version; the duplicate pin in the `Makefile` `deps`
target and the unpinned `golangci-lint` install in `script/bootstrap`
are gone. A `golangci-lint` on `PATH` is used only when its version is
exactly the pinned one (which is how the lint stage runs it inside the
container); anything else goes through Docker, and a missing or
unreachable Docker daemon is a hard error rather than a silent
fallback. `make check` is therefore now as trustworthy as
`script/cibuild`.
- 2026-08-09: Finished the lint remediation under the canonical
`.golangci.yml` (issue #61, which also unblocks issue #59). The
remaining findings were fixed behavior-preservingly: `wsl_v5`

View File

@@ -58,11 +58,13 @@ main() {
# Go toolchain
if missing go; then pkg_install go golang go go; fi
# golangci-lint: packaged in nix, brew, and apk. There is no apt
# package; on apt systems install it manually from a hash-verified
# GitHub release archive (never curl | sh).
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
# golangci-lint is deliberately NOT installed: script/lint runs the
# digest-pinned golangci-lint image from the Dockerfile's lint stage,
# so whatever a package manager happens to ship would only be a
# shadow of the pinned version that could drift from CI.
if missing docker; then
echo "bootstrap: docker not found; script/lint needs it to run" >&2
echo "bootstrap: the pinned linter (see the Dockerfile lint stage)" >&2
fi
# sqlite3 CLI: the test suite shells out to it (VACUUM).

View File

@@ -1,12 +1,126 @@
#!/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"
golangci-lint run ./...
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 "$@"

View File

@@ -3,13 +3,16 @@
# for every finding the enabled linters know how to fix; findings
# without an autofix are reported but left alone (exit status is
# nonzero while any remain).
#
# Delegates to script/lint so the autofixer is the same pinned linter
# version that script/lint and CI use - fixes written by a different
# version are not necessarily fixes for the version that gates.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --fix ./...
exec "$SCRIPT_DIR/lint" --fix "$@"
}
main "$@"