#!/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 running INSIDE that image: the Dockerfile's lint stage # runs `make lint`, and there is no docker daemon in there. That stage # sets VAULTIK_LINT_IN_CONTAINER=1, and only when that variable is set # is a golangci-lint on PATH used directly - and then only if its # version is exactly the pin. Version equality alone is deliberately NOT # enough: it also matches a developer's locally installed copy of the # same version, which is a different build with a different Go # toolchain, reached by a different code path, and it would bypass the # digest pin this script exists to enforce. /.dockerenv was considered # as the context signal and rejected: dockerd creates it for `docker # run`, but it is not reliably present during a BuildKit `docker build`, # which is exactly the case the exception exists for. # # The linter's output is checked before it is believed: every run is # audited by script/lint-audit for findings that cannot belong to this # tree, and a run refused by golangci-lint's cross-process lock is # retried rather than reported as a verdict. See the lock-retry loop in # main and the header of script/lint-audit. # # 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" # golangci-lint takes a cross-process lock and refuses to start while # another instance holds it. That refusal is not a lint result, and # exiting non-zero on it is indistinguishable to a caller from real # findings - so it is retried rather than reported. Bounded, because a # lock that is never released must fail rather than hang. LOCK_MESSAGE="parallel golangci-lint is running" LOCK_ATTEMPTS=6 LOCK_SLEEP=15 # 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 # # `version --short` prints the bare version and is the interface meant # for this (checked against 2.10.1 and 2.12.2). The banner scrape below # it is a fallback for a release where --short is absent or silent; the # banner's exact wording is not a stable interface, which is why it is # no longer the primary parse. installed_version() { command -v golangci-lint >/dev/null 2>&1 || return 0 short="$(golangci-lint version --short 2>/dev/null | tr -d '[:space:]' | sed -e 's/^v//')" case "$short" in *[0-9].[0-9]*.[0-9]*) echo "$short" return 0 ;; esac golangci-lint version 2>/dev/null | awk ' { for (i = 1; i <= NF; i++) { if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) { print $i exit } } }' } # True inside the Dockerfile's lint stage, which sets this. Nothing else # sets it: setting it by hand on a host is an explicit, visible decision # to lint with an unpinned binary, not something reached by accident. in_lint_container() { [ "${VAULTIK_LINT_IN_CONTAINER:-}" = "1" ] } require_docker() { image="$1" if ! command -v docker >/dev/null 2>&1; then cat >&2 </dev/null 2>&1; then cat >&2 </dev/null 2>&1; then printf '%s' "$ROOT" | sha256sum | cut -c1-12 elif command -v shasum >/dev/null 2>&1; then printf '%s' "$ROOT" | shasum -a 256 | cut -c1-12 else printf '%s' "$ROOT" | cksum | tr -cd '0-9' | cut -c1-12 fi } # Caches for the containerized linter, private to THIS worktree. # # 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. Keeping them keyed on the worktree path is # what keeps them correct. One shared cache for the whole repo was the # defect in issue #99: two worktrees of this repo have identical file # contents, so their cache keys collide, and golangci-lint replays the # stored results - including the file paths recorded when they were # produced. That silently reports one worktree's findings, or one # worktree's clean bill of health, for another. cache_dir() { slug="$(printf '%s' "$(basename "$ROOT")" | tr -c 'A-Za-z0-9._-' '-')" echo "$(cache_home)/$slug-$(path_digest)" } # One cache per worktree means throwaway worktrees would otherwise leave # caches behind forever. Each cache records the worktree it belongs to, # and any cache whose worktree no longer exists is collected here, so # growth is bounded by the number of worktrees that actually exist. The # whole tree also sits under XDG_CACHE_HOME (~/.cache by default), so it # is disposable by definition: `rm -rf "${XDG_CACHE_HOME:-~/.cache}/vaultik-lint"` # costs nothing but the next run's cold cache. prune_dead_caches() { home="$(cache_home)" if [ ! -d "$home" ]; then return 0 fi for dir in "$home"/*; do if [ ! -f "$dir/worktree" ]; then continue fi owner="$(cat "$dir/worktree")" if [ -z "$owner" ]; then continue fi if [ ! -d "$owner" ]; then # The Go module cache inside is deliberately read-only, and # rm(1) cannot unlink a file out of a directory it may not # write, so the tree has to be made writable first. And # failing to tidy up is a housekeeping problem, never a # reason to fail a lint: without the fallback below, `set # -e` turns a stale cache that will not delete into a lint # error, which is a gate failing for a reason that has # nothing to do with the code. (Observed, not theorised.) chmod -R u+w "$dir" 2>/dev/null || true if ! rm -rf "$dir" 2>/dev/null; then # Restore the marker on a partial removal: an # unmarked leftover would be skipped by every future # run and never collected. mkdir -p "$dir" 2>/dev/null || true echo "$owner" >"$dir/worktree" 2>/dev/null || true echo "lint: could not remove stale cache $dir" >&2 fi fi done } prepare_cache() { cache="$1" mkdir -p "$cache/go-build" "$cache/go-mod" "$cache/golangci-lint" echo "$ROOT" >"$cache/worktree" } # Run the linter, wherever it is that this script is allowed to run it. run_linter() { if in_lint_container; then golangci-lint run "$@" ./... return $? fi 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 "$@" ./... } # Run the linter, streaming its combined output while also capturing it, # and hand back its exit status. The output has to be inspected before # it is believed, which is why this script no longer just execs the # linter. `tee` would swallow the status, so it is smuggled out through # a file: there is no pipefail in POSIX sh. run_capture() { capture="$1" shift rm -f "$capture.status" { rc=0 # `set -e` is in force inside this subshell too, so the status # has to be caught here: an unguarded non-zero exit (which is # what "the linter found something" looks like) would abort the # subshell before the status was ever written. run_linter "$@" 2>&1 || rc=$? echo "$rc" >"$capture.status" } | tee "$capture" if [ ! -s "$capture.status" ]; then echo "lint: the linter did not report an exit status" >&2 exit 1 fi read -r captured_status <"$capture.status" rm -f "$capture.status" return "$captured_status" } # Reject output that cannot describe this tree. See script/lint-audit # for what that means and why: in short, a finding citing a file that is # not here means the result being reported was produced somewhere else, # and a PASS built out of another checkout's analysis is silent (issue # #99). The audit therefore runs on clean output as well. audit_output() { capture="$1" if ! "$ROOT/script/lint-audit" "$capture"; then if [ -n "$CACHE" ]; then echo " this tree's lint cache: $CACHE" >&2 fi exit 1 fi } main() { cd "$ROOT" IMAGE="$(lint_image)" if [ -z "$IMAGE" ]; then echo "lint: no lint stage found in $DOCKERFILE" >&2 exit 1 fi CACHE="" if in_lint_container; then # No docker daemon in here, so there is no fallback: a mismatch # is a hard error rather than a quiet substitution. installed="$(installed_version)" pinned="$(pinned_version)" if [ -z "$installed" ] || [ "$installed" != "$pinned" ]; then cat >&2 <} EOF exit 1 fi else require_docker "$IMAGE" prune_dead_caches CACHE="$(cache_dir)" prepare_cache "$CACHE" fi capture="$(mktemp "${TMPDIR:-/tmp}/vaultik-lint.XXXXXX")" trap 'rm -f "$capture" "$capture.status"' EXIT HUP INT TERM attempt=1 while :; do status=0 run_capture "$capture" "$@" || status=$? if grep -Fq "$LOCK_MESSAGE" "$capture"; then if [ "$attempt" -lt "$LOCK_ATTEMPTS" ]; then echo "lint: another golangci-lint holds the lock;" \ "retrying in ${LOCK_SLEEP}s" \ "(attempt $attempt of $LOCK_ATTEMPTS)" >&2 sleep "$LOCK_SLEEP" attempt=$((attempt + 1)) continue fi cat >&2 <