Run the linter at the pinned version locally too (closes #78)
All checks were successful
check / check (pull_request) Successful in 2m18s

`script/lint` ran whatever `golangci-lint` was on `PATH` while CI ran
the digest-pinned image from the `Dockerfile` lint stage. The two
versions disagree about real findings, so `make check` could be green
on a tree CI fails - and, on this host's 2.10.1, red on a tree CI
passes. A gate that can differ from CI is not a gate.

`script/lint` now runs the pinned image itself. The single source of
truth for the linter version is the `Dockerfile` lint stage `FROM`
line: `script/lint` parses the image reference (tag AND digest) out of
it with awk and runs exactly that image, so bumping the linter is a
one-line edit there and nowhere else. The duplicate pin in the
`Makefile` `deps` target (`go install ...@v2.12.2`) and the unpinned
`golangci-lint` install in `script/bootstrap` are removed rather than
kept in sync: with linting containerized, a second copy on `PATH` is
only a way to drift.

A `golangci-lint` on `PATH` is used only when its version is exactly
equal to the pin - the same binary by definition, and the case that
matters is the lint stage itself, which runs `make lint` inside the
pinned container where no Docker daemon exists. Every other version
goes through Docker, and a missing or unreachable daemon is a hard
error naming the required image, never a silent fallback.

The container run mounts persistent `GOCACHE`, `GOMODCACHE` and
`GOLANGCI_LINT_CACHE` directories under `${XDG_CACHE_HOME:-~/.cache}`
and runs as the invoking uid/gid, so repeat runs stay fast (2.7s warm
vs 1.8s for the ambient binary) and nothing lands root-owned.
`script/lint-fix` delegates to `script/lint --fix` so autofixes come
from the same pinned linter.

README documents that `make check` is authoritative because of this,
and points at `script/cibuild` as the full CI-equivalent gate.
This commit is contained in:
2026-08-09 02:37:18 +00:00
parent e496aa334b
commit 1808773195
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 "$@"