--- title: Repository Policies last_modified: 2026-08-19 --- This document covers repository structure, tooling, and workflow standards. Code style conventions are in separate documents: - [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) (general, bash, Docker) - [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) - [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) - [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) - [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) --- - Cross-project documentation (such as this file) must include `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync with the authoritative source as policies evolve. - **ALL external references must be pinned by cryptographic hash.** This includes Docker base images, Go modules, npm packages, GitHub Actions, and anything else fetched from a remote source. Version tags (`@v4`, `@latest`, `:3.21`, etc.) are server-mutable and therefore remote code execution vulnerabilities. The ONLY acceptable way to reference an external dependency is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm integrity hash in lockfile, GitHub Actions `@`). No exceptions. This also means never `curl | bash` to install tools like pyenv, nvm, rustup, etc. Instead, download a specific release archive from GitHub, verify its hash (hardcoded in the Dockerfile or script), and only then install. Unverified install scripts are arbitrary remote code execution. This is the single most important rule in this document. Double-check every external reference in every file before committing. There are zero exceptions to this rule. - Every repo with software must have a root `Makefile` with these targets: `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. - Repos follow the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) pattern: the implementation of each Makefile target lives in an executable script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, `script/docker`), and the Makefile targets are thin shims that call them. The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in minimal containers (e.g. alpine images have no bash); locate the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From the standard's canonical set we use `bootstrap`, `setup` (make the repo ready for development after a fresh clone: runs `bootstrap`, then `install-precommit`, plus any repo-specific initialization), `test`, and `cibuild`. `script/bootstrap` installs all dependencies idempotently and assumes nothing is present: base tools come from nix, apt, brew, or apk (detected in that order; apt runs noninteractive). For node it uses the installed node if present; otherwise it installs a PINNED node version via nvm, first installing nvm itself if missing — from a hash-verified GitHub release archive (never `curl | sh`), with bash installed as an explicit prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and `version` is computed on the host because `.git` is not in the build context (see the git-describe rule below); the Gitea workflow calls it. Four further scripts are our own extensions to the standard: `script/check` runs `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, and it calls `script/check`; `script/install-precommit` installs the git pre-commit hook (the `make hooks` target shims to it); and `script/projectname` (literally that filename) simply outputs the project's name. Scripts that need the name call `script/projectname` — e.g. `script/docker` assembles its image tag from it — so those scripts stay byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in `script/precommit`, not in the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) instead of invoking the underlying tools directly. The Makefile is the single source of truth for how these operations are run. - The Makefile is authoritative documentation for how the repo is used. Beyond the required targets above, it should have targets for every common operation: running a local development server (`make run`, `make dev`), re-initializing or migrating the database (`make db-reset`, `make migrate`), building artifacts (`make build`), generating code, seeding data, or anything else a developer would do regularly. If someone checks out the repo and types `make`, they should see every meaningful operation available. A new contributor should be able to understand the entire development workflow by reading the Makefile. - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` as a build step so the build fails if the branch is not green — the one exception being `Dockerfile.lint`, which runs `make lint` alone because that is its entire purpose — which requires `ARG CHECK_EPOCH` and its guard in every stage containing a check-running `RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies this criterion while its check layers are served from cache, so the build cannot fail on a branch that is not green. **Every Dockerfile must also set `ENV LINT_IN_CONTAINER=1`**, above the checks. `script/lint` builds `Dockerfile.lint` when it is not already in a container; without the marker it would try that from inside a build step, where there is no daemon. See the containerised-lint rule below. For non-server repos, the Dockerfile should bring up a development environment and run `make check`. For server repos, `make check` should run as an early build stage before the final image is assembled. Dockerfiles install development prerequisites by running `script/bootstrap` rather than duplicating installs inline; COPY `script/` and the dependency manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap layer stays cached until dependencies change. - **Every check-running `RUN` must be cache-busted with `CHECK_EPOCH`.** Docker invalidates a `COPY` layer only when the copied content changes, so on an unchanged tree the check layer is served from cache, the suite never runs, and the build still exits 0. A sub-second `docker build` reporting success is a cache hit, not a result. This applies to **every** file that runs checks in a build step — `Dockerfile` and `Dockerfile.lint` alike; a `Dockerfile.lint` without the cache-bust is a lint that never ran, reported as a pass. The canonical form, in **every** stage containing a check-running `RUN`, placed **after** the dependency-install layer so that layer stays cached: ```dockerfile ENV LINT_IN_CONTAINER=1 ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make check ``` `ENV LINT_IN_CONTAINER=1` belongs in every such stage too, and is the line most often missed: without it `make check` reaches `script/lint`, which tries to build `Dockerfile.lint` from inside a build step where there is no daemon. See the containerised-lint rule below. and in both `script/cibuild` and `script/docker`: ```sh epoch="$(date +%s%N)$$" version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build \ --build-arg CHECK_EPOCH="$epoch" \ --build-arg VERSION="$version" \ . ``` `script/lint` needs the same nonce but is **not** this command: it builds a different file with `-f Dockerfile.lint` and passes no version. Copy its form from the containerised-lint rule below, not this block — a `docker build` with no `-f` builds the main image and lints nothing. The `VERSION` lines are there for a different reason, covered by the git-describe rule below; they are shown here so the two rules do not each document half a command. All four `CHECK_EPOCH` elements are load-bearing; none is optional, and each guards a failure mode that otherwise fails green: - `ARG` is stage-scoped, so a single declaration leaves the other check stages frozen while the fix reviews as complete. Declare it in every stage that runs checks, immediately above the first such `RUN`. - Expand the value into the command. This makes the cache miss contractual rather than dependent on BuildKit's handling of an unreferenced `ARG`, and it puts the epoch in the build log. The guard is itself value-keyed, for the same reason: it references `$CHECK_EPOCH`, so BuildKit renders the epoch into that layer's description (rendered as `RUN [ -n "" ] || exit 1`) and re-runs it whenever the value changes. Each stage therefore has two independent invalidation points, and the guard always precedes the check `RUN`. Keep both: the expansion is defence in depth, and it is what makes the epoch visible in the build output. - The `[ -n ... ]` guard is required: an unset `ARG` is empty, and empty is a stable cache key, so without it a bare `docker build .` still produces the false green. Failed steps are never cached, so the guard fails on every such invocation, loudly. A bare `docker build .` failing is by design. - Assign `epoch=` on its own line, never inline in the `--build-arg` argument: a failing command substitution inside an argument does not trip `set -e`, so the inline form silently degrades to an empty constant. The `$$` suffix is required because busybox `date` drops `%N` and exits 0, so on an alpine host the epoch would degrade to second granularity and concurrent invocations would collide. This invalidates the check layers and everything after them while leaving `go mod download` and `script/bootstrap` cached, so it does not push against the five-minute Docker build ceiling. Blanket `--no-cache` is not an acceptable substitute: it also busts the dependency layer, so every run reinstalls dependencies over the network instead of only the first and those after a manifest change. Never reach for `docker builder prune` — the build cache is shared with every other build on the host. - **Every lint run happens in a container.** `script/lint` runs the linter directly when it is already inside one, and otherwise builds `Dockerfile.lint` so that it is. Either way the linter never runs on a developer host, where its answer is not trustworthy: - **Confirmed false green.** golangci-lint keys cached results on file **content, not location**, so a second checkout of the same commit serves its findings. One implementer reported `0 issues` on a branch genuinely red with a `goconst` finding. Own-clones-instead-of-worktrees does not help; two clones are byte-identical exactly as two worktrees were. - **False reds**: findings reported against other checkouts and against worktrees already deleted; in one case 399 issues returned to a clean clone that genuinely lints 0. - **Lock contention indistinguishable from findings.** golangci-lint flocks `$TMPDIR/golangci-lint.lock` (`pkg/commands/run.go`, `acquireFileLock()`), host-global and independent of `GOLANGCI_LINT_CACHE`, 5-second timeout. It prints `parallel golangci-lint is running`, analyzes nothing, exits non-zero. Not fixed by per-cache isolation — measured. - **Version skew**: a host linter differing from the pinned one, with the container surfacing thirteen findings the host missed. A container has its own cache, its own `TMPDIR` and a binary pinned by digest, so none of it is reachable. This supersedes the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper, which existed only to make a host run trustworthy; delete it on adoption. The canonical `script/lint`, whose executable lines are the same in every repo apart from the native lint command: ```sh #!/bin/sh # script/lint: run the linter. Inside a container, run it directly; on a # host, build Dockerfile.lint so it runs in one anyway. # # LINT_IN_CONTAINER is set by this repo's Dockerfiles and is the ONLY # accepted signal. Do not add a /.dockerenv fallback: it is absent inside # BuildKit RUN steps and present on hosts that are themselves containers, # so it both misses and false-positives — and a false positive silently # restores host linting. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" if [ "${LINT_IN_CONTAINER:-}" = "1" ]; then # config verify lives here, not in a Dockerfile, so every path # that lints inherits it — the lint stage of the main image as # well as Dockerfile.lint. Duplicating it into each Dockerfile # is how one of them silently loses it. golangci-lint config verify --config .golangci.yml exec golangci-lint run --config .golangci.yml ./... fi # Own line, and `$$` because busybox `date` drops %N silently. # Without a fresh nonce the lint layer is cached and this exits 0 # having linted nothing. epoch="$(date +%s%N)$$" docker build \ --build-arg CHECK_EPOCH="$epoch" \ -f Dockerfile.lint \ . } main "$@" ``` and `Dockerfile.lint`, the standalone path for a developer host: ```dockerfile # Lint-only image, built by script/lint when not already in a container. # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 WORKDIR /src ENV LINT_IN_CONTAINER=1 COPY go.mod go.sum ./ RUN go mod download COPY . . # ARG after the dependency layer so only the lint re-runs. ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "lint epoch: ${CHECK_EPOCH}" && make lint ``` Load-bearing properties: - **Detection rests on `LINT_IN_CONTAINER=1` and nothing else.** Every Dockerfile in the repo sets it; a host does not. The asymmetry is the whole design: a **false negative** inside a container tries a nested `docker build`, finds no daemon and fails loudly, while a **false positive** on a host silently lints there — the exact defect this rule exists to kill. So the signal must be one only our own images can produce. `/.dockerenv` is not such a signal and must not be used, even as a fallback: measured, it is **absent** inside BuildKit `RUN` steps and **present** on any host that is itself a container, which is the common case for CI runners and agent sandboxes. It fails in both directions, and one of them is the dangerous one. - **`CHECK_EPOCH`, not `--no-cache`.** `docker build -f Dockerfile.lint .` on an unchanged tree returns a sub-second cached success having linted nothing. The `ARG` goes **after** the dependency layer so only the lint re-runs; `--no-cache` would also reinstall dependencies on every lint. - **Non-Go repos get the same pattern around their own linter** — `eslint`, `ruff`, `prettier`, `shellcheck`. Only the base image and the native lint command change. - **Keep `golangci-lint config verify`, put it in `script/lint`, and it costs no network.** It goes in the native branch, not in a Dockerfile, so the lint stage of the main image inherits it along with `Dockerfile.lint`; putting it in one Dockerfile leaves the other path unverified. The two commands catch disjoint classes, measured under the pinned v2.12.2: a bogus top-level key and a bogus key under `linters.settings.lll` both pass `golangci-lint run` with **exit 0 and `0 issues`** while `config verify` exits 3 and names them; an invalid value type fails both; an unknown linter name fails `run` and passes `config verify`. So `run` alone silently ignores an unknown key — the mode where a threshold reads as configured and is not applied. It needs no network: 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. Re-run that control when bumping the pin. - **A failed `script/lint` that names no finding is not a lint result.** On the host path `docker build` exits 1 both for findings and for a build that never got there (daemon down, image unpullable, disk full). BuildKit names the failing step; read it, fix the environment, re-run. Do not record a verdict from a run that did not lint. **Scope: this rule is about linters, and a formatter is not one.** `script/fmt` writes your working tree, so it can only run on the host, and `script/fmt-check` is its read-only twin. In a repo whose formatter **is** its linter (prettier over markdown; this repo), `script/bootstrap` therefore installs the linter on the host as an ordinary dependency and `script/fmt-check` runs it there. That is accepted: the version is pinned in `package.json` and installed into the repo's own `node_modules`, so there is no shared content-keyed cache, no host-global lock and nothing to skew against. What is forbidden is taking a **lint verdict** from it — `script/lint` stays the only source of one. A repo auditing itself will see those hits and should leave them; anything else the grep finds is a real second path to the linter and goes. **What a consuming repo does to adopt this**, in order: 1. Add `Dockerfile.lint`. 2. Replace `script/lint` with the form above, with its own native lint command. 3. Add `ENV LINT_IN_CONTAINER=1` to **every** stage of every Dockerfile that runs checks — the lint stage and the build stage both. 4. Delete any golangci-lint install from `script/bootstrap`, with its version and ref variables and its call site. No lint verdict comes from the host any more, so it can only reintroduce version skew. A JS repo's `yarn install` stays. 5. Delete the per-checkout lint state: `GOLANGCI_LINT_CACHE` and `TMPDIR` exports, `--allow-serial-runners`, the retry/VOID wrapper, and `.lint-cache/` from both `.gitignore` and `.dockerignore`. 6. Verify by running `make lint` twice on an unchanged tree: the lint layer must be `DONE` both times, never `CACHED`. Then plant a violation, confirm it fails naming the finding, revert. A bare `docker build -f Dockerfile.lint .` must fail on the guard. `script/check`, `script/cibuild`, `script/docker` and the `Dockerfile` are unchanged by this: `make check` still runs inside the image, and `script/lint` there takes the native path. - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go repos use a multistage build where linting runs in an independent stage based on the `golangci/golangci-lint` image (pinned by hash), so lint failures surface in seconds rather than after a full compile. The build stage declares an explicit dependency on it via `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit — which runs stages in parallel by default — to finish linting first. The canonical Go repo `Dockerfile`: ```dockerfile # Lint stage — fast feedback on formatting and lint issues # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src ENV LINT_IN_CONTAINER=1 COPY go.mod go.sum ./ RUN go mod download COPY . . ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN make lint # Build stage # golang:1.x-alpine, YYYY-MM-DD FROM golang@sha256:... AS builder WORKDIR /src ENV LINT_IN_CONTAINER=1 # Force BuildKit to run the lint stage before proceeding COPY --from=lint /src/go.sum /dev/null COPY go.mod go.sum ./ RUN go mod download COPY . . ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make test # VERSION comes from the host via --build-arg; see the git-describe rule # below. Never run `git describe` here: .dockerignore excludes .git, so # it yields an empty version without failing the build. ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /app ./cmd/app/ # Runtime stage FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - The lint stage uses the `golangci/golangci-lint` image directly (it has both Go and the linter), so nothing needs installing. `make lint` there runs `script/lint`, which sees `LINT_IN_CONTAINER=1` and invokes `golangci-lint` natively instead of building `Dockerfile.lint`. Without that `ENV` the stage would attempt a nested build and fail. - `COPY --from=lint /src/go.sum /dev/null` is a no-op copy that exists only to create the stage dependency; without it a lint failure might not fail the overall build. - **Re-prove that ordering on a warm cache after adopting `CHECK_EPOCH`.** The cache-bust turns the no-op `COPY` into a content-cache hit, so an ordering guarantee established cold does not automatically carry over. It was re-proved in another org repo using the same trick and held, but that result does not transfer by assumption — re-check it warm. - If the project uses `//go:embed` referencing build artifacts, the lint stage must create placeholders so the directives resolve: `RUN mkdir -p web/dist && touch web/dist/index.html`. - If linting needs CGO or system libraries (e.g. `vips-dev`), `apk add` them in the lint stage. - Tests run in the build stage, not the lint stage: they may need compiled artifacts or heavier dependencies. - `ARG CHECK_EPOCH` appears in **both** stages, because `ARG` is stage-scoped: declaring it only in the lint stage leaves `make test` frozen at its last cached result. In each stage the guard sits immediately below the `ARG` and the value is expanded into the first check `RUN`. Later `RUN`s in the same stage need no expansion; their parent layer is already busted. - `ARG VERSION=dev` is declared in the build stage and supplied by `script/docker` and `script/cibuild`. **No stage may call `git describe`**: `.dockerignore` excludes `.git`, so it yields an empty version without failing. See the git-describe rule further down. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that runs `script/cibuild` (which runs `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) on push. The Dockerfile runs `make check`, so a successful build implies all checks pass — but that implication holds **only** because of the `CHECK_EPOCH` cache-bust described above. Without it, an unchanged tree serves the check layer from cache and the build reports a green it never earned. A bare `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through `script/cibuild` or `script/docker`. Never accept a pass as evidence without confirming it ran: a sub-second wall time, or `CACHED` on the check layer, means nothing was executed. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with two exceptions: four-space indents (except Go), and `proseWrap: always` for Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, HTML, CSS) should also have `.prettierrc` and `.prettierignore`. - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local testing is not possible in the repo, `script/precommit` may skip `script/test` and run only `script/lint` and `script/fmt-check`. The hook is installed by `script/install-precommit`; the Makefile must provide a `make hooks` target that shims to it. - All repos with software must have tests that run via the platform-standard test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful tests exist yet, add the most minimal test possible — e.g. importing the module under test to verify it compiles/parses. There is no excuse for `make test` to be a no-op. - `make test` must complete in under 60 seconds. That is the hard cap, and a suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately sits above the hard cap so that it catches a genuinely hung test rather than a merely slow one. - **`make test` should use the conditional verbose rerun pattern.** Run tests without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to show full output. This keeps CI logs and `docker build` output clean on success (just package/suite summaries) while providing full diagnostic detail on failure (every test case, every assertion). The general shell pattern: ```makefile test: @ || \ { echo "--- Rerunning with -v for details ---"; \ ; exit 1; } ``` Go example: ```makefile test: @go test -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -timeout 90s -race -v ./...; exit 1; } ``` Python example: ```makefile test: @python -m pytest || \ { echo "--- Rerunning with -v for details ---"; \ python -m pytest -v; exit 1; } ``` The `exit 1` ensures the target always fails after a rerun — the first run already proved the tests are broken, so the build must not pass even if a flaky test happens to succeed on the second attempt. The rerun exists solely for diagnostic output. - Docker builds must complete in under 5 minutes. - `make check` must not modify any files in the repo. Tests may use temporary directories. - `main` must always pass `make check`, no exceptions. - Never commit secrets. `.env` files, credentials, API keys, and private keys must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`, which holds one worktree — an entire additional checkout of the repo — per in-flight agent), language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up a new repo. These patterns are written to `.gitignore`'s own semantics, in which an unanchored pattern already matches at every depth. They are not a `.dockerignore` and must not be transplanted into one unmodified — see the next rule. - **`.dockerignore` does not use `.gitignore` semantics, and copying patterns across unmodified leaves secrets in the build context.** Docker matches with `moby/patternmatcher`: Go `filepath.Match` semantics plus a `**` extension, compiled to a regexp — plain `filepath.Match` has no `**` at all. So `*` does not cross `/`, and a pattern without a leading `**/` is anchored at the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` therefore excludes only the copies at the repository root; `config/.env` and `certs/server.key` still reach the context and can land in an image layer. That file is more dangerous than a short one with no secret patterns at all, because it reads as solved and stops anyone looking. Give every depth-independent pattern the `**/` prefix — `**/node_modules`, `**/.DS_Store`, and the secret patterns in the canonical file, which are additionally case-folded per the rule below — and leave only genuinely root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory `.claude`, and the repo's own host-built binary. The inverse move is equally wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other. Fetch the standard `.dockerignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend it with the repo's own host-built artifacts — a host `make build` that leaves a compiled binary in the repo root puts that binary in the build context, where `.gitignore` hides it from every git-based check. Write that binary anchored, `/myapp` and never `**/myapp`: the prefixed form also matches `cmd/myapp/` and deletes the package directory from the context. - **In-repo agent scratch belongs in both files, written to each file's own semantics.** `.claude/` holds one worktree per in-flight agent — an entire additional checkout of the repo — so with `COPY . .` the build context inflates by a multiple of the repo, and another session's unreviewed, sometimes uncommitted work can be copied into an image layer. The directory is also created and destroyed constantly, so it invalidates `COPY . .` for reasons that have nothing to do with the repo's own content. In `.gitignore` the entry is `.claude/`, unanchored, which already matches at every depth. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the directory occurs exactly once **where agents run at the repo root**, and the prefixed form would also match any nested directory of that name and delete it from the build. It is not case-folded the way the secret patterns are, because tooling creates it in exactly one spelling, so a folded pattern would add no coverage. **Known gap that comes with the anchored form.** The directory is created in the agent's working directory, so the "exactly once, at the root" premise is a property of how agents are run and not of the tooling. Where agents run in subdirectories — a monorepo with a per-service agent is the ordinary case — `services/api/.claude/` is **not** excluded by the canonical entry and still reaches the build context and the image, which is the exposure the entry exists to close. A repo in that shape adds its own anchored entries (`/services/api/.claude`), or `**/.claude` once it has confirmed no legitimately named nested directory would be caught. This is stated in the canonical `.dockerignore` itself, since that file is what consuming repos receive. - **`.dockerignore` matching is case-sensitive, so cover capitalisation with character classes rather than by doubling patterns.** `**/*.key` does not match `certs/SERVER.KEY`, which is reachable on the case-insensitive filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though case were handled — the same manufactured confidence as the root-anchored form. The matcher supports character ranges, so one line covers every spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret name, not only to extensions: the extensionless SSH keys and `.envrc` need it for the same reason, since on the very filesystems that make `SERVER.KEY` reachable, direnv reads `.ENVRC` and ssh reads `ID_RSA`. Note that `*` matches the empty string, so `**/*.[eE][nN][vV]` already covers a bare `.ENV` and no separate literal `.env` entry is needed. - **A pattern that also catches something the build needs is re-included with a negation, not deleted.** The canonical `**/*.[eE][nN][vV]` excludes a committed env template such as `example.env`; a repo whose build genuinely reads one adds `!docs/example.env` after the pattern. Deleting the pattern instead reopens the exposure for every other file it covers. - **Verify `.dockerignore` by enumerating the image, not by reading the patterns.** Plant files at the root _and_ at least two directories deep, build a probe image that does `COPY . .`, and list what actually landed (`docker run --rm --entrypoint find IMAGE /app`). Reading the patterns and agreeing they look right is exactly what lets the root-only form through. The `transferring context` size is not a substitute: a nested secret is a few bytes, and BuildKit transfers only the delta from the previous build, so the reported size describes the transfer and not the contents of the image. - **Excluding `.git` means `git describe` cannot run inside any build stage, and it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes `.git` is present; in a build stage there is no repository, so `git describe` writes nothing to stdout and the `-X main.Version=` value comes out **empty** rather than erroring. The binary then reports no version at all and the build still exits 0. Compute the version **on the host** and thread it in as a build arg. `script/docker` and `script/cibuild` do this, byte-identically across repos: ```sh # Assign on its own line: a failing command substitution inside an # argument does not trip `set -e`, so the inline form degrades to an # empty constant — the same silent-empty failure this rule is about. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build \ --build-arg CHECK_EPOCH="$epoch" \ --build-arg VERSION="$version" \ . ``` `--always` makes an untagged repo yield the abbreviated commit hash instead of failing. `|| true` keeps a failing `git describe` from tripping `set -e` and leaves the value empty, so the `[ -n "$version" ]` line is the single place the fallback is applied — and it is a **live** check, not defence in depth: it fires on a build from an export with no `.git`, and on a repository with no commits yet. Do not fold the fallback into the substitution as `|| echo unknown`; that makes the guard unreachable, and a guard that cannot fire is indistinguishable from one that works to everyone who copies it. The result is non-empty by construction either way, which is the point: an empty version reads as a successful one, while `unknown` is visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared there and not inherited, because `ARG` is stage-scoped exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile declares no such `ARG` is silently ignored by BuildKit and costs nothing, which is why the scripts stay byte-identical rather than growing a per-repo variant. One consequence for CI: the standard checkout action clones shallow and fetches no tags, so `git describe --tags` there falls back to a bare commit hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on its checkout step; a repo that does not embed a version needs no change. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) should generate these at build time. Notable exception: Go protobuf generated files (`.pb.go`) ARE committed because repos need to work with `go get`, which downloads code but does not execute code generation. - Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never force-push to `main`. - Make all changes on a feature branch. You can do whatever you want on a feature branch. - `.golangci.yml` is standardized. The vendored copy in a consuming repo must _NEVER_ be modified by an agent: fetch it from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it byte-identical, so that no repo can quietly loosen its own linting. Linter configuration changes are made to the canonical copy in the `prompts` repo and reach consuming repos by re-vendoring; an agent may open a PR against canonical, which only the user merges. One list is exempt from byte-identity, because it cannot be written once for every repo: the `deny` list of the `test-support` depguard rule, where a repo names its own test-support packages by full import path. A repo adds entries there and changes nothing else, and a re-vendor carries its entries forward. The canonical golangci-lint version is v2.12.2 (released 2026-05-06), pinned as the image digest in `Dockerfile.lint` (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, which reports `golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the only pin: golangci-lint is not installed on the host by any repo. Bumping the version means changing that one digest. - **`script/bootstrap` must not install golangci-lint.** This supersedes the pinned host install that used to be canonical here. `script/lint` never runs it on the host — it either builds `Dockerfile.lint` or is already in a container that ships the binary — so a host install has no caller, and its only remaining effect is to put a second, independently-versioned linter where somebody eventually runs it by hand and believes the result. Delete the block, its version and ref variables, and its call site. This is not a ban on host dependency installs generally. A JS or docs repo's `script/bootstrap` runs `yarn install`, which brings its linter along with every other dependency; that is unavoidable and fine. The rule is about a **dedicated** linter install, and about where a verdict may come from. **The version-enforcement principle it established still applies to any other tool a repo pins and installs on the host**, and it is the part worth keeping, because each of its four properties guards a failure that otherwise reports success: - **Compare the installed version against the pin, never test presence.** A `if missing ; then install; fi` guard tests `PATH` presence and never version, so on any already-provisioned machine the pin is inert and a version bump is a silent no-op. Compare the **whole** version token, exactly: a parser that stops at the first `-` reports `2.12.2` for a host running `2.12.2-rc1` and skips the install — the original defect, reintroduced through the comparison meant to fix it. - **After installing, re-resolve the binary the way callers resolve it** — through `PATH`, not the directory the installer wrote to — and assert the reported version is the pin. An installer that writes to `GOBIN` while a different binary shadows it earlier in `PATH` genuinely succeeds and changes nothing any caller sees, which is worse than no fix: it converts a known-stale tool into one everyone believes is pinned. Run `hash -r` first so the shell does not answer from its own lookup cache, and when the assertion fails, name the path `command -v` found, the version it reports, and the directory the install wrote to. Diagnose from the resolved path rather than asserting a cause: only a path **outside** the install directory is shadowing. - **A mis-parse must fall through to reinstall, never to a false match.** Absent binary, non-zero exit, empty output and unrecognised output should all yield an empty string, which compares unequal to the pin. The failure direction is always a redundant install, never a skipped one. - **Call it, and say so on success.** A function defined and never called is a silent no-op indistinguishable from success: exit 0, nothing installed, no output. Both success branches must print a line naming the version. Verifying such logic requires a negative control in an environment where a shadowing binary exists earlier in `PATH` than the install target — without it the control passes against the naive compare-then-install form too and proves nothing — plus a mis-parse control that feeds unparseable `--version` output and confirms a reinstall. Run those controls against the block as a consuming repo would adopt it: pasted into a `script/bootstrap`-shaped file that is then executed, never by sourcing it and invoking the function yourself. Driving the function directly tests something the artifact does not do, and it is exactly how a missing call site passes every control while the adopted snippet does nothing. Keep it POSIX sh: no bashisms, no arrays, no `[[`, no `grep -P`. - **Superseded: the per-checkout `GOLANGCI_LINT_CACHE`/`TMPDIR` wrapper for `script/lint`.** It existed only to make a host lint run trustworthy, and the containerised-lint rule above removes the host run. Delete the wrapper, the `--allow-serial-runners` flag, and `.lint-cache/` from both `.gitignore` and `.dockerignore`. Two of its conclusions outlive it: **`GOCACHE` does not need isolating** (measured — content-addressed, no foreign paths in its entries, no global lock), and **verifying lint plumbing requires paired controls** run against the artifact as a consuming repo would adopt it, since a control that passes against the broken form proves nothing. - **Interim rule for reading a lint result produced on the host, in a repo that has not yet adopted the containerised lint above.** A lint run is **VOID** unless both hold: - the output contains no `parallel golangci-lint is running`, and - no reported file path begins with `../`, and none is an absolute path outside the tree the run was launched from. Do not record a verdict from a void run, and do not "fix" findings in files the change does not touch — chasing phantom findings across untouched files puts unrelated edits into a reviewed diff, which is more expensive than the wasted rework. The `../` clause is the one that actually bites, and it is why a filter keyed on `/tmp` or on absolute prefixes is not enough: golangci-lint reports paths relative to its own resolved root rather than yours, and three of the org's reported sightings had relative paths and would have passed such a filter. Both clauses are needed and neither alone is sufficient — one reproduction exited non-zero with the lock error and no foreign paths at all, and another reported 34 well-formed findings, every one of them against another checkout. **State the limit of these tests rather than treating them as a guarantee.** They catch contamination that **names** foreign files. They cannot catch contamination that **suppresses** findings through a poisoned entry for colliding content, which has no wall-clock tell either — **no evidence of that mode has been observed, and nobody should go chasing it**; the point is the reach of the tests, not a claim that the mode exists. They are a filter for the loud mode, not a proof of soundness — which is the whole argument for containerising the linter instead of documenting a discipline that depends on every agent remembering to apply it. Adopt the rule above and this one stops applying to the repo entirely. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). - Use `yarn`, not `npm`. - Write all dates as YYYY-MM-DD (ISO 8601). - Simple projects should be configured with environment variables. - Dockerized web services listen on port 8080 by default, overridable with `PORT`. - **HTTP/web services must be hardened for production internet exposure before tagging 1.0.** This means full compliance with security best practices including, without limitation, all of the following: - **Security headers** on every response: - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year and `includeSubDomains`. - `Content-Security-Policy` (CSP) with a restrictive default policy (`default-src 'self'` as a baseline, tightened per-resource as needed). Never use `unsafe-inline` or `unsafe-eval` unless unavoidable, and document the reason. - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). Prefer the `frame-ancestors` CSP directive as the primary control. - `X-Content-Type-Options: nosniff`. - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). - `Permissions-Policy` restricting access to browser features the application does not use (camera, microphone, geolocation, etc.). - **Request and response limits:** - Maximum request body size enforced on all endpoints (e.g. Go `http.MaxBytesReader`). Choose a sane default per-route; never accept unbounded input. - Maximum response body size where applicable (e.g. paginated APIs). - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend against slowloris attacks. - `WriteTimeout` on the `http.Server`. - `IdleTimeout` on the `http.Server`. - Per-handler execution time limits via `context.WithTimeout` or chi/stdlib `middleware.Timeout`. - **Authentication and session security:** - Rate limiting on password-based authentication endpoints. API keys are high-entropy and not susceptible to brute force, so they are exempt. - CSRF tokens on all state-mutating HTML forms. API endpoints authenticated via `Authorization` header (Bearer token, API key) are exempt because the browser does not attach these automatically. - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, MD5, or SHA. - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or `Strict`) attributes. - **Reverse proxy awareness:** - True client IP detection when behind a reverse proxy (`X-Forwarded-For`, `X-Real-IP`). The application must accept forwarded headers only from a configured set of trusted proxy addresses — never trust `X-Forwarded-For` unconditionally. - **CORS:** - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to an explicit allowlist of known origins. Wildcard (`*`) is acceptable only for public, unauthenticated read-only APIs. - **Error handling:** - Internal errors must never leak stack traces, SQL queries, file paths, or other implementation details to the client. Return generic error messages in production; detailed errors only when `DEBUG` is enabled. - **TLS:** - Services never terminate TLS directly. They are always deployed behind a TLS-terminating reverse proxy. The service itself listens on plain HTTP. However, HSTS headers and `Secure` cookie flags must still be set by the application so that the browser enforces HTTPS end-to-end. This list is non-exhaustive. Apply defense-in-depth: if a standard security hardening measure exists for HTTP services and is not listed here, it is still expected. When in doubt, harden. - `README.md` is the primary documentation. Required sections: - **Description**: First line must include the project name, purpose, category (web server, SPA, CLI tool, etc.), license, and author. Example: "µPaaS is an MIT-licensed Go web application by @sneak that receives git-frontend webhooks and deploys applications via Docker in realtime." - **Getting Started**: Copy-pasteable install/usage code block. - **Entrypoints**: Opens by stating that the repo adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard (with that link), then documents each provided `script/` entrypoint and its purpose. - **Rationale**: Why does this exist? - **Design**: How is the program structured? - **TODO**: Update meticulously, even between commits. When planning, put the todo list in the README so a new agent can pick up where the last one left off. - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a `LICENSE` file in the repo root and a License section in the README. - **Author**: [@sneak](https://sneak.berlin). - First commit of a new repo should contain only `README.md`. - Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before committing. - Use SemVer. - Database migrations live in `internal/db/migrations/` and must be embedded in the binary. - `000_migration.sql` — contains ONLY the creation of the migrations tracking table itself. Nothing else. - `001_schema.sql` — the full application schema. - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There is no installed base to migrate. Edit `001_schema.sql` directly. - **Post-1.0.0:** add new numbered migration files for each schema change. Never edit existing migrations after release. - All repos should have an `.editorconfig` enforcing the project's indentation settings. - Avoid putting files in the repo root unless necessary. Root should contain only project-level config files (`README.md`, `Makefile`, `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and language-specific config). Everything else goes in a subdirectory. Canonical subdirectory names: - `bin/` — executable scripts and tools - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose body is a single call into `internal/` or `pkg/`, no project logic in `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) - `internal/` — Go internal packages - `internal/db/migrations/` — database migrations - `pkg/` — Go library packages - `share/` — systemd units, data files - `static/` — static assets (images, fonts, etc.) - `web/` — web frontend source - When setting up a new repo, files from the `prompts` repo may be used as templates. Fetch them from `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. - New repos must contain at minimum: - `README.md`, `.git`, `.gitignore`, `.editorconfig` - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `Makefile` - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, `install-precommit`) - `Dockerfile`, `.dockerignore` - `.gitea/workflows/check.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml`