From af607e35977f1934da44e8af86f3235e90d499da Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 04:52:22 +0200 Subject: [PATCH] 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. --- Dockerfile | 6 +++ Makefile | 7 ++- README.md | 23 +++++++--- TODO.md | 11 +++++ script/bootstrap | 12 +++-- script/lint | 116 ++++++++++++++++++++++++++++++++++++++++++++++- script/lint-fix | 9 ++-- 7 files changed, 167 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index cebd9d9..267f8ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 626882e..057a916 100644 --- a/Makefile +++ b/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: diff --git a/README.md b/README.md index c6a426f..a65786b 100644 --- a/README.md +++ b/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 diff --git a/TODO.md b/TODO.md index e4374f0..723fa4d 100644 --- a/TODO.md +++ b/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` diff --git a/script/bootstrap b/script/bootstrap index 94f46fc..c41f677 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -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). diff --git a/script/lint b/script/lint index 9b57aea..3819049 100755 --- a/script/lint +++ b/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 </dev/null 2>&1; then + cat >&2 <&2 + exit 1 + fi + + if [ "$(installed_version)" = "$(pinned_version)" ]; then + exec golangci-lint run "$@" ./... + fi + + run_in_docker "$image" "$@" } main "$@" diff --git a/script/lint-fix b/script/lint-fix index 12709a0..74679fe 100755 --- a/script/lint-fix +++ b/script/lint-fix @@ -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 "$@"