Run all linting in Docker via Dockerfile.lint (closes #46)
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:
2026-08-10 12:53:03 +00:00
parent e6a91711b0
commit 73ea8a536f
10 changed files with 392 additions and 298 deletions

View File

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

View File

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

View File

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

View File

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

View File

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