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 "$@"