diff --git a/Dockerfile b/Dockerfile index 62efdec..7e1f610 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,72 +22,64 @@ COPY . . # (the pinned base image, go mod download) keeps its cache; only the # gates go cold. ARG CHECK_EPOCH + +# The linter is invoked directly here, not through `make lint`. That +# target now runs `docker build -f Dockerfile.lint`, and a docker build +# cannot run a docker build: routing the gate through make would mean +# nesting docker inside this image. Same reason `make check` is gone +# from the build stage below. `make fmt-check` stays as it is — it is a +# gate, not the aggregate, and it shells out to nothing. RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check -RUN echo "gate lint, epoch ${CHECK_EPOCH}" && make lint + +# The FROM above and the one in Dockerfile.lint pin the same linter +# twice, and nothing else keeps them in sync; this fails the build when +# they disagree. See the script for why it restates neither pin. +RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \ + script/verify-lint-image-pin + +# Same config-schema check Dockerfile.lint runs, kept here so this build +# gates on exactly what script/lint gates on. It validates against a +# schema the pinned binary embeds, so it needs no network. +RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \ + golangci-lint config verify --config .golangci.yml + +RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \ + golangci-lint run --config .golangci.yml ./... # Build stage # golang:1.25-alpine, 2026-07-23 FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder # We never build or run as root. Create an unprivileged user and point -# HOME and the Go caches at its home so go build/test and golangci-lint -# can write their caches when we drop to it below. $GOPATH/bin is on -# PATH because that is where script/bootstrap's `go install` lands: a -# tool bootstrap installs must be runnable afterwards, and bootstrap -# verifies its own installs against what PATH resolves, so leaving that -# directory unsearched would make any install it performs both unusable -# and self-reported as shadowed. Nothing in this image is shadowed by -# it: the directory does not exist until bootstrap runs. +# HOME and the Go caches at its home so go build and go test can write +# their caches when we drop to it below. $GOPATH/bin is deliberately not +# on PATH: script/bootstrap no longer `go install`s anything (the linter +# runs from a pinned image, never from a host install), so nothing lands +# there and adding it would only widen what this image resolves. RUN adduser -D -u 1000 builder ENV HOME=/home/builder ENV GOPATH=/home/builder/go ENV GOCACHE=/home/builder/.cache/go-build -ENV PATH=/home/builder/go/bin:$PATH WORKDIR /src -# Reuse the linter binary from the lint stage. This copy is load-bearing -# twice over and must not be deleted as redundant now that bootstrap -# below can install a linter of its own: -# -# - It is the only thing making this stage depend on the lint stage, -# so it is what forces BuildKit to finish fmt-check and lint before -# compilation and tests start. Remove it and the fail-fast design -# dies silently: the build stops gating on lint and still exits 0. -# - Together with the check below it is what keeps the two stages on -# one toolchain: `make check` here runs the very binary the lint -# stage ran, not a second one that happens to agree. Bootstrap -# installing its own linter here instead would restore exactly the -# two-independent-toolchains problem the copy prevents (and cost a -# from-source build of the linter). -COPY --from=lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint - -# Fail the build, naming both versions, unless the binary that just -# arrived from the lint stage is the version script/bootstrap pins. -# -# Nothing else enforces that. The linter version is pinned in two -# independent places — the lint stage's image digest above and -# GOLANGCI_LINT_VERSION in script/bootstrap — and bumping one alone is -# an easy mistake. Without this check that mistake is invisible: -# bootstrap below would see a version that is not its pin, quietly -# rebuild the pinned one from source into a directory that is on PATH, -# verify that, and exit 0. The build would go green with the lint stage -# having linted at one version and `make check` at another, which is -# precisely the divergence the copy above exists to prevent. -# -# It runs here, before bootstrap, so that a reinstall cannot satisfy it, -# and it needs no CHECK_EPOCH: its only inputs are the copied binary and -# script/, so Docker invalidates this layer exactly when a cached result -# would stop being true. -COPY script/ script/ -RUN script/verify-linter-pin /usr/local/bin/golangci-lint +# No-op file copy whose only purpose is the build-graph edge: it is what +# makes this stage depend on the lint stage, and so what forces BuildKit +# to finish fmt-check, the pin guard and lint before compilation and +# tests start. Remove it and the fail-fast design dies silently — the +# build stops gating on lint and still exits 0. It replaces a copy of +# the linter binary itself, which is no longer wanted here: nothing in +# this stage runs the linter, because `make lint` is now a docker build +# and a docker build cannot run inside one. +COPY --from=lint /src/go.sum /dev/null # Install development prerequisites the same way a developer does, -# rather than duplicating the installs inline. Only script/ (copied -# above) and the dependency manifests are copied first, nothing else, so -# this layer stays cached until the scripts or the dependencies change — -# bootstrap ends in `go mod download`, which is why there is no separate +# rather than duplicating the installs inline. Only script/ and the +# dependency manifests are copied first, nothing else, so this layer +# stays cached until the scripts or the dependencies change — bootstrap +# ends in `go mod download`, which is why there is no separate # invocation of it here. +COPY script/ script/ COPY go.mod go.sum ./ RUN script/bootstrap @@ -102,11 +94,19 @@ USER builder # permission-denied test paths are exercised legitimately (root would # bypass the chmod(0) the tests rely on). # +# The gates are the individual targets, not `make check`: that aggregate +# runs `script/lint`, which is now a docker build, and nothing inside an +# image build may shell out to docker. Lint is not skipped by this — it +# ran in the lint stage above, which this stage's COPY --from makes a +# prerequisite. `make`, not the scripts directly, because the Makefile's +# `export CGO_ENABLED = 0` applies only to what it invokes. +# # Second per-stage declaration of the gate cache-buster; see the lint # stage above for why one is not enough. It is placed after USER so the # drop to the unprivileged user still happens before the checks run. ARG CHECK_EPOCH -RUN echo "gate check, epoch ${CHECK_EPOCH}" && make check +RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test +RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check RUN make build diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..26927ad --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,55 @@ +# Lint-only image: this is how the linter runs, everywhere. The repo is +# COPYed into the pinned golangci-lint image and the linter runs as a +# build step, so a successful build IS a clean lint. golangci-lint is +# never installed on a host — one toolchain, pinned by digest, identical +# on a laptop and in CI — and this works even when the docker daemon is +# remote and bind mounts are impossible. +# +# script/lint builds this file. It is a separate image from the lint +# stage of the main Dockerfile because script/lint must not depend on +# the rest of that build; the two FROM lines are kept identical by +# script/verify-lint-image-pin, run as a gate below. +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 + +WORKDIR /src + +# Dependency layers first, so they stay cached across lint runs. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Cache-buster for the gate layers, and only for them. Caching of the +# lint run is waived by ruling: COPY is invalidated only by changed +# content, so on an unchanged tree the gates below would be served from +# cache and this build would exit 0 in under a second having run no +# linter at all. That exact false green has bitten this repo twice +# already (#32, #39). script/lint passes a fresh value on every +# invocation. +# +# Each gate RUN must reference the value, because BuildKit hashes the +# expanded command and not the ARG declaration: a declared but +# unreferenced ARG invalidates nothing. The ARG sits below the +# dependency layers deliberately — everything above it keeps its cache, +# only the gates go cold. +ARG CHECK_EPOCH + +# The linter version is pinned in two places, here and in the main +# Dockerfile's lint stage. Nothing else keeps them in sync, so a +# half-applied bump is a build failure; see the script. +RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \ + script/verify-lint-image-pin + +# Validates .golangci.yml against golangci-lint's JSON schema. The +# concern about this step was that it fetches that schema over a live, +# unpinned HTTPS call; measured on the pinned image, it does not. The +# binary carries the schema for its own version, so under +# `--network none` this both passes on a valid config and still rejects +# an invalid one with the jsonschema error. Linting therefore needs no +# network beyond pulling the pinned image. +RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \ + golangci-lint config verify --config .golangci.yml + +RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \ + golangci-lint run --config .golangci.yml ./... diff --git a/README.md b/README.md index 6975dff..58552d3 100644 --- a/README.md +++ b/README.md @@ -454,16 +454,12 @@ and may be invoked directly. The provided entrypoints are: develop this repository, idempotently, assuming nothing is present. `git`, `make`, and `go` come from the first of nix, apt, brew, or apk found on the host, and are presence-checked only. - `golangci-lint` is treated differently: it is checked against the - version pinned in the script (the version the `Dockerfile` lint - stage runs) and reinstalled with `go install` whenever the - installed version differs — older or newer, not merely absent — - because a host on any other version lints against different rules - than CI. After installing, the script verifies the pin against the - `golangci-lint` that `PATH` actually resolves; if a different copy - shadows the install, bootstrap fails, naming both the install - directory and the shadowing binary, rather than reporting a - success the gate would not honour. Ends with `go mod download`. + `golangci-lint` is deliberately **not** installed: it runs from a + digest-pinned image via `script/lint` and never from a host + install, so there is no host copy to drift from the pin. A missing + `docker` is warned about rather than installed or treated as + fatal — everything except linting works without it. Ends with + `go mod download`. - `script/setup` — make a fresh clone ready for development: runs `script/bootstrap`, then `script/install-precommit`. - `script/projectname` — print this project's name (`sfdupes`). @@ -472,14 +468,27 @@ and may be invoked directly. The provided entrypoints are: - `script/test` — run the test suite with a 30-second timeout and coverage enabled, rerunning verbosely on failure so the logs show which test failed. -- `script/lint` — run `golangci-lint` over the module with the - repository's `.golangci.yml`. +- `script/lint` — run the linter. It builds `Dockerfile.lint`, which + copies the repository into the digest-pinned + `golangci/golangci-lint` image and runs + `golangci-lint config verify` and `golangci-lint run` as build + steps, so a successful build is a clean lint. The linter is never + run on the host, which makes a working `docker` the one + prerequisite for linting — and therefore for `make check` and the + pre-commit hook. Once the pinned image is local, nothing here + needs the network: the config-schema validation used by + `config verify` comes from a schema the pinned binary embeds, and + was measured under `--network none` to both pass a valid config + and reject an invalid one. Because the daemon only ever sees a + build context, this works when the docker daemon is remote and + bind mounts are impossible. - `script/fmt` — format the Go sources in place (`gofmt -s -w`). Markdown is not formatted. - `script/fmt-check` — the read-only counterpart of `script/fmt`: prints any unformatted file and exits non-zero instead of writing. - `script/check` — run `script/test`, `script/lint`, and - `script/fmt-check`, in that order. Modifies nothing. + `script/fmt-check`, in that order. Modifies nothing. Needs + `docker`, because `script/lint` does. - `script/docker` — build the Docker image, tagged with the name from `script/projectname`. The `Dockerfile` runs the gates as build steps, so this is also the check a developer or reviewer @@ -493,24 +502,36 @@ and may be invoked directly. The provided entrypoints are: - `script/install-precommit` — install the git pre-commit hook that runs `script/precommit`. The hook is written to the common git directory, so the main checkout and every worktree share it. -- `script/verify-linter-pin` — fail unless a `golangci-lint` binary - (given as its argument, default whatever `PATH` resolves) is - exactly the version `script/bootstrap` pins, naming both versions - if not. The `Dockerfile` build stage runs it on the linter it - copies out of the lint stage: the version is pinned independently - in the lint stage's image digest and in `script/bootstrap`, and - bumping one alone would otherwise be absorbed silently by - bootstrap rebuilding its pin from source, leaving the two stages - on different linters under a green build. The pin is read from - `script/bootstrap`, which stays its single source of truth. +- `script/verify-lint-image-pin` — fail unless the + `golangci/golangci-lint` reference in `Dockerfile.lint` and the + one in the `Dockerfile` lint stage are the same image at the same + digest, naming both if not. The linter is pinned in those two + files and nothing else keeps them in sync, so a bump applied to + one alone would leave `make lint` and the `Dockerfile`'s + fail-fast lint stage checking the same tree against different + rulesets, both green. The guard restates neither pin — a third + copy would be the same drift one file further out — and runs as a + gate in both files, so `make lint`, `make check` and `make docker` + all catch it. -`script/docker` and `script/cibuild` both pass a freshly computed -`CHECK_EPOCH` build argument, and the `Dockerfile`'s gate steps -reference it. Without that, an unchanged tree lets Docker serve the -gate layers from cache and the build exits 0 having executed no tests -and no lint — a green it never earned. `CHECK_EPOCH` invalidates the -gate layers on every run while leaving the pinned base images and the -dependency layers cached. +`script/verify-linter-pin` used to live here. It compared a linter +binary against a version pin in `script/bootstrap`, and both of its +subjects are gone: no linter binary is copied between build stages any +more, and bootstrap pins no version because it installs no linter. The +drift it existed to catch has moved from binary-versus-pin to +pin-versus-pin, which is what `script/verify-lint-image-pin` above +checks. + +`script/lint`, `script/docker` and `script/cibuild` all pass a freshly +computed `CHECK_EPOCH` build argument, and the gate steps in +`Dockerfile.lint` and `Dockerfile` reference it. Without that, an +unchanged tree lets Docker serve the gate layers from cache and the +build exits 0 having executed no tests and no lint — a green it never +earned, and one this repository has produced twice. `CHECK_EPOCH` +invalidates the gate layers on every run while leaving the pinned base +images and the dependency layers cached. `script/lint`'s value carries +the process id as well as the epoch, because two lint runs land inside +the same second easily and a bare epoch would cache the second one. ## Build @@ -526,12 +547,14 @@ carries the compile recipe: pre-commit hook. - `make test` — run the test suite (30-second timeout; reruns with `-v` on failure). -- `make lint` — run `golangci-lint` with the repo config. +- `make lint` — run `golangci-lint` with the repo config, in Docker + (see `script/lint`); requires `docker`. - `make fmt` / `make fmt-check` — format Go sources / verify formatting without writing. - `make check` — `test`, `lint`, and `fmt-check`; modifies nothing. -- `make docker` — build the Docker image, which runs `make check` as - a build stage. + Requires `docker`, via `lint`. +- `make docker` — build the Docker image, which runs the gates as + build stages. - `make hooks` — install the pre-commit hook. - `make clean` — remove the binary. diff --git a/TODO.md b/TODO.md index 5b8a809..e1d7565 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,86 @@ # Completed Steps +- run all linting in Docker via `Dockerfile.lint` and `script/lint` + (2026-08-10, branch `next`, closes + https://git.eeqj.de/sneak/sfdupes/issues/46): per the owner ruling, the + linter runs inside a container invoked through the `script/` + entrypoint and is never installed on a host. New root + `Dockerfile.lint` COPYs the repo into the digest-pinned + `golangci/golangci-lint:v2.12.2` image and runs + `golangci-lint config verify` and `golangci-lint run` as build + steps, so a successful build IS a clean lint; `script/lint` is + reduced to building it. `script/bootstrap` loses the `go install`, + the pin constants, the version parser and `verify_golangci_lint` + outright rather than hardening them — with nothing linting on the + host, the `$GOPATH/bin` versus `PATH` problem that motivated them has + no subject — and now warns rather than fails when `docker` is absent. + Two traps handled. A lint build on an unchanged tree returns success + in well under a second having run no linter, which is + https://git.eeqj.de/sneak/sfdupes/issues/32 and + https://git.eeqj.de/sneak/sfdupes/issues/39 again, so + `Dockerfile.lint` carries `ARG CHECK_EPOCH` referenced + inside every gate `RUN` (BuildKit hashes the expanded command, not + the declaration) and `script/lint` passes `"$(date +%s)-$$"` — the + PID matters because two lint runs land inside the same second easily. + And nothing inside an image build may shell out to docker, so the + main `Dockerfile`'s lint stage now invokes `golangci-lint` directly + instead of `make lint`, and its build stage runs `make test` and + `make fmt-check` instead of the `make check` aggregate (`make`, not + the scripts bare, because the Makefile's `export CGO_ENABLED = 0` + only reaches what it invokes). `COPY --from=lint` + `/usr/bin/golangci-lint` is replaced by + `COPY --from=lint /src/go.sum /dev/null`: the copied binary was the + only edge forcing BuildKit to finish linting before the build stage + starts, and dropping it without replacing the edge would have ended + fail-fast linting silently under a still-green build. That is + canonical `REPO_POLICIES.md:107`'s ordering edge, restored. + `ENV PATH=/home/builder/go/bin:$PATH` is gone with the `go install` + that justified it. `script/verify-linter-pin` is retired, deleted + along with its README entry, because both of its subjects ceased to + exist in the same change: it compared a linter binary against + `GOLANGCI_LINT_VERSION` in `script/bootstrap`, and there is now + neither a binary crossing between stages nor a version pin in + bootstrap. The drift it guarded has not gone away, it has moved — the + linter is still pinned twice, now as the `FROM` line of + `Dockerfile.lint` and the `FROM` line of the `Dockerfile` lint stage, + with nothing syncing them, which is exactly what + https://git.eeqj.de/sneak/sfdupes/issues/42 made a build failure. Its + replacement is one new `script/verify-lint-image-pin`, + run as a gate in both files, which compares the two references to + each other and deliberately restates neither: a hardcoded expected + digest would be a third copy and the same drift one file further out. + `golangci-lint config verify` is included per the ruling, and the + concern about its unpinned live HTTPS schema fetch was measured + rather than assumed — under `--network none` the pinned binary both + passes a valid config and rejects an invalid one with the jsonschema + error, so it validates from an embedded schema and linting needs no + network; the README says so instead of claiming a requirement that + does not exist. Verified: `make lint` green with every `PATH` + directory containing a `golangci-lint` removed + (`/home/user/go/bin`, `/home/user/.local/bin`, `/usr/local/bin`; + `command -v golangci-lint` empty); two consecutive `script/lint` runs + on an untouched tree both executed the linter, 27.7s and 28.7s in the + lint step under distinct epochs with the `COPY . .` layer `CACHED` + above them, at 42.2s and 41.8s wall clock — the no-cache rule was not + weakened to shorten that. Negative control: a planted + `var unusedIssue46Sentinel = 1` failed `script/lint` with + `report.go:173:5: var unusedIssue46Sentinel is unused (unused)`, and + failed `make docker` at `[lint 9/9]` with the build stage stopped at + `[builder 3/12]` — `COPY --from=lint`, `script/bootstrap`, the test + gate and `make build` all zero occurrences — then reverted clean. The + drift guard fails on a tag-only disagreement, on a digest-only + disagreement, and on an unreadable reference, naming both sides. + `make docker` green in 5m35s with all six gates executing under one + epoch (lint 37.6s, test 25.2s reporting + `ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not `(cached)`). + The non-root quirk still holds: in the builder image with the Go test + cache off, `--user 0:0` fails `TestScanHardlinkRunFailsTogether` + (exit 1) where the unprivileged user passes (exit 0). Noted for + follow-up, not fixed here: `golangci-lint` warns that the + `gomodguard` linter is deprecated since v2.12.0 in favour of + `gomodguard_v2`. + - install the Docker build stage's prerequisites by running `script/bootstrap` instead of `apk add --no-cache make` inline (2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical diff --git a/script/bootstrap b/script/bootstrap index 549a5f5..936ea8b 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -2,31 +2,15 @@ # script/bootstrap: install all dependencies needed to build and develop # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, -# or apk (detected in that order); assumes nothing is present. -# golangci-lint is installed via `go install` pinned to the same version -# the Dockerfile lint stage uses (never "latest"), and is reinstalled -# whenever the installed version differs from that pin. The install is -# then verified against the binary PATH actually resolves: if the pin is -# still not what would run, bootstrap fails instead of reporting -# success. +# or apk (detected in that order); assumes nothing is present (not git, +# make, or go). The linter is NOT installed: golangci-lint runs via +# docker only (script/lint), pinned by image digest, so the only lint +# prerequisite is a working docker — which is warned about, not +# installed, because everything except linting works without it. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-08-07 (same version as the Dockerfile lint stage). -# This is the single source of truth for the linter version: the module -# ref below and the version comparison in main() are both derived from -# it, so a bump here cannot half-apply. Written without a leading "v", -# the way `golangci-lint --version` reports it. -GOLANGCI_LINT_VERSION="2.12.2" -GOLANGCI_LINT_MODULE="github.com/golangci/golangci-lint/v2/cmd/golangci-lint" -GOLANGCI_LINT_REF="$GOLANGCI_LINT_MODULE@v$GOLANGCI_LINT_VERSION" - -# Seconds to allow `golangci-lint --version` to run. Bootstrap now -# executes the binary rather than merely locating it, so a wedged one -# must not hang the script. -GOLANGCI_LINT_VERSION_TIMEOUT="30" - PKGMGR="" SUDO="" APT_UPDATED="" @@ -74,73 +58,6 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } -# Echo the installed golangci-lint version, or nothing when the tool is -# absent. The binary reports e.g. -# golangci-lint has version X.Y.Z built with go1.26.5 from abc1234 ... -# so the version is the field after the literal word "version", and it -# carries no leading "v" (the module ref does). Some builds do print a -# leading "v", so strip one if present and compare bare versions. -# -# Only stdout is parsed; the binary's stderr is deliberately left -# connected to ours so that a present-but-broken linter (missing shared -# library, wrong architecture) says why instead of silently yielding the -# empty string. The call is bounded by timeout(1) where that exists — -# stock macOS has no timeout(1), and there the call runs unbounded, as -# it did before this check was version-aware. -golangci_lint_version() { - command -v golangci-lint >/dev/null 2>&1 || return 0 - if command -v timeout >/dev/null 2>&1; then - timeout "$GOLANGCI_LINT_VERSION_TIMEOUT" golangci-lint --version - else - golangci-lint --version - fi | awk ' - { - for (i = 1; i < NF; i++) { - if ($i == "version") { - v = $(i + 1) - sub(/^v/, "", v) - print v - exit - } - } - } - ' -} - -# Confirm that the golangci-lint just installed is the one that will -# actually run. `go install` writes into "$(go env GOBIN)" (or -# "$(go env GOPATH)/bin"), but `make lint` runs whatever PATH resolves -# first. When a wrong-version binary sits ahead of that directory — a -# nix profile, apt, brew, apk, or a tarball in /usr/local/bin — the -# install lands behind the shadow and changes nothing the gate uses. -# Exiting 0 there would leave the local gate linting against a different -# ruleset than CI while claiming success, which is the failure this -# whole check exists to prevent. Diagnose and stop: naming both paths is -# what makes it fixable. Reordering PATH or deleting someone else's -# binary is not bootstrap's call. -verify_golangci_lint() { - # Forget any remembered command locations first: the install may have - # created a binary in a directory the shell already searched. - hash -r 2>/dev/null || true - - goinstalldir="$(go env GOBIN)" - if [ -z "$goinstalldir" ]; then - goinstalldir="$(go env GOPATH)/bin" - fi - resolved="$(command -v golangci-lint 2>/dev/null || true)" - effective="$(golangci_lint_version)" - if [ "$effective" != "$GOLANGCI_LINT_VERSION" ]; then - echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \ - "$goinstalldir, but the golangci-lint on PATH is" \ - "${resolved:-not resolvable} and reports" \ - "${effective:-no parseable version}" >&2 - echo "bootstrap: the install is shadowed or unreachable; put" \ - "$goinstalldir ahead of it on PATH (or remove the shadowing" \ - "binary) and re-run" >&2 - exit 1 - fi -} - main() { cd "$ROOT" @@ -154,20 +71,15 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Lint tooling, pinned via go install (installs into - # "$(go env GOPATH)/bin"; ensure that is on your PATH). Unlike the - # system tools above this is version-checked, not presence-checked: - # the Dockerfile lint stage runs a digest-pinned linter, so a host - # running any other version lints against different rules and - # `make check` can go green on a commit CI then rejects. Any version - # that is not the pin — older or newer — is reinstalled, and the - # install is then verified to be the binary PATH resolves. - installed="$(golangci_lint_version)" - if [ "$installed" != "$GOLANGCI_LINT_VERSION" ]; then - echo "bootstrap: golangci-lint ${installed:-absent or unparseable}," \ - "want $GOLANGCI_LINT_VERSION; installing" - go install "$GOLANGCI_LINT_REF" - verify_golangci_lint + # Linting runs via docker only (script/lint), so docker is a lint + # prerequisite rather than something bootstrap installs. Warn, do + # not fail: everything except `make lint` — and, through it, + # `make check`, `make docker` and the pre-commit hook — works + # without it. + if missing docker; then + echo "bootstrap: WARNING: docker not found; make lint, make check" >&2 + echo "bootstrap: and make docker require it. Install docker to" >&2 + echo "bootstrap: run the linter." >&2 fi go mod download diff --git a/script/lint b/script/lint index 8017180..342b961 100755 --- a/script/lint +++ b/script/lint @@ -1,12 +1,26 @@ #!/bin/sh -# script/lint: run the linter. +# script/lint: run the linter. golangci-lint is never installed on a +# host: it runs via docker only, one way, everywhere — this builds +# Dockerfile.lint, which COPYs the repo into the digest-pinned +# golangci-lint image and lints as a build step, so a successful build +# is a clean lint. The only prerequisite is a working docker; once the +# pinned image is present nothing here reaches the network. +# +# CHECK_EPOCH is what makes the result mean anything. Without it docker +# serves the gate layers from cache on an unchanged tree and this exits +# 0 in well under a second having run no linter. The PID is in the value +# as well as the epoch because two lint runs land inside the same second +# easily, and `date +%s` alone would cache the second one. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - golangci-lint run --config .golangci.yml ./... + docker build \ + --build-arg CHECK_EPOCH="$(date +%s)-$$" \ + -f Dockerfile.lint \ + . } main "$@" diff --git a/script/verify-lint-image-pin b/script/verify-lint-image-pin new file mode 100755 index 0000000..ca6e488 --- /dev/null +++ b/script/verify-lint-image-pin @@ -0,0 +1,84 @@ +#!/bin/sh +# script/verify-lint-image-pin: fail unless the golangci-lint image +# referenced by Dockerfile.lint and the one referenced by the main +# Dockerfile's lint stage are the same image at the same digest. Our own +# extension to scripts-to-rule-them-all, not one of its entrypoints. +# +# The linter version is pinned in two independent files. That is the +# shape #42 turned into a build failure rather than tolerate: nothing +# else keeps the two in sync, and a bump applied to one file alone would +# leave `make lint` and the fail-fast lint stage of `make docker` +# linting the same tree against different rulesets, both green. This is +# the single guard that stops it, run as a gate in both files. +# +# It deliberately restates neither pin. A hardcoded expected digest here +# would be a third copy — one more thing to bump, and the same drift one +# file further out. It compares the two files to each other and knows +# nothing about which version is correct. +# +# A reference that cannot be read is a hard failure, not a skip: a +# comparison of two empty strings succeeds, which would turn this guard +# into exactly the unearned green it exists to prevent. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +LINT_DOCKERFILE="Dockerfile.lint" +MAIN_DOCKERFILE="Dockerfile" + +# Echo the single golangci-lint image reference in the named Dockerfile. +# Scans every argument of every FROM instruction rather than assuming a +# field position, so `FROM --platform=... img AS stage` reads correctly. +# Exits non-zero, with a diagnosis, unless there is exactly one. +lint_image_ref() { + file="$1" + + if [ ! -f "$file" ]; then + echo "verify-lint-image-pin: $file: not found" >&2 + return 1 + fi + + refs="$( + awk ' + toupper($1) == "FROM" { + for (i = 2; i <= NF; i++) { + if ($i ~ /^golangci\/golangci-lint[:@]/) { + print $i + } + } + } + ' "$file" + )" + + count="$(printf '%s' "$refs" | grep -c . || true)" + if [ "$count" -ne 1 ]; then + echo "verify-lint-image-pin: $file: expected exactly one" \ + "golangci/golangci-lint FROM reference, found $count" >&2 + return 1 + fi + + printf '%s\n' "$refs" +} + +main() { + cd "$ROOT" + + lint_ref="$(lint_image_ref "$LINT_DOCKERFILE")" + main_ref="$(lint_image_ref "$MAIN_DOCKERFILE")" + + if [ "$lint_ref" != "$main_ref" ]; then + echo "verify-lint-image-pin: the linter image is pinned twice and" \ + "the two pins disagree:" >&2 + echo "verify-lint-image-pin: $LINT_DOCKERFILE: $lint_ref" >&2 + echo "verify-lint-image-pin: $MAIN_DOCKERFILE: $main_ref" >&2 + echo "verify-lint-image-pin: bump both FROM lines together, tag and" \ + "digest, so script/lint and the Dockerfile lint stage keep" \ + "running the same linter" >&2 + exit 1 + fi + + echo "verify-lint-image-pin: $LINT_DOCKERFILE and $MAIN_DOCKERFILE" \ + "agree on $lint_ref" +} + +main "$@" diff --git a/script/verify-linter-pin b/script/verify-linter-pin deleted file mode 100755 index 774b92b..0000000 --- a/script/verify-linter-pin +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/sh -# script/verify-linter-pin: fail unless a golangci-lint binary is exactly -# the version script/bootstrap pins. Takes the binary to check as its -# argument, defaulting to whatever PATH resolves. Our own extension to -# scripts-to-rule-them-all, not one of its entrypoints. -# -# The Dockerfile build stage runs this on the linter it copies out of the -# lint stage, before anything else runs there. Without it, drift between -# the two stages is silently absorbed: script/bootstrap reinstalls its -# pinned version from source, verifies that, and the build goes green -# with the lint stage having linted at one version and `make check` -# having run at another. Bumping the lint stage image alone is enough to -# produce that, and this is the check that turns it into a build failure -# naming both versions. -# -# The pin is read out of script/bootstrap rather than restated here. -# script/bootstrap is the single source of truth for the linter version, -# and a second hardcoded copy of it is exactly the drift this script -# exists to catch. A pin that cannot be read is therefore a hard failure -# and not a skip: silently comparing against an empty string would turn -# this check into the kind of unearned green it was written to stop. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -# Seconds to allow `golangci-lint --version` to run, so a wedged binary -# stops the build instead of hanging it. Bounded by timeout(1) where that -# exists; stock macOS has none, and there the call runs unbounded. -VERSION_TIMEOUT="30" - -version_output() { - if command -v timeout >/dev/null 2>&1; then - timeout "$VERSION_TIMEOUT" "$1" --version - else - "$1" --version - fi -} - -main() { - # Resolve the argument before changing directory, so a relative path - # means what the caller meant by it. - bin="${1:-golangci-lint}" - resolved="$(command -v "$bin" 2>/dev/null || true)" - - cd "$ROOT" - - pin="$( - sed -n 's/^GOLANGCI_LINT_VERSION="\([^"]*\)".*/\1/p' script/bootstrap - )" - if [ -z "$pin" ]; then - echo "verify-linter-pin: no GOLANGCI_LINT_VERSION assignment found" \ - "in script/bootstrap; that file is the single source of truth" \ - "for the linter version and this check cannot run without it" >&2 - exit 1 - fi - - if [ -z "$resolved" ]; then - echo "verify-linter-pin: $bin: not found (pin is $pin)" >&2 - exit 1 - fi - - # Same output shape script/bootstrap parses: - # golangci-lint has version X.Y.Z built with go1.26.5 from abc1234 - # so the version is the field after the literal word "version", with - # any leading "v" stripped. stderr is left connected so a binary that - # cannot execute (wrong architecture, missing shared library) says why - # rather than being reported as merely unparseable. - if ! out="$(version_output "$resolved")"; then - echo "verify-linter-pin: $resolved --version failed; the binary" \ - "cannot be executed or timed out (pin is $pin)" >&2 - exit 1 - fi - found="$( - echo "$out" | awk ' - { - for (i = 1; i < NF; i++) { - if ($i == "version") { - v = $(i + 1) - sub(/^v/, "", v) - print v - exit - } - } - } - ' - )" - - if [ "$found" != "$pin" ]; then - echo "verify-linter-pin: $resolved reports" \ - "${found:-no parseable version}, but script/bootstrap pins" \ - "$pin" >&2 - echo "verify-linter-pin: these must be the same version — bump the" \ - "Dockerfile lint stage image and GOLANGCI_LINT_VERSION in" \ - "script/bootstrap together" >&2 - exit 1 - fi - - echo "verify-linter-pin: $resolved is $found, matching the" \ - "script/bootstrap pin" -} - -main "$@"