Run all linting in Docker via Dockerfile.lint (closes #46)
All checks were successful
check / check (push) Successful in 1m23s
All checks were successful
check / check (push) Successful in 1m23s
Per the owner ruling, the linter runs inside a container invoked through the script/ entrypoint and is never installed on a host. A 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, which also works when the docker daemon is remote and bind mounts are impossible. script/bootstrap loses the `go install`, the pin constants, the version parser and verify_golangci_lint outright rather than being hardened: with nothing linting on the host, the $GOPATH/bin versus PATH shadowing problem those existed to diagnose has no subject. It keeps the git/make/go presence checks and `go mod download`, and warns rather than fails when docker is absent. Two traps. A lint build on an unchanged tree returns success in well under a second having run no linter, which is #32 and #39 over again. Caching is waived by ruling, so Dockerfile.lint carries ARG CHECK_EPOCH referenced inside every gate RUN -- BuildKit hashes the expanded command, not the declaration, so a declared but unreferenced ARG invalidates nothing -- and script/lint passes "$(date +%s)-$$". The PID is in that value because two lint runs land inside the same second easily and a bare epoch would cache the second one. Nothing inside an image build may shell out to docker. The main Dockerfile's lint stage therefore 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, which reaches script/lint. Those two remain `make` invocations rather than the bare scripts because the Makefile's `export CGO_ENABLED = 0` only applies to what it invokes, and today's `make check` gets it. 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; dropping it without replacing the edge would have ended fail-fast linting silently under a still-green build. That no-op copy is the ordering edge canonical REPO_POLICIES.md prescribes. Nothing in the build stage runs the linter any more, so the binary itself is not wanted there, and ENV PATH=/home/builder/go/bin:$PATH goes 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 this 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 #42 made a build failure. Its replacement is one new script/verify-lint-image-pin that compares those two references to each other and deliberately restates neither pin: a hardcoded expected digest would be a third copy and the same drift one file further out. It runs as a gate in both files, so `make lint`, `make check` and `make docker` all catch drift, and an unreadable reference is a hard failure rather than a vacuous pass. `golangci-lint config verify` is included per the ruling. 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 against a schema it embeds and linting needs no network beyond pulling the image. The README states that rather than a requirement that does not exist. Verified. `make lint` green with every PATH directory containing a golangci-lint removed and `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. A planted unused variable failed script/lint with that exact finding and failed `make docker` at the lint stage with the build stage stopped before its COPY --from=lint, then reverted clean. The drift guard fails on tag-only, digest-only and unreadable-reference cases, naming both sides. `make check` green; `make docker` green in 5m35s with all six gates executing and the test gate reporting real coverage rather than a cached ok. In the builder image with the Go test cache off, --user 0:0 still fails TestScanHardlinkRunFailsTogether where the unprivileged user passes, so the non-root quirk is intact.
This commit is contained in:
100
Dockerfile
100
Dockerfile
@@ -22,72 +22,64 @@ COPY . .
|
|||||||
# (the pinned base image, go mod download) keeps its cache; only the
|
# (the pinned base image, go mod download) keeps its cache; only the
|
||||||
# gates go cold.
|
# gates go cold.
|
||||||
ARG CHECK_EPOCH
|
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 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
|
# Build stage
|
||||||
# golang:1.25-alpine, 2026-07-23
|
# golang:1.25-alpine, 2026-07-23
|
||||||
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
||||||
|
|
||||||
# We never build or run as root. Create an unprivileged user and point
|
# 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
|
# HOME and the Go caches at its home so go build and go test can write
|
||||||
# can write their caches when we drop to it below. $GOPATH/bin is on
|
# their caches when we drop to it below. $GOPATH/bin is deliberately not
|
||||||
# PATH because that is where script/bootstrap's `go install` lands: a
|
# on PATH: script/bootstrap no longer `go install`s anything (the linter
|
||||||
# tool bootstrap installs must be runnable afterwards, and bootstrap
|
# runs from a pinned image, never from a host install), so nothing lands
|
||||||
# verifies its own installs against what PATH resolves, so leaving that
|
# there and adding it would only widen what this image resolves.
|
||||||
# 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.
|
|
||||||
RUN adduser -D -u 1000 builder
|
RUN adduser -D -u 1000 builder
|
||||||
ENV HOME=/home/builder
|
ENV HOME=/home/builder
|
||||||
ENV GOPATH=/home/builder/go
|
ENV GOPATH=/home/builder/go
|
||||||
ENV GOCACHE=/home/builder/.cache/go-build
|
ENV GOCACHE=/home/builder/.cache/go-build
|
||||||
ENV PATH=/home/builder/go/bin:$PATH
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Reuse the linter binary from the lint stage. This copy is load-bearing
|
# No-op file copy whose only purpose is the build-graph edge: it is what
|
||||||
# twice over and must not be deleted as redundant now that bootstrap
|
# makes this stage depend on the lint stage, and so what forces BuildKit
|
||||||
# below can install a linter of its own:
|
# to finish fmt-check, the pin guard and lint before compilation and
|
||||||
#
|
# tests start. Remove it and the fail-fast design dies silently — the
|
||||||
# - It is the only thing making this stage depend on the lint stage,
|
# build stops gating on lint and still exits 0. It replaces a copy of
|
||||||
# so it is what forces BuildKit to finish fmt-check and lint before
|
# the linter binary itself, which is no longer wanted here: nothing in
|
||||||
# compilation and tests start. Remove it and the fail-fast design
|
# this stage runs the linter, because `make lint` is now a docker build
|
||||||
# dies silently: the build stops gating on lint and still exits 0.
|
# and a docker build cannot run inside one.
|
||||||
# - Together with the check below it is what keeps the two stages on
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
# 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
|
|
||||||
|
|
||||||
# Install development prerequisites the same way a developer does,
|
# Install development prerequisites the same way a developer does,
|
||||||
# rather than duplicating the installs inline. Only script/ (copied
|
# rather than duplicating the installs inline. Only script/ and the
|
||||||
# above) and the dependency manifests are copied first, nothing else, so
|
# dependency manifests are copied first, nothing else, so this layer
|
||||||
# this layer stays cached until the scripts or the dependencies change —
|
# stays cached until the scripts or the dependencies change — bootstrap
|
||||||
# bootstrap ends in `go mod download`, which is why there is no separate
|
# ends in `go mod download`, which is why there is no separate
|
||||||
# invocation of it here.
|
# invocation of it here.
|
||||||
|
COPY script/ script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
|
|
||||||
@@ -102,11 +94,19 @@ USER builder
|
|||||||
# permission-denied test paths are exercised legitimately (root would
|
# permission-denied test paths are exercised legitimately (root would
|
||||||
# bypass the chmod(0) the tests rely on).
|
# 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
|
# 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
|
# 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.
|
# drop to the unprivileged user still happens before the checks run.
|
||||||
ARG CHECK_EPOCH
|
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
|
RUN make build
|
||||||
|
|
||||||
|
|||||||
59
Dockerfile.lint
Normal file
59
Dockerfile.lint
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 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. That holds for the gate
|
||||||
|
# steps generally — none of them makes a network call — but not for
|
||||||
|
# this build as a whole: `go mod download` above needs the network on a
|
||||||
|
# cold cache, and under `--network none` a first build fails there
|
||||||
|
# before reaching any gate. That layer stays cached, so only a warm
|
||||||
|
# cache lints offline, until go.mod or go.sum changes.
|
||||||
|
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 ./...
|
||||||
97
README.md
97
README.md
@@ -454,16 +454,12 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
develop this repository, idempotently, assuming nothing is
|
develop this repository, idempotently, assuming nothing is
|
||||||
present. `git`, `make`, and `go` come from the first of nix, apt,
|
present. `git`, `make`, and `go` come from the first of nix, apt,
|
||||||
brew, or apk found on the host, and are presence-checked only.
|
brew, or apk found on the host, and are presence-checked only.
|
||||||
`golangci-lint` is treated differently: it is checked against the
|
`golangci-lint` is deliberately **not** installed: it runs from a
|
||||||
version pinned in the script (the version the `Dockerfile` lint
|
digest-pinned image via `script/lint` and never from a host
|
||||||
stage runs) and reinstalled with `go install` whenever the
|
install, so there is no host copy to drift from the pin. A missing
|
||||||
installed version differs — older or newer, not merely absent —
|
`docker` is warned about rather than installed or treated as
|
||||||
because a host on any other version lints against different rules
|
fatal — everything except linting works without it. Ends with
|
||||||
than CI. After installing, the script verifies the pin against the
|
`go mod download`.
|
||||||
`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`.
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
- `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`.
|
`script/bootstrap`, then `script/install-precommit`.
|
||||||
- `script/projectname` — print this project's name (`sfdupes`).
|
- `script/projectname` — print this project's name (`sfdupes`).
|
||||||
@@ -472,14 +468,35 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
- `script/test` — run the test suite with a 30-second timeout and
|
- `script/test` — run the test suite with a 30-second timeout and
|
||||||
coverage enabled, rerunning verbosely on failure so the logs show
|
coverage enabled, rerunning verbosely on failure so the logs show
|
||||||
which test failed.
|
which test failed.
|
||||||
- `script/lint` — run `golangci-lint` over the module with the
|
- `script/lint` — run the linter. It builds `Dockerfile.lint`, which
|
||||||
repository's `.golangci.yml`.
|
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. Offline machines: the gate steps themselves make
|
||||||
|
no network calls. `golangci-lint run` does not, and neither does
|
||||||
|
`golangci-lint config verify` — it validates against a schema the
|
||||||
|
pinned binary embeds, measured under `--network none` to both
|
||||||
|
pass a valid config and reject an invalid one. The build around
|
||||||
|
them does. `Dockerfile.lint` runs `go mod download` before the
|
||||||
|
gates and this module has external dependencies, so a first lint
|
||||||
|
on a machine with a cold BuildKit cache reaches the network there
|
||||||
|
(as well as pulling the pinned image); under `--network none` it
|
||||||
|
fails at that step, before any gate. That layer sits above the
|
||||||
|
gates and stays cached, so once it is warm `script/lint` — and
|
||||||
|
with it `make check` — runs entirely offline, until `go.mod` or
|
||||||
|
`go.sum` changes and the download layer goes cold again. 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`).
|
- `script/fmt` — format the Go sources in place (`gofmt -s -w`).
|
||||||
Markdown is not formatted.
|
Markdown is not formatted.
|
||||||
- `script/fmt-check` — the read-only counterpart of `script/fmt`:
|
- `script/fmt-check` — the read-only counterpart of `script/fmt`:
|
||||||
prints any unformatted file and exits non-zero instead of writing.
|
prints any unformatted file and exits non-zero instead of writing.
|
||||||
- `script/check` — run `script/test`, `script/lint`, and
|
- `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
|
- `script/docker` — build the Docker image, tagged with the name
|
||||||
from `script/projectname`. The `Dockerfile` runs the gates as
|
from `script/projectname`. The `Dockerfile` runs the gates as
|
||||||
build steps, so this is also the check a developer or reviewer
|
build steps, so this is also the check a developer or reviewer
|
||||||
@@ -493,24 +510,36 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
runs `script/precommit`. The hook is written to the common git
|
runs `script/precommit`. The hook is written to the common git
|
||||||
directory, so the main checkout and every worktree share it.
|
directory, so the main checkout and every worktree share it.
|
||||||
- `script/verify-linter-pin` — fail unless a `golangci-lint` binary
|
- `script/verify-lint-image-pin` — fail unless the
|
||||||
(given as its argument, default whatever `PATH` resolves) is
|
`golangci/golangci-lint` reference in `Dockerfile.lint` and the
|
||||||
exactly the version `script/bootstrap` pins, naming both versions
|
one in the `Dockerfile` lint stage are the same image at the same
|
||||||
if not. The `Dockerfile` build stage runs it on the linter it
|
digest, naming both if not. The linter is pinned in those two
|
||||||
copies out of the lint stage: the version is pinned independently
|
files and nothing else keeps them in sync, so a bump applied to
|
||||||
in the lint stage's image digest and in `script/bootstrap`, and
|
one alone would leave `make lint` and the `Dockerfile`'s
|
||||||
bumping one alone would otherwise be absorbed silently by
|
fail-fast lint stage checking the same tree against different
|
||||||
bootstrap rebuilding its pin from source, leaving the two stages
|
rulesets, both green. The guard restates neither pin — a third
|
||||||
on different linters under a green build. The pin is read from
|
copy would be the same drift one file further out — and runs as a
|
||||||
`script/bootstrap`, which stays its single source of truth.
|
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
|
`script/verify-linter-pin` used to live here. It compared a linter
|
||||||
`CHECK_EPOCH` build argument, and the `Dockerfile`'s gate steps
|
binary against a version pin in `script/bootstrap`, and both of its
|
||||||
reference it. Without that, an unchanged tree lets Docker serve the
|
subjects are gone: no linter binary is copied between build stages any
|
||||||
gate layers from cache and the build exits 0 having executed no tests
|
more, and bootstrap pins no version because it installs no linter. The
|
||||||
and no lint — a green it never earned. `CHECK_EPOCH` invalidates the
|
drift it existed to catch has moved from binary-versus-pin to
|
||||||
gate layers on every run while leaving the pinned base images and the
|
pin-versus-pin, which is what `script/verify-lint-image-pin` above
|
||||||
dependency layers cached.
|
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
|
## Build
|
||||||
|
|
||||||
@@ -526,12 +555,14 @@ carries the compile recipe:
|
|||||||
pre-commit hook.
|
pre-commit hook.
|
||||||
- `make test` — run the test suite (30-second timeout; reruns with
|
- `make test` — run the test suite (30-second timeout; reruns with
|
||||||
`-v` on failure).
|
`-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
|
- `make fmt` / `make fmt-check` — format Go sources / verify
|
||||||
formatting without writing.
|
formatting without writing.
|
||||||
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing.
|
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing.
|
||||||
- `make docker` — build the Docker image, which runs `make check` as
|
Requires `docker`, via `lint`.
|
||||||
a build stage.
|
- `make docker` — build the Docker image, which runs the gates as
|
||||||
|
build stages.
|
||||||
- `make hooks` — install the pre-commit hook.
|
- `make hooks` — install the pre-commit hook.
|
||||||
- `make clean` — remove the binary.
|
- `make clean` — remove the binary.
|
||||||
|
|
||||||
|
|||||||
82
TODO.md
82
TODO.md
@@ -29,6 +29,88 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# 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 makes no network
|
||||||
|
call of its own. The README scopes that to the gate steps rather
|
||||||
|
than to linting as a whole: `Dockerfile.lint` runs `go mod download`
|
||||||
|
above them, so a cold cache still needs the network and only a warm
|
||||||
|
one lints offline. 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
|
- install the Docker build stage's prerequisites by running
|
||||||
`script/bootstrap` instead of `apk add --no-cache make` inline
|
`script/bootstrap` instead of `apk add --no-cache make` inline
|
||||||
(2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical
|
(2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical
|
||||||
|
|||||||
116
script/bootstrap
116
script/bootstrap
@@ -2,31 +2,15 @@
|
|||||||
# script/bootstrap: install all dependencies needed to build and develop
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes nothing is present.
|
# or apk (detected in that order); assumes nothing is present (not git,
|
||||||
# golangci-lint is installed via `go install` pinned to the same version
|
# make, or go). The linter is NOT installed: golangci-lint runs via
|
||||||
# the Dockerfile lint stage uses (never "latest"), and is reinstalled
|
# docker only (script/lint), pinned by image digest, so the only lint
|
||||||
# whenever the installed version differs from that pin. The install is
|
# prerequisite is a working docker — which is warned about, not
|
||||||
# then verified against the binary PATH actually resolves: if the pin is
|
# installed, because everything except linting works without it.
|
||||||
# still not what would run, bootstrap fails instead of reporting
|
|
||||||
# success.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
APT_UPDATED=""
|
APT_UPDATED=""
|
||||||
@@ -74,73 +58,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! 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() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -154,20 +71,15 @@ main() {
|
|||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Lint tooling, pinned via go install (installs into
|
# Linting runs via docker only (script/lint), so docker is a lint
|
||||||
# "$(go env GOPATH)/bin"; ensure that is on your PATH). Unlike the
|
# prerequisite rather than something bootstrap installs. Warn, do
|
||||||
# system tools above this is version-checked, not presence-checked:
|
# not fail: everything except `make lint` — and, through it,
|
||||||
# the Dockerfile lint stage runs a digest-pinned linter, so a host
|
# `make check`, `make docker` and the pre-commit hook — works
|
||||||
# running any other version lints against different rules and
|
# without it.
|
||||||
# `make check` can go green on a commit CI then rejects. Any version
|
if missing docker; then
|
||||||
# that is not the pin — older or newer — is reinstalled, and the
|
echo "bootstrap: WARNING: docker not found; make lint, make check" >&2
|
||||||
# install is then verified to be the binary PATH resolves.
|
echo "bootstrap: and make docker require it. Install docker to" >&2
|
||||||
installed="$(golangci_lint_version)"
|
echo "bootstrap: run the linter." >&2
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs make fmt-check,
|
# script/cibuild: run the CI build. The Gitea workflow runs this on
|
||||||
# make lint and make check (via the script/ entrypoints) as build steps,
|
# push.
|
||||||
# so a successful build implies all checks pass. The Gitea workflow runs
|
#
|
||||||
# this on push.
|
# The Dockerfile runs the gates individually as build steps, not the
|
||||||
|
# make check aggregate: the lint stage runs make fmt-check,
|
||||||
|
# script/verify-lint-image-pin, golangci-lint config verify and
|
||||||
|
# golangci-lint run; the build stage, dropped to an unprivileged user,
|
||||||
|
# runs make test and make fmt-check. Neither make lint nor make check
|
||||||
|
# appears, because both reach script/lint, which is itself a docker
|
||||||
|
# build, and a docker build cannot run inside one. Lint is not skipped
|
||||||
|
# by that — the linter is invoked directly in the lint stage, and the
|
||||||
|
# build stage's COPY --from=lint makes that stage a prerequisite, so
|
||||||
|
# BuildKit must finish it first. Between the two stages everything
|
||||||
|
# make check would run has run, which is why a successful build here
|
||||||
|
# implies the repo is green.
|
||||||
#
|
#
|
||||||
# That implication holds only because of CHECK_EPOCH. A COPY layer is
|
# That implication holds only because of CHECK_EPOCH. A COPY layer is
|
||||||
# invalidated by changed content, and a merge commit's tree is
|
# invalidated by changed content, and a merge commit's tree is
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
#
|
#
|
||||||
# CHECK_EPOCH is passed for the same reason script/cibuild passes it:
|
# CHECK_EPOCH is passed for the same reason script/cibuild passes it:
|
||||||
# without it Docker serves the Dockerfile's gate layers from cache on an
|
# without it Docker serves the Dockerfile's gate layers from cache on an
|
||||||
# unchanged tree and this exits 0 having run neither the lint stage nor
|
# unchanged tree and this exits 0 having run neither the lint stage's
|
||||||
# the builder stage's make check. This is the gate a developer or
|
# gates nor the builder stage's test and fmt-check gates. This is the
|
||||||
# reviewer runs by hand, so a cached pass here is the most misleading
|
# set of gates a developer or reviewer runs by hand, so a cached pass
|
||||||
# result the repo can produce. Dependency layers sit above the ARG and
|
# here is the most misleading result the repo can produce. Dependency
|
||||||
# stay cached.
|
# layers sit above the ARG and stay cached.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
21
script/lint
21
script/lint
@@ -1,12 +1,29 @@
|
|||||||
#!/bin/sh
|
#!/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. The gate
|
||||||
|
# steps make no network calls of their own, but Dockerfile.lint runs
|
||||||
|
# `go mod download` above them, so a cold cache does reach the network
|
||||||
|
# (as does pulling the pinned image); that layer stays cached, and once
|
||||||
|
# it is warm this runs offline until go.mod or go.sum changes.
|
||||||
|
#
|
||||||
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
golangci-lint run --config .golangci.yml ./...
|
docker build \
|
||||||
|
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
||||||
|
-f Dockerfile.lint \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
84
script/verify-lint-image-pin
Executable file
84
script/verify-lint-image-pin
Executable file
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
|
||||||
Reference in New Issue
Block a user