Compare commits
8 Commits
a5fa600c98
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 337b319542 | |||
| d43c1d31ac | |||
| d4eaf5fed2 | |||
| e6a91711b0 | |||
|
|
5ca68804ac | ||
| 3a183aa64b | |||
| 47fd4e8def | |||
| 99d757c31d |
68
Dockerfile
68
Dockerfile
@@ -1,6 +1,6 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
# golangci/golangci-lint:v2.12.2, 2026-08-07
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -22,18 +22,40 @@ COPY . .
|
|||||||
# (the pinned base image, go mod download) keeps its cache; only the
|
# (the pinned base image, go mod download) keeps its cache; only the
|
||||||
# gates go cold.
|
# gates go cold.
|
||||||
ARG CHECK_EPOCH
|
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 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
|
# Build stage
|
||||||
# golang:1.25-alpine, 2026-07-23
|
# golang:1.25-alpine, 2026-07-23
|
||||||
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache make
|
|
||||||
|
|
||||||
# We never build or run as root. Create an unprivileged user and point
|
# 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
|
# HOME and the Go caches at its home so go build and go test can write
|
||||||
# can write their caches when we drop to it below.
|
# 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
|
RUN adduser -D -u 1000 builder
|
||||||
ENV HOME=/home/builder
|
ENV HOME=/home/builder
|
||||||
ENV GOPATH=/home/builder/go
|
ENV GOPATH=/home/builder/go
|
||||||
@@ -41,12 +63,26 @@ ENV GOCACHE=/home/builder/.cache/go-build
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Reuse the linter binary from the lint stage; the copy also forces
|
# No-op file copy whose only purpose is the build-graph edge: it is what
|
||||||
# BuildKit to complete linting before this stage proceeds.
|
# makes this stage depend on the lint stage, and so what forces BuildKit
|
||||||
COPY --from=lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
|
# 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/ 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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Hand the sources and caches to the unprivileged user, then drop root
|
# Hand the sources and caches to the unprivileged user, then drop root
|
||||||
@@ -58,11 +94,19 @@ USER builder
|
|||||||
# permission-denied test paths are exercised legitimately (root would
|
# permission-denied test paths are exercised legitimately (root would
|
||||||
# bypass the chmod(0) the tests rely on).
|
# 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
|
# 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
|
# 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.
|
# drop to the unprivileged user still happens before the checks run.
|
||||||
ARG CHECK_EPOCH
|
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
|
RUN make build
|
||||||
|
|
||||||
|
|||||||
59
Dockerfile.lint
Normal file
59
Dockerfile.lint
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Lint-only image: this is how the linter runs, everywhere. The repo is
|
||||||
|
# COPYed into the pinned golangci-lint image and the linter runs as a
|
||||||
|
# build step, so a successful build IS a clean lint. golangci-lint is
|
||||||
|
# never installed on a host — one toolchain, pinned by digest, identical
|
||||||
|
# on a laptop and in CI — and this works even when the docker daemon is
|
||||||
|
# remote and bind mounts are impossible.
|
||||||
|
#
|
||||||
|
# script/lint builds this file. It is a separate image from the lint
|
||||||
|
# stage of the main Dockerfile because script/lint must not depend on
|
||||||
|
# the rest of that build; the two FROM lines are kept identical by
|
||||||
|
# script/verify-lint-image-pin, run as a gate below.
|
||||||
|
# golangci/golangci-lint:v2.12.2, 2026-08-07
|
||||||
|
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Dependency layers first, so they stay cached across lint runs.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Cache-buster for the gate layers, and only for them. Caching of the
|
||||||
|
# lint run is waived by ruling: COPY is invalidated only by changed
|
||||||
|
# content, so on an unchanged tree the gates below would be served from
|
||||||
|
# cache and this build would exit 0 in under a second having run no
|
||||||
|
# linter at all. That exact false green has bitten this repo twice
|
||||||
|
# already (#32, #39). script/lint passes a fresh value on every
|
||||||
|
# invocation.
|
||||||
|
#
|
||||||
|
# Each gate RUN must reference the value, because BuildKit hashes the
|
||||||
|
# expanded command and not the ARG declaration: a declared but
|
||||||
|
# unreferenced ARG invalidates nothing. The ARG sits below the
|
||||||
|
# dependency layers deliberately — everything above it keeps its cache,
|
||||||
|
# only the gates go cold.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
|
||||||
|
# The linter version is pinned in two places, here and in the main
|
||||||
|
# Dockerfile's lint stage. Nothing else keeps them in sync, so a
|
||||||
|
# half-applied bump is a build failure; see the script.
|
||||||
|
RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
|
||||||
|
script/verify-lint-image-pin
|
||||||
|
|
||||||
|
# Validates .golangci.yml against golangci-lint's JSON schema. The
|
||||||
|
# concern about this step was that it fetches that schema over a live,
|
||||||
|
# unpinned HTTPS call; measured on the pinned image, it does not. The
|
||||||
|
# binary carries the schema for its own version, so under
|
||||||
|
# `--network none` this both passes on a valid config and still rejects
|
||||||
|
# an invalid one with the jsonschema error. That holds for the gate
|
||||||
|
# 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}" && \
|
||||||
|
golangci-lint config verify --config .golangci.yml
|
||||||
|
|
||||||
|
RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \
|
||||||
|
golangci-lint run --config .golangci.yml ./...
|
||||||
87
README.md
87
README.md
@@ -454,16 +454,12 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
develop this repository, idempotently, assuming nothing is
|
develop this repository, idempotently, assuming nothing is
|
||||||
present. `git`, `make`, and `go` come from the first of nix, apt,
|
present. `git`, `make`, and `go` come from the first of nix, apt,
|
||||||
brew, or apk found on the host, and are presence-checked only.
|
brew, or apk found on the host, and are presence-checked only.
|
||||||
`golangci-lint` is treated differently: it is checked against the
|
`golangci-lint` is deliberately **not** installed: it runs from a
|
||||||
version pinned in the script (the version the `Dockerfile` lint
|
digest-pinned image via `script/lint` and never from a host
|
||||||
stage runs) and reinstalled with `go install` whenever the
|
install, so there is no host copy to drift from the pin. A missing
|
||||||
installed version differs — older or newer, not merely absent —
|
`docker` is warned about rather than installed or treated as
|
||||||
because a host on any other version lints against different rules
|
fatal — everything except linting works without it. Ends with
|
||||||
than CI. After installing, the script verifies the pin against the
|
`go mod download`.
|
||||||
`golangci-lint` that `PATH` actually resolves; if a different copy
|
|
||||||
shadows the install, bootstrap fails, naming both the install
|
|
||||||
directory and the shadowing binary, rather than reporting a
|
|
||||||
success the gate would not honour. Ends with `go mod download`.
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
- `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`.
|
`script/bootstrap`, then `script/install-precommit`.
|
||||||
- `script/projectname` — print this project's name (`sfdupes`).
|
- `script/projectname` — print this project's name (`sfdupes`).
|
||||||
@@ -472,14 +468,35 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
- `script/test` — run the test suite with a 30-second timeout and
|
- `script/test` — run the test suite with a 30-second timeout and
|
||||||
coverage enabled, rerunning verbosely on failure so the logs show
|
coverage enabled, rerunning verbosely on failure so the logs show
|
||||||
which test failed.
|
which test failed.
|
||||||
- `script/lint` — run `golangci-lint` over the module with the
|
- `script/lint` — run the linter. It builds `Dockerfile.lint`, which
|
||||||
repository's `.golangci.yml`.
|
copies the repository into the digest-pinned
|
||||||
|
`golangci/golangci-lint` image and runs
|
||||||
|
`golangci-lint config verify` and `golangci-lint run` as build
|
||||||
|
steps, so a successful build is a clean lint. The linter is never
|
||||||
|
run on the host, which makes a working `docker` the one
|
||||||
|
prerequisite for linting — and therefore for `make check` and the
|
||||||
|
pre-commit hook. Offline machines: the gate steps themselves make
|
||||||
|
no network calls. `golangci-lint run` does not, and neither does
|
||||||
|
`golangci-lint config verify` — it validates against a schema the
|
||||||
|
pinned binary embeds, measured under `--network none` to both
|
||||||
|
pass a valid config and reject an invalid one. The build around
|
||||||
|
them does. `Dockerfile.lint` runs `go mod download` before the
|
||||||
|
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`:
|
||||||
prints any unformatted file and exits non-zero instead of writing.
|
prints any unformatted file and exits non-zero instead of writing.
|
||||||
- `script/check` — run `script/test`, `script/lint`, and
|
- `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/fmt-check`, in that order. Modifies nothing.
|
`script/fmt-check`, in that order. Modifies nothing. Needs
|
||||||
|
`docker`, because `script/lint` does.
|
||||||
- `script/docker` — build the Docker image, tagged with the name
|
- `script/docker` — build the Docker image, tagged with the name
|
||||||
from `script/projectname`. The `Dockerfile` runs the gates as
|
from `script/projectname`. The `Dockerfile` runs the gates as
|
||||||
build steps, so this is also the check a developer or reviewer
|
build steps, so this is also the check a developer or reviewer
|
||||||
@@ -493,14 +510,36 @@ and may be invoked directly. The provided entrypoints are:
|
|||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
runs `script/precommit`. The hook is written to the common git
|
runs `script/precommit`. The hook is written to the common git
|
||||||
directory, so the main checkout and every worktree share it.
|
directory, so the main checkout and every worktree share it.
|
||||||
|
- `script/verify-lint-image-pin` — fail unless the
|
||||||
|
`golangci/golangci-lint` reference in `Dockerfile.lint` and the
|
||||||
|
one in the `Dockerfile` lint stage are the same image at the same
|
||||||
|
digest, naming both if not. The linter is pinned in those two
|
||||||
|
files and nothing else keeps them in sync, so a bump applied to
|
||||||
|
one alone would leave `make lint` and the `Dockerfile`'s
|
||||||
|
fail-fast lint stage checking the same tree against different
|
||||||
|
rulesets, both green. The guard restates neither pin — a third
|
||||||
|
copy would be the same drift one file further out — and runs as a
|
||||||
|
gate in both files, so `make lint`, `make check` and `make docker`
|
||||||
|
all catch it.
|
||||||
|
|
||||||
`script/docker` and `script/cibuild` both pass a freshly computed
|
`script/verify-linter-pin` used to live here. It compared a linter
|
||||||
`CHECK_EPOCH` build argument, and the `Dockerfile`'s gate steps
|
binary against a version pin in `script/bootstrap`, and both of its
|
||||||
reference it. Without that, an unchanged tree lets Docker serve the
|
subjects are gone: no linter binary is copied between build stages any
|
||||||
gate layers from cache and the build exits 0 having executed no tests
|
more, and bootstrap pins no version because it installs no linter. The
|
||||||
and no lint — a green it never earned. `CHECK_EPOCH` invalidates the
|
drift it existed to catch has moved from binary-versus-pin to
|
||||||
gate layers on every run while leaving the pinned base images and the
|
pin-versus-pin, which is what `script/verify-lint-image-pin` above
|
||||||
dependency layers cached.
|
checks.
|
||||||
|
|
||||||
|
`script/lint`, `script/docker` and `script/cibuild` all pass a freshly
|
||||||
|
computed `CHECK_EPOCH` build argument, and the gate steps in
|
||||||
|
`Dockerfile.lint` and `Dockerfile` reference it. Without that, an
|
||||||
|
unchanged tree lets Docker serve the gate layers from cache and the
|
||||||
|
build exits 0 having executed no tests and no lint — a green it never
|
||||||
|
earned, and one this repository has produced twice. `CHECK_EPOCH`
|
||||||
|
invalidates the gate layers on every run while leaving the pinned base
|
||||||
|
images and the dependency layers cached. `script/lint`'s value carries
|
||||||
|
the process id as well as the epoch, because two lint runs land inside
|
||||||
|
the same second easily and a bare epoch would cache the second one.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -516,12 +555,14 @@ carries the compile recipe:
|
|||||||
pre-commit hook.
|
pre-commit hook.
|
||||||
- `make test` — run the test suite (30-second timeout; reruns with
|
- `make test` — run the test suite (30-second timeout; reruns with
|
||||||
`-v` on failure).
|
`-v` on failure).
|
||||||
- `make lint` — run `golangci-lint` with the repo config.
|
- `make lint` — run `golangci-lint` with the repo config, in Docker
|
||||||
|
(see `script/lint`); requires `docker`.
|
||||||
- `make fmt` / `make fmt-check` — format Go sources / verify
|
- `make fmt` / `make fmt-check` — format Go sources / verify
|
||||||
formatting without writing.
|
formatting without writing.
|
||||||
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing.
|
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing.
|
||||||
- `make docker` — build the Docker image, which runs `make check` as
|
Requires `docker`, via `lint`.
|
||||||
a build stage.
|
- `make docker` — build the Docker image, which runs the gates as
|
||||||
|
build stages.
|
||||||
- `make hooks` — install the pre-commit hook.
|
- `make hooks` — install the pre-commit hook.
|
||||||
- `make clean` — remove the binary.
|
- `make clean` — remove the binary.
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,46 @@ style conventions are in separate documents:
|
|||||||
every file before committing. There are zero exceptions to this rule.
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||||
|
scripts are our own extensions to the standard: `script/check` runs
|
||||||
|
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||||
|
what the git pre-commit hook runs, and it calls `script/check`;
|
||||||
|
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||||
|
target shims to it); and `script/projectname` (literally that filename) simply
|
||||||
|
outputs the project's name. Scripts that need the name call
|
||||||
|
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||||
|
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||||
|
`script/precommit`, not in the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -57,7 +93,11 @@ style conventions are in separate documents:
|
|||||||
as a build step so the build fails if the branch is not green. For non-server
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
stage before the final image is assembled.
|
stage before the final image is assembled. Dockerfiles install development
|
||||||
|
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||||
|
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||||
|
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||||
|
layer stays cached until dependencies change.
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
@@ -127,8 +167,9 @@ style conventions are in separate documents:
|
|||||||
artifacts or heavier dependencies.
|
artifacts or heavier dependencies.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
a successful build implies all checks pass.
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
|
pass.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -136,9 +177,11 @@ style conventions are in separate documents:
|
|||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
target to install the pre-commit hook.
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
@@ -297,6 +340,10 @@ style conventions are in separate documents:
|
|||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
- **Rationale**: Why does this exist?
|
- **Rationale**: Why does this exist?
|
||||||
- **Design**: How is the program structured?
|
- **Design**: How is the program structured?
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
@@ -326,16 +373,6 @@ style conventions are in separate documents:
|
|||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|
||||||
- **Claude Code repo memory is versioned in the repo**, not left only in
|
|
||||||
`~/.claude` on one machine. Each memory is one file at
|
|
||||||
`.claude/memory/<memory>.md`, and every memory file must be `@`-imported
|
|
||||||
from `.claude/CLAUDE.md` (one `- @memory/<memory>.md` list line per file;
|
|
||||||
relative import paths resolve against `.claude/`, and Claude Code expands
|
|
||||||
the imports into context at session launch). When adding a memory, add both
|
|
||||||
the file and its import line. A root `MEMORY.md` is a violation — Claude
|
|
||||||
Code never auto-loads it; split it into `.claude/memory/` files. Repos with
|
|
||||||
no memories yet need no `.claude/` scaffolding.
|
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
@@ -361,6 +398,9 @@ style conventions are in separate documents:
|
|||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
- `.gitea/workflows/check.yml`
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
|||||||
153
TODO.md
153
TODO.md
@@ -29,6 +29,159 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- fix the lint-image pin comments and `FROM` form in `Dockerfile` and
|
||||||
|
`Dockerfile.lint` (2026-08-10, branch `next`, closes
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/25): dropped the false
|
||||||
|
`(Debian-based)` parenthetical (v2.12.1 was Debian too) and the
|
||||||
|
redundant tag, so both pins are the policy `# image:vX.Y.Z,
|
||||||
|
YYYY-MM-DD` comment over a bare `FROM image@sha256:...`. Digest
|
||||||
|
unchanged. `script/verify-lint-image-pin` parses those `FROM` lines
|
||||||
|
and still matches the tagless form; its advice line lost the now
|
||||||
|
meaningless "tag and digest". With no tag in either reference, a
|
||||||
|
tag-only disagreement no longer exists — a one-sided tag is caught as
|
||||||
|
a plain mismatch.
|
||||||
|
|
||||||
|
- run all linting in Docker via `Dockerfile.lint` and `script/lint`
|
||||||
|
(2026-08-10, branch `next`, closes
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/46): per the owner ruling, the
|
||||||
|
linter runs inside a container invoked through the `script/`
|
||||||
|
entrypoint and is never installed on a host. 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. `script/bootstrap` loses the `go install`,
|
||||||
|
the pin constants, the version parser and `verify_golangci_lint`
|
||||||
|
outright rather than hardening them — with nothing linting on the
|
||||||
|
host, the `$GOPATH/bin` versus `PATH` problem that motivated them has
|
||||||
|
no subject — and now warns rather than fails when `docker` is absent.
|
||||||
|
Two traps handled. A lint build on an unchanged tree returns success
|
||||||
|
in well under a second having run no linter, which is
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/32 and
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/39 again, so
|
||||||
|
`Dockerfile.lint` carries `ARG CHECK_EPOCH` referenced
|
||||||
|
inside every gate `RUN` (BuildKit hashes the expanded command, not
|
||||||
|
the declaration) and `script/lint` passes `"$(date +%s)-$$"` — the
|
||||||
|
PID matters because two lint runs land inside the same second easily.
|
||||||
|
And nothing inside an image build may shell out to docker, so the
|
||||||
|
main `Dockerfile`'s lint stage now 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 (`make`, not
|
||||||
|
the scripts bare, because the Makefile's `export CGO_ENABLED = 0`
|
||||||
|
only reaches what it invokes). `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, and dropping it without replacing the edge would have ended
|
||||||
|
fail-fast linting silently under a still-green build. That is
|
||||||
|
canonical `REPO_POLICIES.md:107`'s ordering edge, restored.
|
||||||
|
`ENV PATH=/home/builder/go/bin:$PATH` is gone 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 the 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
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/42 made a build failure. Its
|
||||||
|
replacement is one new `script/verify-lint-image-pin`,
|
||||||
|
run as a gate in both files, which compares the two references to
|
||||||
|
each other and deliberately restates neither: a hardcoded expected
|
||||||
|
digest would be a third copy and the same drift one file further out.
|
||||||
|
`golangci-lint config verify` is included per the ruling, and 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 from an embedded schema and makes no network
|
||||||
|
call of its own. The README scopes that to the gate steps rather
|
||||||
|
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
|
||||||
|
(`/home/user/go/bin`, `/home/user/.local/bin`, `/usr/local/bin`;
|
||||||
|
`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, at 42.2s and 41.8s wall clock — the no-cache rule was not
|
||||||
|
weakened to shorten that. Negative control: a planted
|
||||||
|
`var unusedIssue46Sentinel = 1` failed `script/lint` with
|
||||||
|
`report.go:173:5: var unusedIssue46Sentinel is unused (unused)`, and
|
||||||
|
failed `make docker` at `[lint 9/9]` with the build stage stopped at
|
||||||
|
`[builder 3/12]` — `COPY --from=lint`, `script/bootstrap`, the test
|
||||||
|
gate and `make build` all zero occurrences — then reverted clean. The
|
||||||
|
drift guard fails on a tag-only disagreement, on a digest-only
|
||||||
|
disagreement, and on an unreadable reference, naming both sides.
|
||||||
|
`make docker` green in 5m35s with all six gates executing under one
|
||||||
|
epoch (lint 37.6s, test 25.2s reporting
|
||||||
|
`ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not `(cached)`).
|
||||||
|
The non-root quirk still holds: in the builder image with the Go test
|
||||||
|
cache off, `--user 0:0` fails `TestScanHardlinkRunFailsTogether`
|
||||||
|
(exit 1) where the unprivileged user passes (exit 0). Noted for
|
||||||
|
follow-up, not fixed here: `golangci-lint` warns that the
|
||||||
|
`gomodguard` linter is deprecated since v2.12.0 in favour of
|
||||||
|
`gomodguard_v2`.
|
||||||
|
|
||||||
|
- install the Docker build stage's prerequisites by running
|
||||||
|
`script/bootstrap` instead of `apk add --no-cache make` inline
|
||||||
|
(2026-08-09, branch `dockerfile-bootstrap`, closes #42): canonical
|
||||||
|
`REPO_POLICIES.md:97` requires it, and the inline install left the
|
||||||
|
build stage maintaining its own notion of the toolchain — exactly
|
||||||
|
the divergence #24 exists to close, one layer down. The stage now
|
||||||
|
copies `script/` plus `go.mod`/`go.sum` and runs `script/bootstrap`,
|
||||||
|
which ends in `go mod download`, so the separate invocation of that
|
||||||
|
is gone. `COPY --from=lint /usr/bin/golangci-lint` stays, and moves
|
||||||
|
above the bootstrap layer. It is the only edge making this stage
|
||||||
|
depend on the lint stage, so deleting it as redundant would end
|
||||||
|
fail-fast linting silently. Letting bootstrap install its own linter
|
||||||
|
here would have reintroduced the second toolchain and paid for a
|
||||||
|
from-source build of it. What makes the two stages provably one
|
||||||
|
toolchain rather than two that happen to agree is a new
|
||||||
|
`script/verify-linter-pin`, run in the build stage on the binary
|
||||||
|
that arrives from the lint stage, before bootstrap: it fails the
|
||||||
|
build naming both versions unless that binary is the version
|
||||||
|
`script/bootstrap` pins. Bootstrap's own check could not serve that
|
||||||
|
purpose — it reinstalls its pin from source and then verifies
|
||||||
|
whatever `PATH` resolves, so drift self-heals silently and a lint
|
||||||
|
stage image bumped on its own would lint at the new version while
|
||||||
|
`make check` ran at the old one, green. The linter version is pinned
|
||||||
|
in two independent places (the lint stage image digest and
|
||||||
|
`GOLANGCI_LINT_VERSION`) and nothing else keeps them in sync, so a
|
||||||
|
half-applied bump is now a build failure. The pin is read out of
|
||||||
|
`script/bootstrap`, which stays the single source of truth; a pin
|
||||||
|
that cannot be read is a hard failure, not a skip. The check needs
|
||||||
|
no `CHECK_EPOCH`: its only inputs are the copied binary and
|
||||||
|
`script/`, so Docker invalidates the layer exactly when a cached
|
||||||
|
result would stop being true, and it is documented with the other
|
||||||
|
entrypoints in the README. `$GOPATH/bin` joins `PATH` because
|
||||||
|
that is where bootstrap's `go install` lands and bootstrap verifies
|
||||||
|
its installs against what `PATH` resolves — nothing in the image is
|
||||||
|
shadowed by it, the directory does not exist until bootstrap runs.
|
||||||
|
Everything added sits above `ARG CHECK_EPOCH`, and the `chown` and
|
||||||
|
`USER builder` still precede `make check`. Verified: the guard fails
|
||||||
|
the build with both versions named when the lint stage's linter is
|
||||||
|
faked to a different version, and an unmodified build still passes
|
||||||
|
it; bootstrap runs clean under Alpine's `sh` and its `apk` branch,
|
||||||
|
installing `git` and `make` and finding the copied
|
||||||
|
linter already at the pin; a second build served the bootstrap and
|
||||||
|
dependency layers `CACHED` while both gates ran with a fresh epoch;
|
||||||
|
a planted `unused` finding failed the build at the lint gate in
|
||||||
|
48.9s with the build stage's `make check` never starting; and the
|
||||||
|
suite run in the image as `--user 0:0` fails
|
||||||
|
`TestScanHardlinkRunFailsTogether`, so the drop to the unprivileged
|
||||||
|
user is still load-bearing. That last check needs the Go test cache
|
||||||
|
disabled — the first attempt reported `ok ... (cached)` as root,
|
||||||
|
reusing the result the build-time run had left in the shared cache,
|
||||||
|
which would have read as a pass. Build wall time, on a shared host
|
||||||
|
running many concurrent builds and so noisy: 2m13s on an unchanged
|
||||||
|
tree, 2m17s and 4m29s for two builds after a source change, 5m14s
|
||||||
|
cold. Only the cold one breaches the policy ceiling, and not because
|
||||||
|
of this change — `chown -R builder:builder /src /home/builder` walks
|
||||||
|
the module cache and re-runs on every source change, and it alone
|
||||||
|
varied between 77s and 210s across those four builds, which is also
|
||||||
|
the whole spread in the totals. The same cold measurement against
|
||||||
|
`main` is 5m03s with a 209s `chown`. Filed as #43
|
||||||
- bust the Docker layer cache for the gate steps, so `script/cibuild`
|
- bust the Docker layer cache for the gate steps, so `script/cibuild`
|
||||||
and `script/docker` cannot report a green they did not earn
|
and `script/docker` cannot report a green they did not earn
|
||||||
(2026-08-09, branch `cibuild-cache-bust`, closes #32): both scripts
|
(2026-08-09, branch `cibuild-cache-bust`, closes #32): both scripts
|
||||||
|
|||||||
116
script/bootstrap
116
script/bootstrap
@@ -2,31 +2,15 @@
|
|||||||
# script/bootstrap: install all dependencies needed to build and develop
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes nothing is present.
|
# or apk (detected in that order); assumes nothing is present (not git,
|
||||||
# golangci-lint is installed via `go install` pinned to the same version
|
# make, or go). The linter is NOT installed: golangci-lint runs via
|
||||||
# the Dockerfile lint stage uses (never "latest"), and is reinstalled
|
# docker only (script/lint), pinned by image digest, so the only lint
|
||||||
# whenever the installed version differs from that pin. The install is
|
# prerequisite is a working docker — which is warned about, not
|
||||||
# then verified against the binary PATH actually resolves: if the pin is
|
# installed, because everything except linting works without it.
|
||||||
# still not what would run, bootstrap fails instead of reporting
|
|
||||||
# success.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
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=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
APT_UPDATED=""
|
APT_UPDATED=""
|
||||||
@@ -74,73 +58,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! 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() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -154,20 +71,15 @@ main() {
|
|||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Lint tooling, pinned via go install (installs into
|
# Linting runs via docker only (script/lint), so docker is a lint
|
||||||
# "$(go env GOPATH)/bin"; ensure that is on your PATH). Unlike the
|
# prerequisite rather than something bootstrap installs. Warn, do
|
||||||
# system tools above this is version-checked, not presence-checked:
|
# not fail: everything except `make lint` — and, through it,
|
||||||
# the Dockerfile lint stage runs a digest-pinned linter, so a host
|
# `make check`, `make docker` and the pre-commit hook — works
|
||||||
# running any other version lints against different rules and
|
# without it.
|
||||||
# `make check` can go green on a commit CI then rejects. Any version
|
if missing docker; then
|
||||||
# that is not the pin — older or newer — is reinstalled, and the
|
echo "bootstrap: WARNING: docker not found; make lint, make check" >&2
|
||||||
# install is then verified to be the binary PATH resolves.
|
echo "bootstrap: and make docker require it. Install docker to" >&2
|
||||||
installed="$(golangci_lint_version)"
|
echo "bootstrap: run the linter." >&2
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
21
script/lint
21
script/lint
@@ -1,12 +1,29 @@
|
|||||||
#!/bin/sh
|
#!/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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
golangci-lint run --config .golangci.yml ./...
|
docker build \
|
||||||
|
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
||||||
|
-f Dockerfile.lint \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
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 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 "$@"
|
||||||
Reference in New Issue
Block a user