Compare commits

..

2 Commits

Author SHA1 Message Date
d43c1d31ac Merge branch 'next': run all linting in Docker (closes #46)
All checks were successful
check / check (push) Successful in 1m19s
2026-08-10 15:55:42 +02:00
d4eaf5fed2 Run all linting in Docker via Dockerfile.lint (closes #46)
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.
2026-08-10 13:47:31 +00:00
6 changed files with 51 additions and 23 deletions

View File

@@ -46,8 +46,12 @@ RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
# unpinned HTTPS call; measured on the pinned image, it does not. The # unpinned HTTPS call; measured on the pinned image, it does not. The
# binary carries the schema for its own version, so under # binary carries the schema for its own version, so under
# `--network none` this both passes on a valid config and still rejects # `--network none` this both passes on a valid config and still rejects
# an invalid one with the jsonschema error. Linting therefore needs no # an invalid one with the jsonschema error. That holds for the gate
# network beyond pulling the pinned image. # 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}" && \ RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \
golangci-lint config verify --config .golangci.yml golangci-lint config verify --config .golangci.yml

View File

@@ -475,13 +475,21 @@ and may be invoked directly. The provided entrypoints are:
steps, so a successful build is a clean lint. The linter is never steps, so a successful build is a clean lint. The linter is never
run on the host, which makes a working `docker` the one run on the host, which makes a working `docker` the one
prerequisite for linting — and therefore for `make check` and the prerequisite for linting — and therefore for `make check` and the
pre-commit hook. Once the pinned image is local, nothing here pre-commit hook. Offline machines: the gate steps themselves make
needs the network: the config-schema validation used by no network calls. `golangci-lint run` does not, and neither does
`config verify` comes from a schema the pinned binary embeds, and `golangci-lint config verify` — it validates against a schema the
was measured under `--network none` to both pass a valid config pinned binary embeds, measured under `--network none` to both
and reject an invalid one. Because the daemon only ever sees a pass a valid config and reject an invalid one. The build around
build context, this works when the docker daemon is remote and them does. `Dockerfile.lint` runs `go mod download` before the
bind mounts are impossible. gates and this module has external dependencies, so a first lint
on a machine with a cold BuildKit cache reaches the network there
(as well as pulling the pinned image); under `--network none` it
fails at that step, before any gate. That layer sits above the
gates and stays cached, so once it is warm `script/lint` — and
with it `make check` — runs entirely offline, until `go.mod` or
`go.sum` changes and the download layer goes cold again. Because
the daemon only ever sees a build context, this works when the
docker daemon is remote and bind mounts are impossible.
- `script/fmt` — format the Go sources in place (`gofmt -s -w`). - `script/fmt` — format the Go sources in place (`gofmt -s -w`).
Markdown is not formatted. Markdown is not formatted.
- `script/fmt-check` — the read-only counterpart of `script/fmt`: - `script/fmt-check` — the read-only counterpart of `script/fmt`:

View File

@@ -82,9 +82,11 @@
concern about its unpinned live HTTPS schema fetch was measured concern about its unpinned live HTTPS schema fetch was measured
rather than assumed — under `--network none` the pinned binary both rather than assumed — under `--network none` the pinned binary both
passes a valid config and rejects an invalid one with the jsonschema passes a valid config and rejects an invalid one with the jsonschema
error, so it validates from an embedded schema and linting needs no error, so it validates from an embedded schema and makes no network
network; the README says so instead of claiming a requirement that call of its own. The README scopes that to the gate steps rather
does not exist. Verified: `make lint` green with every `PATH` 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 directory containing a `golangci-lint` removed
(`/home/user/go/bin`, `/home/user/.local/bin`, `/usr/local/bin`; (`/home/user/go/bin`, `/home/user/.local/bin`, `/usr/local/bin`;
`command -v golangci-lint` empty); two consecutive `script/lint` runs `command -v golangci-lint` empty); two consecutive `script/lint` runs

View File

@@ -1,8 +1,19 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make fmt-check, # script/cibuild: run the CI build. The Gitea workflow runs this on
# make lint and make check (via the script/ entrypoints) as build steps, # push.
# so a successful build implies all checks pass. The Gitea workflow runs #
# this on push. # The Dockerfile runs the gates individually as build steps, not the
# make check aggregate: the lint stage runs make fmt-check,
# script/verify-lint-image-pin, golangci-lint config verify and
# golangci-lint run; the build stage, dropped to an unprivileged user,
# runs make test and make fmt-check. Neither make lint nor make check
# appears, because both reach script/lint, which is itself a docker
# build, and a docker build cannot run inside one. Lint is not skipped
# by that — the linter is invoked directly in the lint stage, and the
# build stage's COPY --from=lint makes that stage a prerequisite, so
# BuildKit must finish it first. Between the two stages everything
# make check would run has run, which is why a successful build here
# implies the repo is green.
# #
# That implication holds only because of CHECK_EPOCH. A COPY layer is # That implication holds only because of CHECK_EPOCH. A COPY layer is
# invalidated by changed content, and a merge commit's tree is # invalidated by changed content, and a merge commit's tree is

View File

@@ -4,11 +4,11 @@
# #
# CHECK_EPOCH is passed for the same reason script/cibuild passes it: # CHECK_EPOCH is passed for the same reason script/cibuild passes it:
# without it Docker serves the Dockerfile's gate layers from cache on an # without it Docker serves the Dockerfile's gate layers from cache on an
# unchanged tree and this exits 0 having run neither the lint stage nor # unchanged tree and this exits 0 having run neither the lint stage's
# the builder stage's make check. This is the gate a developer or # gates nor the builder stage's test and fmt-check gates. This is the
# reviewer runs by hand, so a cached pass here is the most misleading # set of gates a developer or reviewer runs by hand, so a cached pass
# result the repo can produce. Dependency layers sit above the ARG and # here is the most misleading result the repo can produce. Dependency
# stay cached. # layers sit above the ARG and stay cached.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -3,8 +3,11 @@
# host: it runs via docker only, one way, everywhere — this builds # host: it runs via docker only, one way, everywhere — this builds
# Dockerfile.lint, which COPYs the repo into the digest-pinned # Dockerfile.lint, which COPYs the repo into the digest-pinned
# golangci-lint image and lints as a build step, so a successful build # golangci-lint image and lints as a build step, so a successful build
# is a clean lint. The only prerequisite is a working docker; once the # is a clean lint. The only prerequisite is a working docker. The gate
# pinned image is present nothing here reaches the network. # 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 # 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 # serves the gate layers from cache on an unchanged tree and this exits