Run all linting in Docker via Dockerfile.lint (closes #46)
All checks were successful
check / check (push) Successful in 1m38s
All checks were successful
check / check (push) Successful in 1m38s
Per the owner ruling the linter runs in a container invoked through the script/ entrypoint, never installed on a host. 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, and works with a remote docker daemon, where bind mounts are impossible. script/bootstrap loses the `go install`, the pin constants, the version parser and verify_golangci_lint: with nothing linting on the host, the $GOPATH/bin versus PATH shadowing they diagnosed has no subject. It keeps the git/make/go presence checks and `go mod download`, and warns rather than fails when docker is absent. Traps for anyone changing this. A lint build on an unchanged tree exits 0 in under a second having run no linter -- #32 and #39 again. Caching is waived by ruling: Dockerfile.lint carries ARG CHECK_EPOCH referenced inside every gate RUN, because BuildKit hashes the expanded command and a declared but unreferenced ARG invalidates nothing. script/lint passes "$(date +%s)-$$"; the PID is there because two runs land in the same second easily and a bare epoch would cache the second. Nothing inside an image build may shell out to docker. The main Dockerfile's lint stage therefore invokes golangci-lint directly rather than `make lint`, and its build stage runs `make test` and `make fmt-check` rather than the `make check` aggregate, which reaches script/lint. Both stay `make` invocations rather than bare scripts because the Makefile's `export CGO_ENABLED = 0` only reaches what it invokes. COPY --from=lint /usr/bin/golangci-lint becomes 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 now, so ENV PATH=/home/builder/go/bin:$PATH goes with the `go install` that justified it. script/verify-linter-pin is retired with its README entry: it compared a linter binary against GOLANGCI_LINT_VERSION in script/bootstrap and neither subject still exists. The drift moved rather than went away -- the linter is pinned twice, as the FROM line of Dockerfile.lint and the FROM line of the Dockerfile lint stage, which is what #42 made a build failure. script/verify-lint-image-pin compares those two references to each other and restates neither pin; a hardcoded digest would be a third copy and the same drift one file further out. It runs as a gate in both files, and an unreadable reference is a hard failure rather than a vacuous pass. `golangci-lint config verify` is included per the ruling, and its unpinned live HTTPS schema fetch was measured rather than assumed: under --network none the pinned binary passes a valid config and rejects an invalid one with the jsonschema error, so it validates against a schema it embeds. That holds for the gate steps, none of which makes a network call, but not for the build around them -- Dockerfile.lint runs `go mod download` above the gates, so a cold cache needs the network and only a warm one lints offline, until go.mod or go.sum changes. 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 under distinct epochs with the COPY layer CACHED above them. A planted unused variable failed script/lint with that finding, and failed `make docker` at the lint stage with the build stage stopped before its COPY --from=lint; 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:
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