script/cibuild and script/docker were bare docker build invocations
with no cache control, and the Dockerfile copies the tree before
running its gates. On an unchanged tree Docker served those layers
from cache, so the gates never executed and the build still exited 0.
A merge commit here has a tree byte-identical to the branch head it
merges, so every merge CI run was almost certainly a full cache hit,
and PR #31's reviewer caught make docker returning success as a
17-layer cache hit that proved nothing.
Declare ARG CHECK_EPOCH in both stages and have the scripts pass
--build-arg CHECK_EPOCH="$(date +%s)". ARG is scoped per stage and
this Dockerfile has three gates across two of them (make fmt-check and
make lint in the lint stage, make check in the build stage), so one
declaration would have left a stage silently cacheable. BuildKit
hashes the expanded command rather than the declaration, so each gate
RUN echoes the epoch: an unreferenced ARG invalidates nothing, and the
echo doubles as evidence in the build log that the layer really ran.
Both declarations sit below the dependency layers, so the pinned base
images, go mod download, apk add and the source copies keep their
cache and only the gates go cold. The build-stage declaration sits
after USER, so the drop to the unprivileged builder user still happens
before make check and the chmod(0) permission tests stay real.
`go install` writes into GOBIN (or GOPATH/bin), but the linter `make
lint` runs is whichever golangci-lint PATH resolves first. On a host
where a wrong-version binary sits ahead of that directory — a nix
profile, apt, brew, apk, a tarball in /usr/local/bin, or the
/usr/local/bin copy the Dockerfile builder stage makes — the install
landed behind the shadow, changed nothing the gate uses, and bootstrap
still printed "bootstrap complete" and exited 0. That leaves the local
gate linting against a different ruleset than CI while affirmatively
claiming otherwise, and every subsequent run reinstalls forever, so the
second run is never a no-op.
After installing, re-read the effective version. On a mismatch print
the resolved binary, the install directory and both versions to stderr
and exit non-zero. Do not reorder PATH or remove anyone's binary:
diagnose and stop.
Also:
- stop discarding `golangci-lint --version` stderr, so a present but
broken binary (missing shared library, wrong architecture) says why
instead of silently yielding the empty string and reinstalling on
every run forever. Only stdout is parsed, so the parse matrix is
unchanged.
- bound the `--version` call with timeout(1) where it exists, since
bootstrap now executes a binary it previously only located and a
wedged one would otherwise hang the script. Hosts without timeout(1)
run it unbounded, as before.
- use X.Y.Z in the parsing comment so the pinned version stays a single
literal in the script.
script/bootstrap installed the pinned linter only when the command was
absent, so on any host that already had some golangci-lint the pin was
never consulted and a version bump was inert forever. That is how a
host running v2.10.1 against a v2.12.2 pin got a green `make check`
while `make docker` rejected the same commit: the local gate was
linting with a different ruleset than CI, and the disagreement only
surfaced after a push.
The version is now a single value, GOLANGCI_LINT_VERSION, with the
`go install` module ref derived from it, so a future bump cannot
half-apply. A golangci_lint_version helper parses the installed
version out of `golangci-lint --version` (the field after the word
"version", with an optional leading "v" stripped, since the module ref
carries one and the binary's output does not) and yields the empty
string when the tool is absent or unreadable. Any version that is not
the pin -- older, newer, absent or unparseable -- is reinstalled, so a
first run upgrades and a second is a no-op.
git, make and go keep their presence-only checks: they come from the
host package manager, the repo pins no system toolchain versions, and
go.mod governs the language version. That is now stated in a comment
next to them rather than left ambiguous beside a tool that is
version-checked.
Bump the pinned golangci-lint from v2.12.1 to v2.12.2 in the
Dockerfile lint stage (tagged, digest-pinned Debian image) and in
script/bootstrap (go install ref). Replace .golangci.yml with the
canonical config: linter settings (lll, funlen, cyclop, dupl) move
under linters.settings per the v2 schema so they are actually
applied, and the redundant issues.exclude-use-default key is
dropped. No new lint findings surfaced; make check is green.
Bring the repo into conformance with the scripts-to-rule-them-all
(STRTA) scaffold. The real logic that lived inline in the Makefile now
lives in POSIX-sh entrypoints under script/, and the Makefile's standard
targets are thin @script/NAME shims.
- script/: bootstrap, setup, projectname, test, lint, fmt, fmt-check,
check, docker, precommit, install-precommit, cibuild. All are
executable #!/bin/sh entrypoints; the go mod tidy guard from the old
inline hooks recipe moved into script/precommit.
- Makefile: the nine standard targets (bootstrap, setup, test, lint,
fmt, fmt-check, check, docker, hooks) are now thin shims; the
repo-specific sfdupes/build/clean targets and the CGO_ENABLED export
are preserved.
- .gitea/workflows/check.yml: run script/cibuild instead of a bare
docker build.
- Dockerfile: run make check (and the build) as an unprivileged builder
user rather than root. We should never build or run as root, and doing
so also lets the permission-denied tests run legitimately: root
bypasses the chmod(0) that TestScanHardlinkRunFailsTogether relies on,
which made the in-image make check fail. HOME and the Go caches point
at the user's home so go build/test and golangci-lint can write.
make check passes locally and docker build . is green (the in-image
non-root make check passes, including the hardlink permission test).