Run the linter at the pinned version locally too (closes #78)
All checks were successful
check / check (pull_request) Successful in 2m18s
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:
@@ -1,4 +1,10 @@
|
|||||||
# Lint stage
|
# 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
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
||||||
|
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -56,10 +56,13 @@ clean:
|
|||||||
rm -f vaultik
|
rm -f vaultik
|
||||||
go clean
|
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:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
|
||||||
|
|
||||||
# Run tests with coverage.
|
# Run tests with coverage.
|
||||||
test-coverage:
|
test-coverage:
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
* `script/bootstrap` — install all development dependencies (go,
|
* `script/bootstrap` — install all development dependencies (go, sqlite3,
|
||||||
golangci-lint, Go module download)
|
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/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
* `script/projectname` — print the project name (used for the Docker
|
* `script/projectname` — print the project name (used for the Docker
|
||||||
image tag)
|
image tag)
|
||||||
* `script/test` — run the test suite (verbose rerun on failure)
|
* `script/test` — run the test suite (verbose rerun on failure)
|
||||||
* `script/lint` — run `golangci-lint run ./...`
|
* `script/lint` — run `golangci-lint run ./...` at the exact version CI
|
||||||
* `script/lint-fix` — apply the linter's autofixes (rewrites files)
|
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` — format all code (writes)
|
||||||
* `script/fmt-check` — check formatting (read-only)
|
* `script/fmt-check` — check formatting (read-only)
|
||||||
* `script/check` — run `script/test`, `script/lint`, and
|
* `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/docker` — build the Docker image tagged via
|
||||||
`script/projectname`
|
`script/projectname`
|
||||||
* `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
* `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
|
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
||||||
not change files), then `script/check`
|
not change files), then `script/check`
|
||||||
* `script/install-precommit` — install the git pre-commit hook that
|
* `script/install-precommit` — install the git pre-commit hook that
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -19,6 +19,17 @@ or delete the branch.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-08-09: Finished the lint remediation under the canonical
|
||||||
`.golangci.yml` (issue #61, which also unblocks issue #59). The
|
`.golangci.yml` (issue #61, which also unblocks issue #59). The
|
||||||
remaining findings were fixed behavior-preservingly: `wsl_v5`
|
remaining findings were fixed behavior-preservingly: `wsl_v5`
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ main() {
|
|||||||
# Go toolchain
|
# Go toolchain
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# golangci-lint: packaged in nix, brew, and apk. There is no apt
|
# golangci-lint is deliberately NOT installed: script/lint runs the
|
||||||
# package; on apt systems install it manually from a hash-verified
|
# digest-pinned golangci-lint image from the Dockerfile's lint stage,
|
||||||
# GitHub release archive (never curl | sh).
|
# so whatever a package manager happens to ship would only be a
|
||||||
if missing golangci-lint; then
|
# shadow of the pinned version that could drift from CI.
|
||||||
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
|
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
|
fi
|
||||||
|
|
||||||
# sqlite3 CLI: the test suite shells out to it (VACUUM).
|
# sqlite3 CLI: the test suite shells out to it (VACUUM).
|
||||||
|
|||||||
116
script/lint
116
script/lint
@@ -1,12 +1,126 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter.
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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() {
|
main() {
|
||||||
cd "$ROOT"
|
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 "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
# for every finding the enabled linters know how to fix; findings
|
# for every finding the enabled linters know how to fix; findings
|
||||||
# without an autofix are reported but left alone (exit status is
|
# without an autofix are reported but left alone (exit status is
|
||||||
# nonzero while any remain).
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
exec "$SCRIPT_DIR/lint" --fix "$@"
|
||||||
golangci-lint run --fix ./...
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user