Run the linter at the pinned version locally too (closes #78) #79
@@ -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
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -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:
|
||||
|
||||
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
|
||||
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
11
TODO.md
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
116
script/lint
116
script/lint
@@ -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 "$@"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user