Compare commits
1 Commits
next
...
73ea8a536f
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ea8a536f |
100
Dockerfile
100
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
|
||||
|
||||
|
||||
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
|
||||
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,35 @@ 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. 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`).
|
||||
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 +510,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 +555,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.
|
||||
|
||||
|
||||
82
TODO.md
82
TODO.md
@@ -29,6 +29,88 @@
|
||||
|
||||
# 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
|
||||
`script/bootstrap` instead of `apk add --no-cache make` inline
|
||||
(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
|
||||
# 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
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Dockerfile runs make fmt-check,
|
||||
# make lint and make check (via the script/ entrypoints) as build steps,
|
||||
# so a successful build implies all checks pass. The Gitea workflow runs
|
||||
# this on push.
|
||||
# script/cibuild: run the CI build. 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
|
||||
# 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:
|
||||
# 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
|
||||
# the builder stage's make check. This is the gate a developer or
|
||||
# reviewer runs by hand, so a cached pass here is the most misleading
|
||||
# result the repo can produce. Dependency layers sit above the ARG and
|
||||
# stay cached.
|
||||
# unchanged tree and this exits 0 having run neither the lint stage's
|
||||
# gates nor the builder stage's test and fmt-check gates. This is the
|
||||
# set of gates a developer or reviewer runs by hand, so a cached pass
|
||||
# here is the most misleading result the repo can produce. Dependency
|
||||
# layers sit above the ARG and stay cached.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
21
script/lint
21
script/lint
@@ -1,12 +1,29 @@
|
||||
#!/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
|
||||
|
||||
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 "$@"
|
||||
|
||||
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