Run all linting in Docker via Dockerfile.lint (closes #46)
All checks were successful
check / check (push) Successful in 1m5s
All checks were successful
check / check (push) Successful in 1m5s
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
|
||||
# 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user