Commit Graph

4 Commits

Author SHA1 Message Date
12e8db8b0e Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 29s
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.
2026-08-10 12:49:34 +00:00
3a218497b8 Keep in-repo agent scratch out of the build context and out of git (closes #27)
All checks were successful
check / check (push) Successful in 17s
The canonical .dockerignore and .gitignore both omitted the in-repo agent
scratch directory. On this fleet that directory holds one worktree per
in-flight agent -- an entire additional checkout of the repo each -- so under
`COPY . .` all of it reached the build context and the image. Measured on this
repo before the change: five planted scratch files, at every depth beneath the
directory, all present inside a probe image built from the real context.

Three consequences, only the first of which is about size. The context inflates
by a multiple of the repo. Another session's unreviewed and sometimes
uncommitted work is copied into a build artifact. And the directory is created
and destroyed constantly by tooling, so it invalidates `COPY . .` for reasons
that have nothing to do with this repo's content -- which is the accidental
cache protection described at length in the issue thread, and the reason this
change was sequenced behind the CHECK_EPOCH bust rather than landed alongside
the rest of the .dockerignore work.

The two entries are deliberately different shapes, because the two files have
different semantics and neither is derived from the other. In .dockerignore the
entry is anchored, `.claude`, with no `**/` prefix: the directory occurs exactly
once, at the context root, and the prefixed form additionally matches any nested
directory of that name. Measured rather than argued -- the `**/`-prefixed
control was built and enumerated too, and it removes prompts/.claude/ from the
context as well, which in a repo with a legitimately named nested directory
would silently delete it from the build. In .gitignore the entry is unanchored,
`.claude/`, because a .gitignore pattern already matches at every depth;
`git check-ignore -v` confirms it covering both .claude/ and prompts/.claude/,
so a `**/` prefix there would be redundant at best, and on an anchored pattern
it would be actively wrong.

Anchoring buys that at the cost of a residual exposure, and the vendored files
now say so rather than only asserting the reason to anchor. "Occurs exactly
once, at the context root" is a property of how agents are run, not of the
tooling: the directory is created in the agent's working directory, so a
monorepo running a per-service agent in services/api/ still ships
services/api/.claude/ into the context and the image -- the exact exposure this
change exists to close, left open in the repo shape where it is likeliest. The
.dockerignore header block, the REPO_POLICIES.md bullet and both checklists
state the gap and the remedy (anchored entries for the subdirectories that have
one, or `**/.claude` once no legitimately named nested directory would be
caught). Consuming repos receive the files and not the tracker, so a caveat that
lives only in a PR body is not a caveat.

It is not case-folded the way the neighbouring secret patterns are: tooling
creates the directory in exactly one spelling, so a folded pattern would add no
coverage. The earlier justification -- that a miss costs bloat rather than
exposure -- is gone, because it contradicted this issue's own framing, in which
the cost of a miss is unreviewed work in an image layer.

The second half of this change is the consequence that ships broken silently.
Excluding .git means `git describe` cannot run in any build stage, and it fails
quietly there rather than erroring: `-X main.Version=` comes out empty, the
binary reports no version, and the build still exits 0. The Go template in
REPO_POLICIES.md had `ARG VERSION=dev` and never said where VERSION came from,
which is precisely the gap a reader fills in with `git describe` inside the
build. It now says: computed on the host, threaded in with `--build-arg
VERSION=...`, shown as a complete command rather than as two rules each
documenting half of one. script/docker and script/cibuild do it, with the same
discipline the epoch already has -- assignment on its own line, because a
failing command substitution inside an argument does not trip `set -e`, plus a
non-empty fallback so a build from an export with no .git reports `unknown`
rather than an empty string that reads as a successful version.

That fallback is applied in exactly one place, and that place can actually
execute. `git describe ... || true` leaves the value empty when it fails, and
the `[ -n "$version" ]` line is what substitutes `unknown`. Folding the fallback
into the substitution as `|| echo unknown` would have left the guard unreachable
-- harmless in itself, but a guard that cannot fire is indistinguishable from
one that works to every repo that copies it, and canonical text should not carry
a check that is decorative. Measured firing in both sh and dash: not a git
repository -> unknown, repository with no commits yet -> unknown, this
repository -> the describe output. The checklist now carries the guard line as
well, so the vendored guidance and the vendored script no longer disagree.

The scripts pass VERSION unconditionally rather than growing a per-repo variant.
This repo's Dockerfile declares no `ARG VERSION`, and BuildKit was measured
accepting the unconsumed arg silently -- no warning, no cache effect, confirmed
by the paired runs below in which the bootstrap layer still caches. The
alternative, leaving it to each repo, reintroduces the trap: a repo that needs a
version and finds no VERSION in its scripts writes `git describe` into the
Dockerfile, which is the failure being closed.

The same correction reaches the two Go documents that carry the GOLDFLAGS
pattern, since a `$(shell git describe)` evaluated inside a build stage is
exactly this empty version. Both are now `?=`, and their comments say precisely
when that matters: where a build stage compiles by invoking make, `ARG VERSION`
puts the value in the environment and `?=` defers to it, whereas the canonical
Go template compiles with `go build` directly and uses the Makefile on the host
only -- still `?=`, so that a repo which later moves its build behind make does
not silently start shipping an empty version. Leaving them as `:=`
would have left the corpus telling a reader one thing in the policy and the
opposite in the styleguide.

Both repo checklists gain the entries too. They are what an agent reads while
writing these files, so they are where the wrong shape actually gets written:
the .gitignore item is the one an existing repo never re-fetches, and it now
names `.claude/` explicitly along with the warning not to prefix it.

Verification, by enumerating a probe image rather than by reading the patterns.
Standalone minimal Dockerfile held outside the context, `--no-cache` scoped to
that one image, no prune of any kind. Before: all five planted scratch files in
the image, 42 files total. After: zero, 37 files total, with README.md,
script/check, prompts/NEW_REPO_CHECKLIST.md and a planted probe_src/app.md all
still present as positive controls, so the exclusion is a real exclusion and not
a COPY that stopped copying. Transferred context fell from 161.86kB to 68.82kB,
recorded as corroboration only: BuildKit reports a delta, not a total, and an
earlier run in this repo transferred 2.18kB while shipping 43 files.

The CHECK_EPOCH verification was re-run under the changed context, because the
context moved underneath the earlier measurement. Two consecutive script/cibuild
runs on an unchanged tree: run 1 in 17.07s, run 2 in 5.73s, both executing the
check layer with a distinct epoch and real prettier output from both lint and
fmt-check. The `RUN script/bootstrap` layer is CACHED in run 2, which is the
validity control -- it proves no concurrent prune landed between the runs and
that no --no-cache path was taken, so the check layer executing is the bust
working rather than a cold cache.

Planted files were removed afterwards and their absence confirmed against the
filesystem with `find`, not against `git status`, which cannot see them once
.gitignore covers the directory -- the same blind spot that made the earlier
secret exposure invisible.
2026-08-09 17:13:10 +00:00
clawbot
51c394552e Bust the Docker check-layer cache with a per-invocation CHECK_EPOCH (closes #26)
All checks were successful
check / check (push) Successful in 7s
script/cibuild was a plain `docker build .`, and the Dockerfile does
`COPY . .` followed by `RUN make check`. Docker invalidates a COPY layer
only when the copied content changes, so on an unchanged tree the check
layer was served from cache, the suite never ran, and the build still
exited 0. Measured here: run 1 took 18.5s and ran the suite; run 2 on a
byte-identical tree took 0.286s with `RUN make check` CACHED.

script/cibuild and script/docker now assign a per-invocation nonce on its
own line and pass it as --build-arg CHECK_EPOCH. The Dockerfile declares
ARG CHECK_EPOCH, guards it with `[ -n "$CHECK_EPOCH" ] || exit 1`, and
expands it into the check command. Post-fix, two consecutive runs both
execute make check (17.4s / 8.1s) with `RUN script/bootstrap` still
CACHED, so dependency layers are untouched and the build ceiling is not
at risk.

The guard is what makes a bare `docker build .` — the command
REPO_POLICIES named verbatim — fail closed rather than reuse the empty
and therefore stable cache key; verified failing in 0.455s. Holding the
epoch constant restores the false green (run 2 fully CACHED), which pins
the varying value as the operative mechanism rather than a coincidence.

Because the guard references $CHECK_EPOCH it is itself value-keyed:
BuildKit renders the epoch into that layer's description and re-runs the
layer when the value changes. Each stage therefore has two independent
invalidation points, the guard and the expansion, and the guard always
precedes the check RUN. Both are kept and the prose now records this;
the expansion remains defence in depth and is what puts the epoch in the
build log.

The false guarantee was org-canonical text in more than one document, so
it is corrected everywhere it appeared rather than only where the issue
first found it. REPO_POLICIES.md carried it in two places, and its Go
multistage template had check steps in two stages; ARG is stage-scoped,
so both stages get the treatment or the fleet inherits the half-fixed
shape. CODE_STYLEGUIDE_GO.md restated the guarantee for the bare command
this change makes fail closed. NEW_REPO_CHECKLIST.md specified the
pre-fix script/cibuild verbatim, so every new repo would have been born
with the false green, and EXISTING_REPO_CHECKLIST.md ended on a
`docker build` acceptance item that the guard makes unsatisfiable by
design — an agent working that checklist would have been led to delete
the guard to tick the last box. Both checklists' Dockerfile criteria were
also satisfiable by a Dockerfile whose check layers are still frozen, and
now require the ARG and guard in every check-running stage.

REPO_POLICIES.md's own Dockerfile criterion carried that same incomplete
form; it is tightened by cross-reference to the CHECK_EPOCH rule rather
than by duplicating the canonical block. The Go template's Key points
gain a caveat that the cache-bust turns the `COPY --from=lint` no-op into
a content-cache hit, so a repo using a file-dependency trick for stage
ordering must re-prove that ordering on a warm cache after adopting it.
That was re-proved in another repo in the org which uses the trick with a
marker file, where the ordering held; the caveat states explicitly that
it was not verified here, this repo being single-stage with no lint stage
to order against.
2026-08-09 15:15:03 +00:00
3d8d1c0600 Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims, policy update (#23)
All checks were successful
check / check (push) Successful in 5s
Reviewed-on: #23
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-06 23:53:05 +02:00