Author SHA1 Message Date
sneak dcf75f6c72 Close three gaps between the containerised-lint rule and its first adopters
check / check (push) Successful in 22s
The rule landed in 12e8db8 is right; these are the three places where the
canonical text and the repos implementing it can diverge without either
side looking wrong.

1. `.dockerignore` excluding the agent scratch directory is now stated as a
   correctness precondition of containerised linting rather than a
   context-size measure. `Dockerfile.lint` lints whatever `COPY . .` copies,
   and language toolchains discover files by walking the tree instead of
   reading `.gitignore`, so a nested worktree in the context puts the
   foreign-tree false reds back inside the container — in the convincing
   form, where the findings are real but belong to another checkout.
   sneak/quak measured the same discovery mechanism taking a test count
   from 210 to 1050.

2. The cache-bust build arg is fixed at `CHECK_EPOCH` in `Dockerfile.lint`
   as well as in `Dockerfile`. A per-file name is invisible to the grep that
   proves every build is busted, which makes a renamed guard and a missing
   guard read identically. sneak/quak's lint file currently names it
   `LINT_EPOCH`.

3. The formatting check must run in exactly one of the two images, and
   either placement is allowed. Splitting lint out of the `Dockerfile` is
   precisely the moment `fmt-check` gets dropped from both, and running the
   formatter beside the linters is the better shape wherever it is the same
   pinned dependency — it takes the last host toolchain off the checked
   path for the reason the linter came off it.

Both checklists carry the matching items, since a repo that satisfies the
policy prose but not the checklist is the drift this is meant to stop.

Refs #40
2026-08-10 12:59:44 +00:00
sneak 12e8db8b0e Run every lint in a container via Dockerfile.lint (closes #40)
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
clawbot 0620416869 Give golangci-lint per-checkout cache and lock state (closes #30)
check / check (push) Successful in 7s
A golangci-lint result on a host running many concurrent workers does not
reliably belong to the tree that asked for it. Two independent mechanisms,
which have repeatedly been mistaken for one:

The result cache is keyed on file content, not location, so two checkouts
of the same commit hold byte-identical files, share cache entries, and one
tree's findings are served for the other under the other tree's path. This
produced a confirmed false green as well as the loud false reds. Moving
workers from shared worktrees to their own clones does not address it —
two clones collide exactly as two worktrees did — and removes only the
foreign-path artefact that made the defect noticeable.

The concurrency lock is $TMPDIR/golangci-lint.lock (pkg/commands/run.go,
acquireFileLock), host-global and independent of GOLANGCI_LINT_CACHE, with
a five-second acquire timeout, so it fails when the host is busiest. A
private cache directory does not isolate it. Setting only the cache closes
the contamination half and leaves runs failing red on a condition that is
not a result at all.

REPO_POLICIES.md now carries the canonical Go script/lint: both variables
scoped into a .lint-cache/ directory inside the checkout, above any
container-versus-host branch so every path reaching the linter gets them;
--allow-serial-runners, which keeps the mutual-exclusion guard and queues
rather than aborting, for the same-checkout overlap TMPDIR scoping cannot
cover, with --allow-parallel-runners rejected because it deletes the
guard; and a bounded retry that treats the lock error as VOID rather than
as findings, exiting 75 on exhaustion so it is neither a pass nor a
failure. Detection is on the stderr stream and never on exit status:
findings go to stdout, so a finding quoting the lock message in source
cannot be retried away, and the exit status is not a stable discriminator
anyway. The interim void rule is recorded with the ../ clause that the
original filter missed, and with its limit stated — it catches
contamination that names foreign files, not contamination that suppresses
findings. Both checklists gained the corresponding items, since a half-fix
that sets only the cache reads as complete.

GOCACHE was measured rather than assumed and does not need isolating: with
the two variables scoped per checkout and GOCACHE shared at the host
default, each checkout reported its own paths.

Verified with the snippet extracted from the committed document and
executed as a consuming repo would adopt it, each control paired against
the pre-fix form: contamination reproduced on the pre-fix script and
absent on the adopted one; a stub linter colliding twice then clearing,
with the retry engaging and succeeding; exhaustion exiting 75 with a VOID
message; a genuine finding whose text quotes the lock message reported as
findings with no retry; and a real held lock failing the pre-fix script
with exit 3 while the adopted script, inheriting the same environment,
completed in one second.
2026-08-09 18:51:22 +00:00
sneak 3a218497b8 Keep in-repo agent scratch out of the build context and out of git (closes #27)
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
sneak fd78aeb003 Keep secrets out of the Docker build context at every depth (closes #29)
check / check (push) Successful in 21s
The canonical .dockerignore was three lines -- .git, node_modules, .DS_Store
-- while the canonical Dockerfile does `COPY . .`, so a developer's local
.env, *.pem or *.key was shipped into the build context and could land in an
image layer. Nothing surfaced it because .gitignore covers those patterns, so
the files are invisible to every git-based check.

The obvious repair, copying .gitignore's secret patterns across, is worse than
the gap it closes. .dockerignore does not use .gitignore semantics: Docker
matches with moby/patternmatcher, which is filepath.Match semantics plus a `**`
extension compiled to a regexp. `*` does not cross `/`, and a pattern without
a leading `**/` is anchored at the build-context root. A file listing .env,
*.pem and *.key therefore reads as solved, reviews as solved, and protects
only the repository root, while config/.env and certs/server.key still ship.
The three-line file at least invited scrutiny; the transplanted form
manufactures confidence and stops anyone looking.

So every depth-independent pattern here carries the `**/` prefix and only
genuinely root-anchored entries stay unprefixed. `**/node_modules` fixes a
defect the three-line file had today for any nested node_modules,
independently of the secret exposure.

Coverage is not limited to the three patterns the issue names, because the
enumeration found more shapes reaching the image. `**/*.env` covers the
prod.env / local.env convention, which the .env and .env.* spellings miss
entirely; `.envrc` is a secrets file by direnv convention; *.p12 and *.pfx are
bundles carrying private keys; and id_rsa, id_dsa, id_ecdsa and id_ed25519 are
the extensionless SSH keys that were covered before only when someone happened
to append a .key suffix.

Matching is case-sensitive, so `**/*.key` does not match certs/SERVER.KEY,
which is reachable on the case-insensitive filesystems most laptops use.
Doubling each pattern with an ALL-CAPS twin is not the fix: measured against
the planted set it still ships certs/Server.Key and certs/Ca.Pem while reading
as though case were handled, which is this issue's failure mode restated in a
new place. The matcher supports character ranges, so every secret name is
written that way -- `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
`**/.[eE][nN][vV][rR][cC]`, `**/[iI][dD]_[rR][sS][aA]` and the rest. The
extensionless SSH keys and .envrc are folded for the same reason the
extensions are: on the very filesystems that make SERVER.KEY reachable, direnv
reads .ENVRC and ssh reads ID_RSA, so exempting them would have contradicted
the rule that justifies the folding. Since `*` matches the empty string,
`**/*.[eE][nN][vV]` already covers a bare .ENV and no separate literal .env
entry is needed; the literal one is gone rather than left to imply that case
is unhandled there.

Deliberately not covered: bare `key` and `pem` filenames, which no tool
produces and which collide with legitimate paths (a `**/key` pattern would
delete an internal/key/ package directory from the context); `**/id_*`, which
would match ordinary source such as id_generator.go and ID_MAP.go; and *.crt
and *.cer, which are public certificates rather than secrets and are sometimes
a legitimate build input. Each of those is planted as a positive control and
verified present in the image after the change.

The one predictable false positive is a committed env template: `**/*.env`
excludes example.env. The remedy travels with the file rather than living in a
review thread -- the header comment and the policy both say to re-include it
with a negation, `!docs/example.env`, and never to delete the pattern, which
would reopen the exposure for everything else it covers.

The OS and editor patterns are included on their own merits rather than by
mirroring .gitignore. None of them is ever a build input, and editor state in
particular churns under a developer's hands, so each one is a source of
`COPY . .` invalidation carrying no information about the source tree. Now
that the checks are keyed on CHECK_EPOCH rather than on accidental context
churn, there is no reason left to keep churn in the context. Language build
artifacts are deliberately absent: they are per-repo, and the file's header
comment tells consuming repos to add their own host-built binaries, which is
the case that actually bites -- a host `make build` drops a multi-megabyte
artifact into the context where .gitignore hides it from every git-based
check. That comment gives the anchored form explicitly, `/myapp` rather than
`**/myapp`, because the prefixed spelling also matches cmd/myapp/ and deletes
the package directory. The header comment is the only part of this guidance a
consuming repo actually receives, since it is vendored with the file.

.gitignore is untouched. Its semantics are the inverse: an unanchored pattern
already matches at any depth, so `**/`-prefixing it produces a file that is
wrong in a way that looks careful. That asymmetry is why "derive one from the
other" was the wrong instruction, and it is now written down in
REPO_POLICIES.md in both directions, together with the case-sensitivity rule,
the negation remedy, and the requirement to verify by enumerating the image
rather than by reading the patterns. Every consuming repo inherits
.dockerignore by copy, so the trap has to live where the next person looks,
not only be fixed once here. Both repo checklists gain the same requirements.

Verified by planting 130 secret files -- twenty-six name shapes, including
every capitalisation of .env, .env.*, prod.env, .envrc, id_rsa, id_ed25519,
ca.pem, server.key, bundle.p12 and bundle.pfx, at five depths from the context
root to a/b/c -- alongside nine positive controls, then building a standalone
probe image doing `COPY . .` and listing what actually landed inside it. The
patterns as first written leaked 35 of the 130: every .ENVRC, .Envrc, ID_RSA,
Id_Rsa, ID_ED25519, .ENV.PRODUCTION and .Env.Local, at all five depths. A
lowercase-only control leaks 80, so the probe is not vacuous. After this
change: zero, with all nine controls still present, including
internal/ID_MAP.go and certs/CA.CRT.

Transferred-context size is recorded but load-bearing on nothing: an earlier
run reported 2.18kB transferred while 43 files, five of them secrets, were in
the image. BuildKit transfers only the delta from the previous build, so the
number describes the transfer and not the contents.

Planted files were removed and their absence confirmed against the filesystem
rather than against `git status`, which could not have seen them.

`make docker` re-run after the change: the check layer executed rather than
being served from cache, so the CHECK_EPOCH verification still holds under the
altered build context.
2026-08-09 16:42:00 +00:00
clawbot d173e69f85 Make the pinned golangci-lint actually reach the host (closes #28)
check / check (push) Successful in 9s
REPO_POLICIES.md now carries the canonical script/bootstrap snippet for Go
repos alongside the .golangci.yml bullet, where the pinned linter version
already lives.

The guard it replaces, `if missing golangci-lint; then go install ...; fi`,
tests PATH presence and never version, so on any already-provisioned machine
the pin is inert and a version bump is a no-op. The Dockerfile installs
unconditionally into a clean image, so CI and local then disagree about what
the linter is: a local `make check` green while `make docker` rejects the same
commit, and a container run surfacing findings the host run cannot see.

Comparing versions alone is not enough. `go install` writes to GOBIN (or
GOPATH/bin) while callers resolve through PATH, so a shadowing binary earlier
in PATH lets the install succeed and change nothing a caller ever sees, while
bootstrap prints success. The canonical form therefore compares the installed
version against the pin, re-resolves through PATH after installing and asserts
the pin, and treats any unparseable --version output as a mismatch so the
failure direction is a redundant install rather than a skipped one.

The comparison is exact over the whole version token. A parser that stops at
the first `-` reports 2.12.2 for a host running 2.12.2-rc1, which compares
equal to a 2.12.2 pin and skips the install -- the original defect, reachable
through the comparison meant to close it, and not caught by requiring the pin
to be tagged, since a pre-release is tagged too.

When the assert fails the diagnosis is derived from the resolved path rather
than asserted: a path outside the install directory is shadowing and the
operator is told to remove it or reorder PATH; a path inside it is not, and
saying so would send them after a fault that does not exist; no resolution at
all means the install directory is simply absent from PATH. A trailing slash
on GOBIN is normalised away, since it would otherwise make the inside-the-
directory test miss and misreport shadowing.

The snippet ends with a call site, and both success paths print a confirmation
naming the version. Two definitions with no invocation are a silent no-op with
exactly the shape this change exists to close, and a success path that prints
nothing is byte-identical to that no-op: same exit status, same empty output.

The version helper ends in `|| true` so a --version that exits non-zero cannot
kill the script through `set -e` under `set -o pipefail` before the diagnostic
is printed, which the styleguide's bash form would otherwise do.

The policy text states each of those as a requirement rather than leaving them
implicit in the code, and records why the commit-pinned `go install` ref
satisfies the hash-pinning rule: a commit hash is not a mutable tag, and the
checksum database verifies the fetch, with no repo go.sum consulted, since
`go install pkg@version` ignores the go.mod in the current directory or any
parent. Whether a go.mod tool dependency should replace that is an open
decision and is linked rather than settled here. The two version strings must
be kept in sync and must match exactly what --version prints; tagged pins are
preferred because the expected string is then derivable from the ref, rather
than because the comparison cannot handle a pseudo-version.

Verification runs the controls against the block as a consuming repo would
adopt it, pasted into a script/bootstrap-shaped file and executed, rather than
sourcing it and calling the function directly.

The node and yarn handling described earlier in the document is untouched.
2026-08-09 16:01:36 +00:00
clawbot 51c394552e Bust the Docker check-layer cache with a per-invocation CHECK_EPOCH (closes #26)
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
18 changed files with 1226 additions and 785 deletions
+64 -27
View File
@@ -1,39 +1,75 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with # Docker matches this file with moby/patternmatcher: Go filepath.Match
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # semantics plus a `**` extension, compiled to a regexp. Plain
# `/` and an unprefixed pattern is anchored at the context root. Every # filepath.Match has no `**` at all. What follows from that: `*` does not
# depth-independent pattern therefore needs `**/`, or `config/.env` and # cross `/`, and a pattern without a leading `**/` is anchored at the
# `certs/server.key` still ship while this file reads as solved. Only # build-context root. Every depth-independent pattern therefore needs the
# genuinely root-anchored entries go unprefixed. Never transplant these # `**/` prefix — without it `config/.env` and `certs/server.key` still
# into .gitignore, where `**/` is wrong. # ship while the file reads as solved.
# #
# Matching is case-sensitive, so secrets use character ranges rather # Root-anchored entries are for paths that occur exactly once, at the
# than an ALL-CAPS twin, which would still miss `Server.Key`. # context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the
# context. In-repo agent scratch is the other case, for the same reason
# — with the caveat recorded at that entry: anchoring is exact only
# where agents run at the repo root, and a repo where they do not must
# add its own entries.
# #
# Extend with this repo's own host-built artifacts, written anchored: # Matching is case-sensitive, so `**/*.key` does not match
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # `certs/SERVER.KEY`, which is reachable on the case-insensitive
# deletes the package directory from the context. # filesystems most laptops use. Adding an ALL-CAPS twin per pattern is
# not the fix: it still misses `Server.Key` while reading as though case
# were handled. Character ranges cover every spelling in one line, so
# every secret name below is written that way — including the
# extensionless SSH keys and `.envrc`, because on those same
# case-insensitive filesystems direnv reads `.ENVRC` and ssh reads
# `ID_RSA`.
#
# `**/*.[eE][nN][vV]` also excludes a committed env template such as
# `example.env`. If the build genuinely needs one, re-include it with a
# negation after the pattern: `!docs/example.env`.
#
# Extend this file with the repo's own host-built artifacts (compiled
# binaries, test binaries, coverage output); those are per-repo and
# belong here because a host build otherwise drops them into the
# context.
# .git is sent without its config. Without a VERSION build argument the # Repository metadata: exactly one, at the context root. Excluding it
# stage that compiles runs `git describe --tags --always` on .git, which # means `git describe` cannot run in any build stage, and it fails
# does not need .git/config; that file can hold a credential, such as a # quietly there rather than erroring, so a version embedded that way
# password in a remote URL or the token the CI checkout step stores there. # comes out empty. Compute the version on the host and pass it in with
.git/config # `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
.git
# Agent scratch: one full checkout of the repo per in-flight agent. # In-repo agent scratch: a directory holding a full additional checkout
# Anchored because it occurs once where agents run at the repo root. # of the repo for each in-flight agent. Anchored because it occurs
# KNOWN GAP: a repo running agents in subdirectories still ships # exactly once *where agents run at the repo root*, which is the
# `services/api/.claude/` and must add its own anchored entry. # convention this file assumes; the `**/` form would also match any
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
.claude .claude
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers both the bare `.env` name (`*` matches
# convention. Re-include a committed template with a negation if the # the empty string) and the `prod.env` convention.
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]
**/.[eE][nN][vV].* **/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC] **/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates # Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. # (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM] **/*.[pP][eE][mM]
**/*.[kK][eE][yY] **/*.[kK][eE][yY]
**/*.[pP]12 **/*.[pP]12
@@ -50,7 +86,8 @@
**/.DS_Store **/.DS_Store
**/Thumbs.db **/Thumbs.db
# Editor state: never a build input, and it churns COPY. # Editor state. Never a build input, and it churns under a developer's
# hands, so it invalidates COPY for reasons unrelated to the source.
**/*.swp **/*.swp
**/*.swo **/*.swo
**/*~ **/*~
+2 -67
View File
@@ -10,21 +10,14 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -35,64 +28,6 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+26 -48
View File
@@ -1,59 +1,37 @@
# Lint phase. The linter is invoked directly rather than through `make
# lint` or `script/lint`, which are themselves a docker build and would
# recurse into a daemon that does not exist in a build step.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Test phase, same shape and for the same reason.
#
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN echo "No tests defined."
# Development environment, and the last stage: a plain `docker build .`
# names no target and so builds this one. Nothing is wanted from the two
# phases above; the copies are what make BuildKit build them first, so
# this image cannot be produced unless lint and test passed. A stage
# appended after this one would drop all three out of a plain build.
#
# node 22-alpine, 2026-02-22 # node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
WORKDIR /app WORKDIR /app
COPY --from=lint /app/package.json /dev/null # script/bootstrap installs all prerequisites (make via apk here; node
COPY --from=test /app/package.json /dev/null # and yarn are already in the base image, so those steps are skipped).
# Dependency manifests are copied first so the bootstrap layer is
# script/bootstrap installs all prerequisites. Manifests are copied # cached until they change.
# first so that layer stays cached until dependencies change.
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
# Nothing here is compiled and a LABEL cannot run git, so the version is # CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# the VERSION build argument that script/docker and script/cibuild pass; # script/docker. Without it an unchanged tree serves this layer from
# a plain `docker build .` leaves it empty. # cache and the build reports a green it never ran. ARG is stage-scoped,
ARG VERSION # so it must be redeclared in every stage that runs checks. The guard
LABEL org.opencontainers.image.version="${VERSION}" # makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# 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
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
+19 -16
View File
@@ -116,23 +116,26 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `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` — `docker build --no-cache --target test -t prompts-test .`, - `script/test` — run the test suite (no tests defined here)
building the `test` phase of the `Dockerfile` (no tests defined here) - `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The
- `script/lint` — `docker build --no-cache --target lint -t prompts-lint .`, linter runs in a container, always: it is never installed on the host and
building the `lint` phase, which runs prettier over the markdown files never invoked there. Linting happens as a build step, so a successful build is
- `script/fmt` — format all markdown files with prettier (writes; native, not in a clean lint, and the same per-invocation `CHECK_EPOCH` nonce used elsewhere
a container) is what stops Docker serving that lint from cache on an unchanged tree
- `script/fmt-check` — check formatting (read-only; native) - `script/fmt` — format all markdown files with prettier (writes)
- `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); builds no image of its own extension). Needs a docker daemon, since `script/lint` is a container build
- `script/docker` — - `script/docker` — build the Docker image, tagged via `script/projectname`
`docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag (byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
coming from `script/projectname` (byte-identical across repos) `script/cibuild`
- `script/cibuild` — cd to the repo root, run `script/bootstrap`, run - `script/cibuild` — cd to the repo root, run `script/lint` first, then assign
`script/check`, compute `version` from `git describe`, then `epoch="$(date +%s%N)$$"` and
`docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI `docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs). Two
runs; it bootstraps because CI checks out and runs this alone while container builds: the lint image, then the main image, which runs
`script/fmt-check` is native) `script/test` and `script/fmt-check` but deliberately not `make check` — that
would nest a docker build inside a build step. A bare `docker build .` 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
+134 -57
View File
@@ -21,63 +21,140 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-10-03: Moved the canonical golangci-lint to v2.14.0, built with go1.27, - 2026-08-10: Closed three gaps the containerised-lint rule left between the
because v2.12.2 refuses to lint a module whose `go` directive is 1.27 (issue canonical text and the first repos to implement it. `.dockerignore` excluding
65). Releases from v2.13.0 deprecate `exhaustruct` in favour of the agent scratch directory is now stated as a correctness precondition of
`exhaustruct_v5`, which `default: all` switches on, so the canonical that rule rather than a context-size measure: the lint image lints whatever
`.golangci.yml` now disables `exhaustruct_v5` beside `exhaustruct`. v2.12.2 `COPY . .` copies, and toolchains discover files by walking the tree instead
rejects that file, so `REPO_POLICIES.md` and both repo checklists now say a of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds
repo sets the lint phase digest and re-vendors `.golangci.yml` in one commit. back inside the container — `sneak/quak` measured the same discovery mechanism
- 2026-10-02: The image version now comes from git inside the build (issues 69 taking a test count from 210 to 1050. The cache-bust arg is fixed at
and 71), superseding the 2026-09-08 entry that excluded `.git`. The canonical `CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is
`.dockerignore` sends `.git` but keeps out `.git/config`, which can hold a invisible to the grep that proves every build is busted, making a renamed
credential. The Dockerfile example in `REPO_POLICIES.md` installs `git`, takes guard indistinguishable from a missing one. And the formatting check is now
the `VERSION` build argument when one is given and otherwise required to run in exactly one of the two images, with either placement
`git describe --tags --always`, and fails when `.git` exists but the version allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check`
is empty, `dev` or `unknown`; a plain `docker build .` with no build arguments gets dropped from both, and running the formatter beside the linters is the
must succeed. This repo's `script/docker` and `script/cibuild` still pass better shape where it is the same pinned dependency.
`--build-arg VERSION`, since its own `Dockerfile` compiles nothing. - 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
- 2026-09-08: Moved linting and testing into Docker as phases of the main made this repo do it rather than merely document it. `script/lint` is now
`Dockerfile`, per the owner ruling on issue 40. `script/lint` and `docker build -f Dockerfile.lint .` and nothing else; the linter is never
`script/test` build one phase each by name with `--no-cache` — the same answer installed on the host and never invoked there, so a run cannot inherit another
issue 26 got, so no separate cache-busting mechanism survives — and the final checkout's content-keyed result cache, the host-global
stage copies a harmless file from both, so the image cannot be built unless `$TMPDIR/golangci-lint.lock`, or a host toolchain that differs from the pinned
they pass. This also closes issue 30: a container has its own result cache and one — the three mechanisms behind a confirmed false green, a string of
its own lock, so a lint verdict can no longer belong to another checkout. No findings reported against other agents' checkouts, and a container that saw
separate lint Dockerfile, and no `golangci-lint config verify` step. thirteen findings the host missed. Linting runs as a build step, so a
`script/check` runs the gates and nothing else, and `script/cibuild` successful build is a clean lint, which also works where the docker daemon is
bootstraps first, since it is all CI runs and `script/fmt-check` is native. remote and bind mounts are impossible. The recursion this creates is resolved
- 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out by direction rather than by detection: the main `Dockerfile` runs the
of version control: `.claude/` is one full checkout of the repo per in-flight individual non-lint checks instead of `make check`, and `script/cibuild` runs
agent, and under `COPY . .` all of it was reaching the image. Also closed the `script/lint` first, so no build ever nests a build. `Dockerfile.lint` carries
consequence of excluding `.git` — `git describe` yields an empty version the same `CHECK_EPOCH` guard as the main image, with the `ARG` below the
inside a build stage without failing, so `script/docker` and `script/cibuild` dependency layer so only the lint steps re-run — blanket `--no-cache` was
now compute the version on the host and pass `--build-arg VERSION`. rejected because it makes every lint reinstall its dependencies over the
- 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a network. Two canonical forms were superseded rather than left standing beside
local `.env`, `*.pem` or `*.key` was reaching the build context under the new one, since consuming repos read this document literally: the
`COPY . .`, invisible to every git-based check. The patterns are now written `script/bootstrap` golangci-lint install (nothing runs a host linter now, so
to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every it can only reintroduce skew; the version-enforcement principle stays
depth, case-folded with character ranges — and `REPO_POLICIES.md` requires documented for other pinned host tools) and the per-checkout
verifying by enumerating the image rather than by reading the file. cache/lock/`.lint-cache` wrapper (its whole subject was making a host run
- 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host. trustworthy). The Go multistage lint stage goes with them: it ran `make lint`,
`REPO_POLICIES.md` now requires comparing the installed version against the which is now a docker build. `golangci-lint config verify` was kept on
pin rather than testing `PATH` presence, and re-resolving the binary through measurement, not preference — a bogus config key passes `golangci-lint run`
`PATH` after installing, so a version bump cannot be a silent no-op and a with `0 issues` and fails `config verify`, and every case reproduced
shadowed install cannot report success. byte-identically under `docker run --network none`, so the schema is embedded
- 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild` in the pinned binary and the line costs no network. Verified with two
and `script/docker` now build with `--no-cache`, so the Dockerfile's check consecutive runs on an unchanged tree both executing the linter, a planted
layers cannot be served from cache on an unchanged tree, and the text claiming violation caught and reverted, the bare-build guard firing, and the main image
a bare `docker build .` proves the checks ran is corrected in building without attempting a nested build.
`REPO_POLICIES.md`, both checklists and the Go styleguide. - 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.
- 2026-09-03: Added `-count=1` to both `go test` invocations in the canonical Go REPO_POLICIES.md now carries the canonical Go `script/lint`, which gives the
`make test` example in `REPO_POLICIES.md`, so the target cannot report a linter per-checkout `GOLANGCI_LINT_CACHE` and per-checkout `TMPDIR`. The two
cached pass it did not earn, and documented that Go's test-result cache is a are separate defects and the second is the one that gets dropped: the result
second, independent cache stacked below the Docker layer cache. cache is keyed on file content rather than location, so checkouts holding
- 2026-08-31: Migrated the canonical `.golangci.yml` from the deprecated identical files serve each other's findings under the other's path, while the
`gomodguard` to `gomodguard_v2`: the old linter is disabled by name (which is concurrency lock is `$TMPDIR/golangci-lint.lock` — host-global, independent of
what silences the deprecation warning), the successor is named explicitly in the cache, and unaffected by isolating it. Moving workers from worktrees to
`linters.enable`, and it carries a `blocked` module list drawn only from their own clones does not help either half; it only removes the foreign-path
decisions already recorded in the Go package defaults. artefact that made the defect visible. The lock error is retried rather than
surfaced, because it is not a result: it exits non-zero exactly as findings
do, and reporting it as findings sends a correct branch back for rework.
Detection is on the stderr stream and never on exit status, so a finding
quoting the lock message in source cannot be retried away, and exhaustion
exits 75 with a VOID message rather than passing or failing quietly.
`--allow-serial-runners` (which keeps the guard and queues) covers the
same-checkout overlap that `TMPDIR` scoping cannot; `--allow-parallel-runners`
is rejected outright. The stdout and stderr capture files are per invocation
rather than per checkout, because serialising the linter does not serialise
the shell's redirections: two runs in one checkout — the overlap the flag
exists to support — would otherwise truncate and read each other's output,
which is the same defect one layer above where it was fixed. Both checklists
gained the corresponding items, since a half-fix that sets only the cache
reads as complete. `GOCACHE` was measured and does not need isolating.
Verified with the snippet extracted from the committed document and executed
as a consuming repo would adopt it, against paired controls: contamination
reproduced on the pre-fix form and absent on the adopted one, retry engaged,
exhaustion loud, a genuine finding still reported, and a held host lock
failing the pre-fix script while leaving the adopted one untouched.
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own
`moby/patternmatcher` semantics — `**/`-prefixed so they hold at every depth,
which also fixes nested `node_modules` — rather than transplanted from
`.gitignore`, whose unprefixed form protects only the repository root while
reading as solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the
`prod.env` convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys,
every one of them case-folded with character ranges because matching is
case-sensitive and an ALL-CAPS twin per pattern still misses `Server.Key`.
`REPO_POLICIES.md` and both repo checklists now state that asymmetry and
require verification by enumerating the image rather than by reading the
patterns. Verified with a probe image before, against three naive forms
(unprefixed, lowercase-only, ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old
`if missing` guard tested PATH presence only, so pins were inert on any
provisioned machine and CI silently disagreed with local) and then re-resolves
the binary through `PATH` and fails loudly, naming the shadowing path, when
the install did not take effect — the failure mode the naive
compare-then-install fix leaves behind while reporting success.
- 2026-08-09: Fixed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now pass a per-invocation `CHECK_EPOCH` nonce, and the
`Dockerfile` (plus the Go multistage template in REPO_POLICIES.md, in both its
lint and builder stages) declares `ARG CHECK_EPOCH` with a guard that makes a
bare `docker build .` fail closed. Corrected the org-canonical text that
asserted a successful build implies all checks pass, across every document
carrying it: `REPO_POLICIES.md`, both repo checklists (which still told agents
to write the pre-fix `script/cibuild` and ended on an acceptance item the
guard makes unsatisfiable), and the Go styleguide.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema - 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually
+46 -32
View File
@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-10-02 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.
@@ -24,8 +24,7 @@ last_modified: 2026-10-02
1. Embed the git commit hash into the binary and include it in startup logs and 1. Embed the git commit hash into the binary and include it in startup logs and
in health check output. This is to make it easier to correlate running in health check output. This is to make it easier to correlate running
instances with their code. Do not include build time or build user, as these instances with their code. Do not include build time or build user, as these
will make the build nondeterministic. The architecture is not passed in at will make the build nondeterministic.
build time; a program that reports it reads `runtime.GOARCH` at run time.
Example relevant Makefile sections: Example relevant Makefile sections:
@@ -36,25 +35,38 @@ last_modified: 2026-10-02
import ( import (
"fmt" "fmt"
"runtime"
) )
var Version string var (
Version string
Buildarch string
)
func main() { func main() {
fmt.Printf("Version: %s\n", Version) fmt.Printf("Version: %s\n", Version)
fmt.Printf("Arch: %s\n", runtime.GOARCH) fmt.Printf("Buildarch: %s\n", Buildarch)
} }
``` ```
```make ```make
# ?= rather than := so that a `VERSION` build argument takes precedence: # ?= rather than := because this `$(shell git describe ...)` is only
# where a build stage invokes make, `ARG VERSION` puts it in the # correct on the host. `.dockerignore` excludes `.git`, so evaluated
# environment and `?=` defers to it. Otherwise `git describe` runs, in a # inside a build stage it expands to the empty string without failing
# build stage on the `.git` the build context carries. # and the binary reports no version at all. The version is computed on
VERSION ?= $(shell git describe --tags --always) # the host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the
# value in the environment and `?=` defers to it. The canonical Go
# template in REPO_POLICIES.md instead runs `go build` directly with
# `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is
# a host-only path — but it is still `?=`, because a repo that later
# moves the build behind make must not silently start shipping an empty
# version. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
# osx can't statically link apparently?! # osx can't statically link apparently?!
ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_S),Darwin)
@@ -99,19 +111,26 @@ last_modified: 2026-10-02
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`. Run it with `make lint`, never by invoking the binary: the `golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`:
linter runs as a phase of the `Dockerfile` and is not installed on the host the linter runs in a container, always, and is never installed on the host.
by any repo. Invoked directly on a shared host it reads a result cache keyed A `golangci-lint` invoked directly on a shared host reads a result cache
on file content rather than location, and a host-global lock, so its answer keyed on file content rather than location and a host-global lock, so its
may belong to another checkout entirely. answer may belong to another checkout entirely.
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. It carries the lint and test phases, and the final stage depends on the non-lint checks; linting lives in `Dockerfile.lint` and is run by
both, so a build makes sure the code is in an able-to-be-compiled state, `script/cibuild` before the main build, because `script/lint` is itself a
linted, and its tests run. Go through `script/cibuild` or `script/docker` `docker build` and cannot run inside one. So `script/cibuild` is what
rather than a bare `docker build .`: they pass `--no-cache`, without which guarantees the code is in an able-to-be-compiled state, linted, and tested —
an unchanged tree serves the gate layers from cache and the build reports a **a successful `docker build .` on its own does not, because it never
green it never ran. lints.** That guarantee holds only because each build passes a
per-invocation `CHECK_EPOCH` build arg that busts its check layers out of
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)
for the canonical form.
1. Every repo must have a `Makefile`. See 1. Every repo must have a `Makefile`. 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)
@@ -132,15 +151,10 @@ last_modified: 2026-10-02
1. Keep the `main()` function as small as possible. 1. Keep the `main()` function as small as possible.
1. Keep the `main` package as small as possible. Each `cmd/<name>/` directory 1. Keep the `main` package as small as possible. Move as much code as is
contains a single `main.go` whose body is one call into library code (for feasible to a library package, even if it's an internal one. `main` is just
example `os.Exit(cli.Main())` calling `internal/cli`). All CLI logic — flag an entrypoint to your code, not a place for implementations. Exception:
parsing, subcommand dispatch, argument handling, output formatting — lives single-file scripts.
in `internal/` or `pkg/`, not in `cmd/`. `main` is just an entrypoint to
your code, not a place for implementations. Exception: single-file scripts.
1. No project logic outside `internal/` or `pkg/`. Anything in `cmd/` is a thin
entrypoint only.
1. HTTP HandleFuncs should be returned from methods or functions that need to 1. HTTP HandleFuncs should be returned from methods or functions that need to
handle HTTP requests. Don't use methods or your top level functions as handle HTTP requests. Don't use methods or your top level functions as
+90 -79
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-10-03 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
@@ -28,61 +28,80 @@ with your task.
artifacts, secrets) — fetch from artifacts, secrets) — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing.
An existing repo usually has a hand-written one that is never re-fetched, An existing repo usually has a hand-written one that is never re-fetched,
so check the entries rather than the file's presence. so check the entries rather than the file's presence: `.claude/` in
particular, unanchored, so agent worktrees cannot be committed by
accident. Do not give it a `**/` prefix — that is a `.dockerignore` form
and is wrong here.
- [ ] `.editorconfig` exists — fetch from - [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist; the Dockerfile carries a `lint` - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
phase and a `test` phase, and the final stage carries a `COPY --from=` of `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`);
a harmless file from each — fetch `.dockerignore` from Dockerfile runs the **non-lint** checks as build steps (`script/test`,
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `script/fmt-check`), and every stage containing a check-running `RUN`
- [ ] Nothing has been appended after the final stage, and the gate phases are declares `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
reachable from it. A stage nothing depends on is built only when guard immediately below it — see the `CHECK_EPOCH` rule in
`--target` names it, so a lost `COPY --from=` edge leaves `docker build .` `REPO_POLICIES.md`. Without them the check layer is served from cache on
passing while the gate never runs. Confirm by planting a violation, not by an unchanged tree and the build reports a green it never ran.
reading the file. - [ ] The `Dockerfile` no longer runs `make check`, and no longer has a `lint`
- [ ] The gate phases invoke their tools directly, never through `make lint` or stage or a `COPY --from=lint ... /dev/null` ordering line. This is the
`script/test` — those are themselves a `docker build` and would recurse item an existing repo most often fails: `script/lint` is now a
inside a build step `docker build`, so both of those nest a docker build inside a build step.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix, Delete the stage; `script/cibuild` running `script/lint` first is what
only genuinely root-anchored entries such as `.claude` are unprefixed, and replaces its fail-fast purpose.
`.gitignore`'s patterns have not been transplanted unmodified — the - [ ] `Dockerfile.lint` exists and `script/lint` builds it — see the
transplanted form leaves `config/.env` and `certs/server.key` in the build containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
context while reading as solved 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`. The
arg is named `CHECK_EPOCH` in this file too — a repo that calls it
`LINT_EPOCH` here is missed by the grep that checks every build is
cache-busted.
- [ ] The formatting check runs in exactly one of the two images — either
`script/fmt-check` in the `Dockerfile` or the formatter beside the linters
in `Dockerfile.lint`, whichever puts it on the pinned toolchain. Neither
image running it is the failure to look for here, since moving lint out of
the `Dockerfile` is exactly when it gets dropped.
- [ ] `.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`. An existing repo is where such a binary is `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
likeliest to already be sitting in the build context, invisible to git. existing repo is where such a binary is likeliest to already be sitting in
- [ ] `.claude/` is in `.gitignore` (unanchored) and `.claude` in the build context, invisible to git.
`.dockerignore` (anchored, no `**/` prefix). Agent worktrees are entire - [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/`
checkouts of the repo, so they inflate the context by a multiple of it and prefix. Agent worktrees are entire checkouts of the repo, so they inflate
can copy another session's unreviewed work into an image layer. If agents the context by a multiple of it and can copy another session's unreviewed
here run anywhere other than the repo root, the anchored entry misses work into an image layer — and `Dockerfile.lint` then lints that checkout
`services/api/.claude/`: add anchored entries for those directories. as though it were this one, because toolchains discover files by walking
- [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into the tree and never read `.gitignore` (`sneak/quak`: 210 discovered tests
the build context. It keeps out `.git/config`, which `git describe` does became 1050). Confirm by enumerating the image, not by reading the file —
not need and which can hold a credential: a password in a remote URL, or `.gitignore` hides these from `git status` too.
the token the CI checkout step stores there. The stage that compiles has - [ ] **Do agents in this repo run anywhere other than the repo root?** The
`git` (the Debian Go image has it; an alpine one needs scratch directory is created in the agent's working directory, so the
`apk add --no-cache git`) and takes the version from the `VERSION` build canonical anchored entry misses `services/api/.claude/` in a monorepo with
argument when one is given, otherwise from `git describe --tags --always`. a per-service agent — it still reaches the build context and the image. An
That gives the tag on a tagged commit; on a later commit, the tag, the existing repo is where such a layout already exists, so check it here
number of commits since it and the short commit (`v1.2.3-4-gabc1234`); and rather than assuming the canonical entry covers you: add anchored entries
the short commit when no tag is reachable. `ARG VERSION` has no default, for the subdirectories that have one (`/services/api/.claude`), or
and the build fails if the context carries `.git` and the version still `**/.claude` once you have confirmed no legitimately named nested
comes out empty, `dev` or `unknown`. A plain `docker build .` with no directory would be caught.
build arguments must succeed; a Dockerfile that refuses an empty build - [ ] If the repo embeds a version in a binary, that version is computed on the
argument drops that refusal and keeps the argument. `script/docker` and host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild` pass the version they compute on the host; it takes `script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes
precedence. A tag-derived version additionally needs `fetch-depth: 0` on `.git`, so it yields an empty version without failing the build. A
the CI checkout step, which clones shallow and fetches no tags by default. tag-derived version additionally needs `fetch-depth: 0` on the CI checkout
step, which clones shallow and fetches no tags by default.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified.
`.dockerignore` anchors an unprefixed pattern at the context root, so the
transplanted form leaves `config/.env` and `certs/server.key` in the build
context while reading as solved — see the `.dockerignore` rule in
`REPO_POLICIES.md`.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference 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`
- [ ] Language-specific config: - [ ] Language-specific config:
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from - [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and, `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`)
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
(fetch from (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and
@@ -103,33 +122,26 @@ 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
- [ ] `script/lint` and `script/test` build their phase by name - [ ] `script/lint` is the canonical container build and nothing else. No host
(`docker build --no-cache --target <phase> -t <name>-<phase> .`), and no linter invocation survives anywhere in the repo — grep for the linter's
host invocation anywhere in the repo can produce a lint verdict — grep for own name in `script/`, the `Makefile` and CI config, not just in
the linter's own name across `script/`, the `Makefile` and CI config, not `script/lint`. An existing repo is where a second path to the linter is
just `script/lint`. A second path is likeliest here: a `make lint-fast`, likeliest to exist: a `make lint-fast`, a container-versus-host branch, or
an older host-versus-container branch, or a CI step calling the binary a CI step that calls the binary directly.
directly. `script/fmt` and `script/fmt-check` are expected hits and stay - [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install
on the host. block, its version and ref variables, and its call site: nothing invokes a
- [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a host linter any more, so all it can still do is put a differently
dangling image behind on every run, on every host and CI runner versioned binary where somebody runs it by hand and believes the result.
- [ ] `script/cibuild` runs `script/bootstrap` before `script/check`, and builds - [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
the image with `--no-cache`. Without the bootstrap the CI run dies in exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`script/fmt-check`, which runs the formatter on the host and finds nothing `.gitignore` and `.dockerignore`. A container has its own cache and its
installed. own lock, so keeping the wrapper leaves two contradictory `script/lint`
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version forms in the fleet.
before invoking `yarn`, as `script/bootstrap`'s own install step does. - [ ] `script/cibuild` runs `script/lint` before the main `docker build`.
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of Without that line CI never lints at all, because the main image
the shell that called it, so a bare `yarn` exits 127 on a runner carrying deliberately does not.
nothing but docker and git.
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its
version variables and its call site. A JS repo's `yarn install` stays; it
brings a linter along with every other dependency, and no verdict is taken
from it.
- [ ] `make check` does not modify any files in the repo - [ ] `make check` does not modify any files in the repo
- [ ] `make test` has a 90-second timeout and completes within the 60-second - [ ] `make test` has a 30-second timeout
hard cap (over 20 seconds is green but must be filed as an improvement
bug)
- [ ] `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
check) check)
- [ ] `make check` passes on current branch - [ ] `make check` passes on current branch
@@ -174,10 +186,9 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but - [ ] `make lint` runs twice on an unchanged tree with the lint layer `DONE`
docker and git, with no node or yarn on `PATH`, which is what CI has, and both times, never `CACHED` and never sub-second
demonstrably executed the checks — a sub-second build, or `CACHED` on a - [ ] `script/cibuild` succeeds and runs both container builds (a bare
gate layer, means nothing ran `docker build .` or `docker build -f Dockerfile.lint .` fails closed by
- [ ] A planted lint violation fails both `make lint` and a plain design, on the `CHECK_EPOCH` guard)
`docker build .`; revert it afterwards
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task
+28 -17
View File
@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-10-02 last_modified: 2026-08-09
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -118,13 +118,15 @@ import (
) )
var ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -824,7 +826,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"arch", runtime.GOARCH, "buildarch", l.params.Globals.Buildarch,
) )
} }
``` ```
@@ -944,20 +946,23 @@ import "go.uber.org/fx"
// Package-level variables (set from main) // Package-level variables (set from main)
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Version: Version, Buildarch: Buildarch,
Version: Version,
} }
return n, nil return n, nil
} }
@@ -968,13 +973,15 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go ```go
// cmd/httpd/main.go // cmd/httpd/main.go
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -984,14 +991,18 @@ func main() {
Use ldflags to inject version information at build time: Use ldflags to inject version information at build time:
```makefile ```makefile
# ?= rather than := so that a `VERSION` build argument takes precedence: # ?= rather than := because this `$(shell git describe ...)` is only correct
# where a build stage invokes make, `ARG VERSION` puts it in the # on the host: `.dockerignore` excludes `.git`, so evaluated inside a build
# environment and `?=` defers to it. Otherwise `git describe` runs, in a # stage it expands to the empty string without failing and the binary reports
# build stage on the `.git` the build context carries. # no version. The version is computed on the host by `script/docker` /
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --tags --always) VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
``` ```
--- ---
+87 -62
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-10-03 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
@@ -62,41 +62,53 @@ Template files can be fetched from:
`cmd/myapp/` and delete the package directory. Do not transplant `cmd/myapp/` and delete the package directory. Do not transplant
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at `.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. The canonical file's `.claude` entry is context while reading as solved. See the `.dockerignore` rule in
anchored for the same reason as a repo-root binary; leave it that way, but `REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for
note that it only covers agents running at the repo root — if this repo the same reason as a repo-root binary; leave it that way, but note it only
will run them in subdirectories, `services/api/.claude/` needs its own covers agents running at the repo root — if this repo will run them in
subdirectories, `services/api/.claude/` is not excluded and needs its own
anchored entry. anchored entry.
- If the image embeds a version in a binary: `.dockerignore` lets `.git` - If the image embeds a version in a binary, the version is computed on the
into the build context. It keeps out `.git/config`, which `git describe` host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is
does not need and which can hold a credential: a password in a remote URL, declared in the stage that compiles, and **no stage calls `git describe`**
or the token the CI checkout step stores there. The stage that compiles — `.dockerignore` excludes `.git`, so it yields an empty version without
has `git` (the Debian Go image has it; an alpine one needs failing the build.
`apk add --no-cache git`) and takes the version from the `VERSION` build - The `Dockerfile` runs the **non-lint** checks as build steps —
argument when one is given, otherwise from `git describe --tags --always`. `script/test` and `script/fmt-check`, never `make check`. `script/lint` is
That gives the tag on a tagged commit; on a later commit, the tag, the a `docker build` of `Dockerfile.lint`, so `make check` here nests a build
number of commits since it and the short commit (`v1.2.3-4-gabc1234`); and inside a build step, where there is no daemon. Put a comment above those
the short commit when no tag is reachable. `ARG VERSION` has no default, `RUN` lines saying so. Every stage containing a check-running `RUN` must
and the build fails if the context carries `.git` and the version still declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
comes out empty, `dev` or `unknown`. A plain `docker build .` with no guard immediately below it — see the `CHECK_EPOCH` rule in
build arguments must succeed; a Dockerfile that refuses an empty build `REPO_POLICIES.md`. Without them the check layer is served from cache on
argument drops that refusal and keeps the argument. an unchanged tree and the build reports a green it never ran.
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking - Server: also builds and runs the application
its tool directly rather than through `make` or `script/`, and the final - Non-server: brings up dev environment and runs those checks
stage carries a `COPY --from=` of a harmless file from each so the image
cannot be built unless both passed. Keep the final stage last: a stage
nothing depends on is built only when `--target` names it.
- Server: the final stage builds and runs the application
- Non-server: the final stage brings up the dev environment
- 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. The arg keeps the name `CHECK_EPOCH` in
this file as well, so one grep covers both builds.
- The formatting check runs in exactly one of the two images: either
`script/fmt-check` in the `Dockerfile`, or the formatter beside the
linters in `Dockerfile.lint` where that is the same pinned dependency.
Never neither, never both.
- `.dockerignore` must exclude the agent scratch directory before this image
is trusted: it lints whatever is in the build context, and toolchains walk
the tree rather than reading `.gitignore`, so an agent worktree that
reaches the context is linted as though it were the repo.
- [ ] 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`
- [ ] Language-specific: - [ ] Language-specific:
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from - [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and, `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`)
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `yarn init`, `yarn add --dev prettier` - [ ] JS: `yarn init`, `yarn add --dev prettier`
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
@@ -115,34 +127,51 @@ are thin shims calling them. Model scripts:
installs installs
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`, - [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — `docker build --no-cache --target test .`, - [ ] `script/test` / `make test` — runs real tests, not a no-op (30-second
tagged; the phase runs real tests, not a no-op (90-second timeout, timeout)
60-second hard cap on wall time) - [ ] `script/lint` / `make lint` — builds `Dockerfile.lint` and nothing else:
- [ ] `script/lint` / `make lint` — `docker build --no-cache --target lint .`, `epoch="$(date +%s%N)$$"` on its own line, then
tagged. No lint verdict may come from a host invocation of the linter. `docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The
- [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a linter is never installed on the host and never invoked there. Copy the
container) canonical script from `REPO_POLICIES.md`; it is byte-identical across
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only; repos. Without the nonce this script exits 0 on an unchanged tree having
native) linted nothing.
- [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `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); `--no-cache`, plus the `script/projectname` (byte-identical across repos); carries the same three
version as a build arg version lines as `script/cibuild` below, and passes
- [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
`script/check`, then - [ ] `script/cibuild` — cd to repo root, run `script/lint` **first** for
`docker build --no-cache --build-arg VERSION="$version" .` (what CI runs). fail-fast feedback, then, each on its own line:
The bootstrap is required: CI checks out and runs this alone, and
`script/fmt-check` runs the formatter on the host. ```sh
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version epoch="$(date +%s%N)$$"
before invoking `yarn`, as `script/bootstrap`'s own install step does. version="$(git describe --tags --always --dirty 2>/dev/null || true)"
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of [ -n "$version" ] || version="unknown"
the shell that called it, so a bare `yarn` exits 127 on a runner carrying docker build \
nothing but docker and git. --build-arg CHECK_EPOCH="$epoch" \
- [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a --build-arg VERSION="$version" \
dangling image behind .
```
(what CI runs). The `script/lint` call is not optional: the main image does
not lint, so without it CI never lints. Both build args are mandatory, and
both assignments must be on their own line: a failing command substitution
inside an argument does not trip `set -e`, so the inline form degrades
silently to an empty constant. The `[ -n "$version" ]` line is a live check
that fires on an export with no `.git` and on a repo with no commits — keep
it, and do not collapse it into `|| echo unknown`, which makes it
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
`script/precommit` `script/precommit`
@@ -153,16 +182,12 @@ 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 in a fresh clone on a host carrying nothing but - [ ] `script/cibuild` succeeds and runs both container builds
docker and git, with no node or yarn on `PATH`, which is what CI has, and - [ ] No secrets in repo
demonstrably executed the checks — a sub-second build, or `CACHED` on a
gate layer, means nothing ran
- [ ] Plant a lint violation and confirm both `make lint` and a plain
`docker build .` fail on it; revert. A plain build that passes proves the
final stage is missing its `COPY --from=` edge to the gate phases.
- [ ] No secrets in repo, and none in the build context: enumerate a probe image
rather than reading `.dockerignore`
- [ ] No mutable image/package references - [ ] No mutable image/package references
- [ ] No unnecessary files in repo root - [ ] No unnecessary files in repo root
- [ ] All dates written as YYYY-MM-DD - [ ] All dates written as YYYY-MM-DD
+613 -291
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,8 +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. test and lint are Docker # extension to scripts-to-rule-them-all. Must not modify any files.
# phases; fmt-check is native, because a formatter writes the working #
# tree. 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)"
+26 -14
View File
@@ -1,10 +1,10 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. Two container builds, in order:
# checks out and runs this and nothing else, and script/fmt-check runs # script/lint (Dockerfile.lint) and then the main image, which runs the
# the formatter on the host, which a pristine checkout cannot do. # non-lint checks. Both only prove anything because each passes its own
# --no-cache for the same reason as script/docker: the gate phases the # fresh CHECK_EPOCH nonce: without it Docker serves the check layers
# final stage depends on are RUN steps, and a cached one is a check that # from cache on an unchanged tree and the build exits 0 without running
# did not run. # anything.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -12,17 +12,29 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" # Lint first, for fail-fast feedback: it is its own container build
"$SCRIPT_DIR/check" # and computes its own CHECK_EPOCH. It runs here rather than inside
# Own line: a failing command substitution inside an argument does # the main image because a docker build cannot run a docker build.
# not trip `set -e`, so the inline form degrades silently to an "$SCRIPT_DIR/lint"
# empty constant. The VERSION build argument takes precedence over # Assign on its own line: a failing command substitution inside an
# the version a build stage derives from the .git in the context. # 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.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . .
} }
main "$@" main "$@"
+19 -8
View File
@@ -1,8 +1,9 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname. The
# --no-cache because the gate phases the final stage depends on are RUN # Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# steps, and a cached one is a check that did not run. # nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,13 +11,23 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Assign on its own line: a failing command substitution inside an
# not trip `set -e`, so the inline form degrades silently to an # argument does not trip `set -e`, which would silently degrade the
# empty constant. The VERSION build argument takes precedence over # nonce to an empty constant. `$$` is required because busybox `date`
# the version a build stage derives from the .git in the context. # drops %N without erroring.
epoch="$(date +%s%N)$$"
# VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+18 -14
View File
@@ -1,23 +1,27 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and # script/lint: run the linter. The linter is never installed on the host
# this builds that phase alone; the linter is never installed or run on # and never invoked there — it runs in a container, one way, everywhere,
# a developer host, where a shared result cache and a host-global lock # so a run cannot inherit another checkout's cache, another process's
# make its answer untrustworthy. # lock, or a host toolchain that differs from the pinned one. Linting
# # happens as a build step (see Dockerfile.lint), so a successful build
# The phase is not the last stage in the file, so it is built only when # is a clean lint, and it works where the docker daemon is remote and
# --target names it. --no-cache because a cached lint layer is a lint # bind mounts are impossible.
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ # Assign on its own line: a failing command substitution inside an
--target lint \ # argument does not trip `set -e`, which would silently degrade the
-t "$("$SCRIPT_DIR/projectname")-lint" . # 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 "$@"
+3 -10
View File
@@ -1,19 +1,12 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite.
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ echo "No tests defined."
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"