Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 13s

The linter is no longer installed on the host and no longer invoked
there. script/lint is now `docker build -f Dockerfile.lint .` and
nothing else, with the linter running as a build step, so a successful
build of that file is a clean lint — and it works unchanged where the
docker daemon is remote and bind mounts are impossible.

That removes three host-only failure mechanisms rather than mitigating
them: the result cache keyed on file content rather than location, which
produced a confirmed false green and a string of findings reported
against other checkouts; the host-global $TMPDIR/golangci-lint.lock,
which fails a run with `parallel golangci-lint is running` in a way no
caller can distinguish from findings; and host/container version skew,
which hid thirteen findings on one repo. A container per run has its own
cache, its own lock and a binary pinned by digest.

Resolving the recursion this creates. script/lint is a docker build, so
a Dockerfile that runs `make check` would nest a build inside a build
step where there is no daemon. Fixed by direction, not detection: the
main Dockerfile runs script/test and script/fmt-check individually, with
a comment saying why `make check` must not come back, and script/cibuild
runs script/lint first for fail-fast feedback. script/check still runs
all three, so developers and the pre-commit hook are unaffected.

Dockerfile.lint carries the same CHECK_EPOCH guard as the main image,
with the ARG placed below the dependency layer so only the lint steps
re-run. Blanket --no-cache was rejected: it re-runs the dependency
install on every lint and makes linting network-dependent.

golangci-lint config verify is kept, on measurement rather than
preference. Under the pinned v2.12.2, a bogus top-level key and a bogus
key nested under linters.settings.lll both pass `golangci-lint run` with
exit 0 and `0 issues` while config verify exits 3 and names them; an
unknown linter name fails run and passes config verify. The two catch
disjoint classes, and `run` alone silently ignores the class where a
threshold reads as configured and is not applied. The concern that
config verify fetches its JSON schema over live HTTPS does not hold for
this version: every case reproduced byte-identically under
`docker run --network none`, in a container where `getent hosts
golangci-lint.run` exits 2. The schema is embedded in the pinned binary.

Two canonical forms are superseded and deleted rather than left standing
beside the new one, because consuming repos read these documents
literally and two contradictory canonical script/lint forms is worse
than either. The script/bootstrap golangci-lint install landed for
#28 is removed: nothing invokes
a host linter now, so it can only reintroduce the skew it was written to
close. Its version-enforcement principle — compare version not presence,
re-resolve through PATH after installing, let a mis-parse fall through
to reinstall, and call it — stays documented for any other pinned host
tool. The per-checkout GOLANGCI_LINT_CACHE/TMPDIR wrapper is removed
with it; its entire subject was making a host run trustworthy. Adopting
repos delete .lint-cache/ from .gitignore and .dockerignore too. The Go
multistage lint stage and its COPY --from=lint ordering trick go the
same way: that stage ran `make lint`, which is now a docker build.

Corrected everywhere the claim that a successful docker build implies
lint passed — REPO_POLICIES.md, both repo checklists, the Go styleguide
and the README. The guarantee now belongs to script/cibuild, which runs
both container builds; a bare `docker build .` never lints at all.

Verified in this repo, not only documented: two consecutive script/lint
runs on a byte-identical tree both executed prettier (4.556s and 3.738s,
lint layers DONE with a fresh epoch printed, dependency layers CACHED as
intended); a planted violation failed the build naming the file, and
reverting it went green; a bare `docker build -f Dockerfile.lint .`
failed on the guard; make check, script/docker and script/cibuild all
green with the check layers demonstrably executing; and the main image
build completed without attempting a nested build.

Rework, from independent review of this commit. The canonical text is
the deliverable here, so a false sentence is a fleet-wide defect: the
Dockerfile rule still said the build "fails if the branch is not green",
which stopped being true when lint left that image, and the earlier
sweep grepped one phrasing rather than the claim. Re-swept on the claim
itself — green/red-branch wording, build-fails wording, entailment verbs
near build/lint/check, and "linted" asserted as covered — across
prompts/, README.md, TODO.md, both Dockerfiles and every script.

Two absolutes are narrowed to what is actually true, because seventeen
repos adopt this literally. The rule is that no lint VERDICT may come
from a host invocation, not that the binary never exists on the host: a
JS repo's `yarn install` puts its linter in node_modules on the host
unavoidably, and in a repo whose formatter is its linter — this one —
`script/fmt-check` runs the same command that Dockerfile.lint runs. That
gap is now stated with its bound (the version is pinned in the repo's
own node_modules, so no shared cache, no host lock, nothing to skew) and
the audit grep keeps its reach, gaining a note on which two hits are
expected rather than being weakened.

script/lint conflates "found issues" with "could not run": docker build
exits 1 for both. The exit-75 VOID machinery is deliberately not
restored, and the reasoning is now recorded where a reader looking for
it lands. The dangerous direction is already closed, since a build that
cannot run fails closed and can never read as clean; BuildKit already
names the failing step, where the old lock error went to stderr while
findings went to stdout and was easy to lose; and the failure is not
transient, so the retry that justified the old machinery would be wrong
here. Rebuilding the distinction would mean per-invocation capture files
and traps again plus matching on BuildKit's message format, which is not
a stable interface, and a mis-match in the "treat as infrastructure"
direction would be the false green this rule exists to prevent. What
survives is binding as a reading rule: a run that did not reach the lint
step is not a verdict.

Also corrected: script/lint was listed above a CHECK_EPOCH snippet that
does a bare `docker build .` with no -f, which would have built the main
image and linted nothing; the canonical Go Dockerfile template used
`make fmt-check` / `make test` where every prose rule in the same
document says script/, one Makefile edit away from re-entering the
recursion; README omitted the mandatory VERSION build arg; script/docker
did not say lint had left its image, a comment that propagates
fleet-wide; the "byte-identical across repos" claim for script/lint is
narrowed to its executable lines; and "--no-cache makes linting
network-dependent" is softened to the measured comparative claim.
This commit is contained in:
2026-08-10 12:49:34 +00:00
parent 0620416869
commit 35858dab66
12 changed files with 689 additions and 664 deletions

View File

@@ -25,4 +25,13 @@ COPY . .
# here, not one. Keep both. # here, not one. Keep both.
ARG CHECK_EPOCH ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
# The individual non-lint checks, NOT `make check`. Lint is deliberately
# absent here: `script/lint` is itself a `docker build` (of
# Dockerfile.lint), so running `make check` in this image would attempt
# a docker build inside a build step, where there is no daemon. Putting
# `make check` back reintroduces exactly that recursion. Lint is not
# skipped — script/cibuild runs script/lint first, in its own container,
# before this build starts.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

41
Dockerfile.lint Normal file
View File

@@ -0,0 +1,41 @@
# Lint-only image. `script/lint` builds this file and nothing else: the
# linter runs as a build step, so a successful build IS a clean lint.
# Building rather than bind-mounting is what makes it work where the
# docker daemon is remote and bind mounts are impossible.
#
# The linter is invoked directly below rather than through `make lint`.
# That is not a style choice: `script/lint` IS this build, so calling it
# from inside would recurse into a docker build with no daemon.
#
# This repo's linter is prettier over markdown. A Go repo's version of
# this file differs only in the base image and the two lint commands;
# see the containerised-lint rule in prompts/REPO_POLICIES.md.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app
# Dependency layer first, and deliberately above the ARG below, so it
# stays cached and only the lint steps re-run on every invocation.
# Without that ordering the cache-bust would reinstall dependencies on
# every lint and make linting network-dependent.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# it an unchanged tree serves the lint layer from cache and the build
# reports a lint it never ran — a green that proves nothing, which is
# the whole failure mode this file exists to avoid reintroducing. The
# guard makes a bare `docker build -f Dockerfile.lint .` fail loudly
# instead of silently reusing the empty (and therefore stable) cache
# key. The value is expanded into the lint command as well, so the cache
# miss does not depend on BuildKit's handling of an unreferenced ARG and
# the epoch is visible in the build log. Keep both references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && \
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always

View File

@@ -117,19 +117,32 @@ alpine. We provide:
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here) - `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files with prettier - `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The
lint verdict comes only from the container; nothing on the host produces one.
(The prettier in `node_modules` that `script/fmt` and `script/fmt-check` use
is the same binary, which is why this says "verdict" rather than "never on the
host" — see the scope note in `prompts/REPO_POLICIES.md`.) Linting happens as
a build step, so a successful build is a clean lint, and the same
per-invocation `CHECK_EPOCH` nonce used elsewhere is what stops Docker serving
that lint from cache on an unchanged tree. A failure that names no finding —
daemon down, image unpullable — is not a lint result: read which build step
failed, fix that, and re-run
- `script/fmt` — format all markdown files with prettier (writes) - `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension). Needs a docker daemon, since `script/lint` is a container build
- `script/docker` — build the Docker image, tagged via `script/projectname` - `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as (byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild` `script/cibuild`
- `script/cibuild` — cd to the repo root, assign `epoch="$(date +%s%N)$$"`, then - `script/cibuild` — cd to the repo root, run `script/lint` first, then assign
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs; the image `epoch="$(date +%s%N)$$"` and `version="$(git describe ...)"` on their own
build runs `script/check`, and the per-invocation `CHECK_EPOCH` nonce is what lines and
stops Docker serving that check from cache on an unchanged tree — a bare `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
`docker build .` fails closed on purpose) (what CI runs; both build args are mandatory). Two container builds: the lint
image, then the main image, which runs `script/test` and `script/fmt-check`
but deliberately not `make check` — that would nest a docker build inside a
build step. So `script/cibuild` is what proves the branch green; a bare
`docker build .` never lints, and fails closed on purpose
- `script/precommit` — run by the git pre-commit hook (our own extension); calls - `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own

42
TODO.md
View File

@@ -21,6 +21,48 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
made this repo do it rather than merely document it. `script/lint` is now
`docker build -f Dockerfile.lint .` and nothing else; the linter is never
installed on the host and never invoked there, so a run cannot inherit another
checkout's content-keyed result cache, the host-global
`$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned
one — the three mechanisms behind a confirmed false green, a string of
findings reported against other agents' checkouts, and a container that saw
thirteen findings the host missed. Linting runs as a build step, so a
successful build is a clean lint, which also works where the docker daemon is
remote and bind mounts are impossible. The recursion this creates is resolved
by direction rather than by detection: the main `Dockerfile` runs the
individual non-lint checks instead of `make check`, and `script/cibuild` runs
`script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries
the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the
dependency layer so only the lint steps re-run — blanket `--no-cache` was
rejected because it makes every lint reinstall its dependencies over the
network. Two canonical forms were superseded rather than left standing beside
the new one, since consuming repos read this document literally: the
`script/bootstrap` golangci-lint install (nothing runs a host linter now, so
it can only reintroduce skew; the version-enforcement principle stays
documented for other pinned host tools) and the per-checkout
cache/lock/`.lint-cache` wrapper (its whole subject was making a host run
trustworthy). The Go multistage lint stage goes with them: it ran `make lint`,
which is now a docker build. `golangci-lint config verify` was kept on
measurement, not preference — a bogus config key passes `golangci-lint run`
with `0 issues` and fails `config verify`, and every case reproduced
byte-identically under `docker run --network none`, so the schema is embedded
in the pinned binary and the line costs no network. Two positions are stated
rather than left as gaps, because canon that omits them gets re-derived
wrongly: `docker build` returns 1 both for findings and for a build that never
reached the lint step, and the exit-75 VOID machinery is deliberately not
restored — the dangerous direction is closed since an unrunnable lint fails
closed, BuildKit already names the failing step, and the failure is not
transient, so what survives is the reading rule that a run which did not lint
is not a verdict. And the rule is about linters: `script/fmt` and
`script/fmt-check` run on the host by necessity, which in a repo whose
formatter is its linter — this one — means that exact command does run there,
recorded as a known and bounded gap rather than papered over. Verified with
two consecutive runs on an unchanged tree both executing the linter, a planted
violation caught and reverted, the bare-build guard firing, and the main image
building without attempting a nested build.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it. - 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two

View File

@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
1. Try to hard wrap long lines at 77 characters or less. 1. Try to hard wrap long lines at 77 characters or less.
@@ -111,17 +111,27 @@ last_modified: 2026-08-09
1. For anything beyond a simple script or tool, or anything that is going to 1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. `golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`:
the linter runs in a container, always, and `golangci-lint` is not installed
on the host at all — no repo's `script/bootstrap` installs it any more. A
`golangci-lint` invoked directly on a shared host reads a result cache keyed
on file content rather than location and a host-global lock, so its answer
may belong to another checkout entirely, and a `make lint` is the only
verdict worth recording. A failure that names no finding is not a verdict
either: read which build step failed before concluding anything.
1. Write a `Dockerfile` for every repo, even if it only runs the tests and 1. Write a `Dockerfile` for every repo, even if it only runs the tests. It runs
linting. `script/cibuild` and `script/docker` should always make sure that the non-lint checks; linting lives in `Dockerfile.lint` and is run by
the code is in an able-to-be-compiled state, linted, and any tests run, and `script/cibuild` before the main build, because `script/lint` is itself a
the build should fail if linting doesn't pass. That guarantee holds only `docker build` and cannot run inside one. So `script/cibuild` is what
because those scripts pass a per-invocation `CHECK_EPOCH` build arg that guarantees the code is in an able-to-be-compiled state, linted, and tested —
busts the check layers out of the Docker cache; without it an unchanged tree **a successful `docker build .` on its own does not, because it never
serves those layers from cache and the build reports a green it never ran. A lints.** That guarantee holds only because each build passes a
bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
guard — always go through `script/cibuild` or `script/docker`. See the Docker cache; without it an unchanged tree serves those layers from
cache and the build reports a green it never ran. A bare `docker build .`
fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard — always go
through `script/cibuild`, `script/docker` or `script/lint`. See
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
for the canonical form. for the canonical form.

View File

@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -36,12 +36,23 @@ with your task.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`); `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
Dockerfile runs `make check` as a build step, and every stage containing a Dockerfile runs the **non-lint** checks as build steps (`script/test`,
check-running `RUN` declares `ARG CHECK_EPOCH` with the `script/fmt-check`), and every stage containing a check-running `RUN`
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is guard immediately below it — see the `CHECK_EPOCH` rule in
served from cache on an unchanged tree and the build reports a green it `REPO_POLICIES.md`. Without them the check layer is served from cache on
never ran. an unchanged tree and the build reports a green it never ran.
- [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint`
stage or a `COPY --from=lint ... /dev/null` ordering line. This is the
item an existing repo most often fails: `script/lint` is now a
`docker build`, so both of those nest a docker build inside a build step.
Delete the stage; `script/cibuild` running `script/lint` first is what
replaces its fail-fast purpose.
- [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the
containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
base image is pinned by sha256 with a version/date comment, it carries
`ARG CHECK_EPOCH` **after** the dependency layer with the guard below it,
and it invokes the linter directly rather than through `make lint`.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled - [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored — binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
@@ -100,13 +111,33 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] Go: `script/lint` isolates golangci-lint per checkout — - [ ] `script/lint` is the canonical container build and nothing else, and no
`GOLANGCI_LINT_CACHE` and `TMPDIR` both exported into `.lint-cache/` host invocation anywhere in the repo can produce a lint **verdict** — grep
(which is in `.gitignore` and `.dockerignore`), `--allow-serial-runners` for the linter's own name across `script/`, the `Makefile` and CI config,
passed, and the lock error retried rather than reported as findings. Copy not just in `script/lint`. An existing repo is where a second path to the
the canonical block from `REPO_POLICIES.md`. Setting only the cache is the linter is likeliest to exist: a `make lint-fast`, a container-versus-host
common half-fix and leaves `parallel golangci-lint is running` failing branch, or a CI step that calls the binary directly. **Two hits are
runs red. expected and are not the defect**, so triage rather than delete: any
`script/fmt` (a formatter must run on the host — that is its job), and, in
a repo whose formatter is also its linter, `script/fmt-check`, whose
command will be the same string as the one in `Dockerfile.lint`. See the
scope note in the containerised-lint rule. Everything else the grep finds
is a real second path and goes.
- [ ] `script/bootstrap` installs no linter **for the purpose of linting**.
Delete a dedicated install — the golangci-lint block, its version and ref
variables, and its call site: nothing invokes a host linter any more, so
all it can still do is put a differently versioned binary where somebody
runs it by hand and believes the result. A JS repo's `yarn install` stays;
it brings a linter along with every other dependency, which is unavoidable
and harmless as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`. A container has its own cache and its
own lock, so keeping the wrapper leaves two contradictory `script/lint`
forms in the fleet.
- [ ] `script/cibuild` runs `script/lint` before the main `docker build`.
Without that line CI never lints at all, because the main image
deliberately does not.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 30-second timeout - [ ] `make test` has a 30-second timeout
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile - [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
@@ -153,6 +184,9 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds (a bare `docker build .` fails closed by design, - [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
on the `CHECK_EPOCH` guard) both times, never `CACHED` and never sub-second
- [ ] `script/cibuild` succeeds and runs both container builds (a bare
`docker build .` or `docker build -f Dockerfile.lint .` fails closed by
design, on the `CHECK_EPOCH` guard)
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task

View File

@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-08-09 last_modified: 2026-08-10
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -73,15 +73,27 @@ Template files can be fetched from:
declared in the stage that compiles, and **no stage calls `git describe`** declared in the stage that compiles, and **no stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without `.dockerignore` excludes `.git`, so it yields an empty version without
failing the build. failing the build.
- All Dockerfiles must run `make check` as a build step, and every stage - The `Dockerfile` runs the **non-lint** checks as build steps —
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the `script/test` and `script/fmt-check`, never `make check`. `script/lint` is
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the a `docker build` of `Dockerfile.lint`, so `make check` here nests a build
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is inside a build step, where there is no daemon. Put a comment above those
served from cache on an unchanged tree and the build reports a green it `RUN` lines saying so. Every stage containing a check-running `RUN` must
never ran. declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
guard immediately below it — see the `CHECK_EPOCH` rule in
`REPO_POLICIES.md`. Without them the check layer is served from cache on
an unchanged tree and the build reports a green it never ran.
- Server: also builds and runs the application - Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check` - Non-server: brings up dev environment and runs those checks
- Image pinned by sha256 hash with version/date comment - Image pinned by sha256 hash with version/date comment
- [ ] `Dockerfile.lint` — the lint-only image that `script/lint` builds. Same
`ARG CHECK_EPOCH` + guard + expanded-value discipline as above, with the
`ARG` placed **after** the dependency layer so only the lint steps re-run.
Base image pinned by sha256 with a version/date comment. Go repos use
`golangci/golangci-lint` and run both `golangci-lint config verify` and
`golangci-lint run`; other repos use the same pattern around their own
linter (eslint, ruff, prettier). Copy the canonical file from
`REPO_POLICIES.md`. The linter is invoked directly there, never via
`make lint`, which would recurse.
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs - [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference `script/cibuild` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -108,29 +120,27 @@ are thin shims calling them. Model scripts:
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second - [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
timeout) timeout)
- [ ] `script/lint` / `make lint`runs linter - [ ] `script/lint` / `make lint`builds `Dockerfile.lint` and nothing else:
- [ ] Go: exports `GOLANGCI_LINT_CACHE` **and** `TMPDIR` into a `epoch="$(date +%s%N)$$"` on its own line, then
`.lint-cache/` directory inside the checkout, above any `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. No
container-versus-host branch so every path that reaches the linter lint verdict may come from a host invocation. Copy the canonical script
gets them; passes `--allow-serial-runners` (never from `REPO_POLICIES.md`; its executable lines are identical across repos.
`--allow-parallel-runners`); retries on Without the nonce this script exits 0 on an unchanged tree having linted
`parallel golangci-lint is running` detected on **stderr** and exits nothing, and without `-f Dockerfile.lint` it builds the main image and
75 with a VOID message on exhaustion. Copy the canonical block from lints nothing at all.
`REPO_POLICIES.md` rather than writing your own: a version that sets
only the cache leaves the false-red half live, and one that detects
the collision by exit status can retry a real finding away.
- [ ] Go: `.lint-cache/` is in both `.gitignore` and `.dockerignore`
- [ ] `script/fmt` / `make fmt` — formats code (writes) - [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files modify files. It needs a docker daemon, because `script/lint` is a
container build, and it must never be called from inside a build stage
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); carries the same three `script/projectname` (byte-identical across repos); carries the same three
version lines as `script/cibuild` below, and passes version lines as `script/cibuild` below, and passes
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"` `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, then, each on its own line: - [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for
fail-fast feedback, then, each on its own line:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
@@ -142,14 +152,17 @@ are thin shims calling them. Model scripts:
. .
``` ```
(what CI runs). Both build args are mandatory, and both assignments must be (what CI runs). The `script/lint` call is not optional: the main image does
on their own line: a failing command substitution inside an argument does not lint, so without it CI never lints. Both build args are mandatory, and
not trip `set -e`, so the inline form degrades silently to an empty both assignments must be on their own line: a failing command substitution
constant. The `[ -n "$version" ]` line is a live check that fires on an inside an argument does not trip `set -e`, so the inline form degrades
export with no `.git` and on a repo with no commits — keep it, and do not silently to an empty constant. The `[ -n "$version" ]` line is a live check
collapse it into `|| echo unknown`, which makes it unreachable. See the that fires on an export with no `.git` and on a repo with no commits — keep
`CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each it, and do not collapse it into `|| echo unknown`, which makes it
element is load-bearing. A bare `docker build .` fails closed by design. unreachable. See the `CHECK_EPOCH` and git-describe rules in
`REPO_POLICIES.md` for why each element is load-bearing. A bare
`docker build .` fails closed by design, and so does a bare
`docker build -f Dockerfile.lint .`.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
@@ -161,7 +174,11 @@ are thin shims calling them. Model scripts:
# 4. Verify # 4. Verify
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make lint` demonstrably runs the linter rather than returning a cached
build: run it twice on an unchanged tree and confirm the lint layer says
`DONE`, never `CACHED`, both times
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds and runs both container builds
- [ ] No secrets in repo - [ ] No secrets in repo
- [ ] No mutable image/package references - [ ] No mutable image/package references
- [ ] No unnecessary files in repo root - [ ] No unnecessary files in repo root

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,13 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. Must not modify any files.
#
# script/lint is a docker build (see Dockerfile.lint), so this script
# requires a docker daemon. That is deliberate: it is the only way a
# developer and the pre-commit hook get the same linter CI gets. It also
# means this script must never be run from inside a build stage — see
# the comment in Dockerfile, which runs the individual non-lint checks
# for exactly that reason.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,14 +1,21 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, but # script/cibuild: run the CI build. Two container builds, in order:
# that only proves anything because CHECK_EPOCH is a fresh nonce on every # script/lint (Dockerfile.lint) and then the main image, which runs the
# invocation: without it Docker serves the check layer from cache on an # non-lint checks. Both only prove anything because each passes its own
# unchanged tree and the build exits 0 without running the suite. # fresh CHECK_EPOCH nonce: without it Docker serves the check layers
# from cache on an unchanged tree and the build exits 0 without running
# anything.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Lint first, for fail-fast feedback: it is its own container build
# and computes its own CHECK_EPOCH. It runs here rather than inside
# the main image because a docker build cannot run a docker build.
"$SCRIPT_DIR/lint"
# Assign on its own line: a failing command substitution inside an # Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, which would silently degrade the # argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`

View File

@@ -4,6 +4,12 @@
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh # Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a # nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing. # green that proves nothing.
#
# Those checks are the NON-LINT ones. Linting left this image: it runs
# in its own container, built by script/lint, because script/lint is a
# docker build and cannot run inside one. So a green here does not mean
# the branch is green — script/cibuild, which runs script/lint first, is
# what means that.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,13 +1,33 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run the linter. This is the ONLY source of a lint verdict
# in this repo — the linter runs in a container, one way, everywhere, so
# a run cannot inherit another checkout's cache, another process's lock,
# or a host toolchain that differs from the pinned one. Linting happens
# as a build step (see Dockerfile.lint), so a successful build is a
# clean lint, and it works where the docker daemon is remote and bind
# mounts are impossible.
#
# A failure here that names no finding is NOT a lint result: docker
# build exits 1 both for findings and for a build that never reached the
# lint step (daemon down, image unpullable, disk full). BuildKit names
# the failing step; read it, fix the environment, and re-run. Do not
# record a verdict from a run that did not lint.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Linting markdown files..." # Assign on its own line: a failing command substitution inside an
yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always # argument does not trip `set -e`, which would silently degrade the
# nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. Without a fresh nonce the lint layer is
# served from cache and this script exits 0 having linted nothing.
epoch="$(date +%s%N)$$"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"