Files
prompts/prompts/REPO_POLICIES.md
sneak fd78aeb003
All checks were successful
check / check (push) Successful in 21s
Keep secrets out of the Docker build context at every depth (closes #29)
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

41 KiB

title, last_modified
title last_modified
Repository Policies 2026-08-09

This document covers repository structure, tooling, and workflow standards. Code style conventions are in separate documents:


  • 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 @<commit-sha>). 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 pattern: the implementation of each Makefile target lives in an executable script in script/ (script/bootstrap, script/setup, script/test, script/lint, script/fmt, script/fmt-check, script/check, script/docker), and the Makefile targets are thin shims that call them. The scripts must be POSIX sh (#!/bin/sh, set -eu, no bashisms) so they run in minimal containers (e.g. alpine images have no bash); locate the repo root with $(cd "$(dirname "$0")/.." && pwd -P) and cd there before acting. From the standard's canonical set we use bootstrap, setup (make the repo ready for development after a fresh clone: runs bootstrap, then install-precommit, plus any repo-specific initialization), test, and cibuild. script/bootstrap installs all dependencies idempotently and assumes nothing is present: base tools come from nix, apt, brew, or apk (detected in that order; apt runs noninteractive). For node it uses the installed node if present; otherwise it installs a PINNED node version via nvm, first installing nvm itself if missing — from a hash-verified GitHub release archive (never curl | sh), with bash installed as an explicit prerequisite since nvm requires bash. yarn is then pinned via corepack prepare yarn@<version> --activate. Never install "latest" or "lts"; always exact versions. script/cibuild runs the CI build: it changes to the repo root and runs docker build --build-arg CHECK_EPOCH="$epoch" ., where epoch is a per-invocation nonce (see the CHECK_EPOCH 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/<name>. The README must document the provided scripts in an Entrypoints section (see the README requirements below).

  • Always use Makefile targets (make fmt, make test, make lint, etc.) 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<tab>, 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 — 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. 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 RUN make 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. The canonical form, in every stage containing a check-running RUN:

    ARG CHECK_EPOCH
    RUN [ -n "$CHECK_EPOCH" ] || exit 1
    RUN echo "check epoch: ${CHECK_EPOCH}" && make check
    

    and in both script/cibuild and script/docker:

    epoch="$(date +%s%N)$$"
    docker build --build-arg CHECK_EPOCH="$epoch" .
    

    All four 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 "<epoch>" ] || 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, script/bootstrap, and the pinned toolchain install cached, so it does not push against the five-minute Docker build ceiling. Blanket --no-cache also works but is wasteful and can blow that ceiling.

  • 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). This stage runs make fmt-check and make lint before the full build begins. The build stage then declares an explicit dependency on the lint stage via COPY --from=lint /src/go.sum /dev/null, which forces BuildKit to complete linting before proceeding to compilation and tests. This ensures lint failures surface in seconds rather than minutes, without blocking on dependency download or compilation in the build stage.

    The standard pattern for a Go repo Dockerfile is:

    # 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
    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
    
    # 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
    
    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 includes both Go and the linter), so there is no need to install the linter separately.
    • COPY --from=lint /src/go.sum /dev/null is a no-op file copy that creates a stage dependency. BuildKit runs stages in parallel by default; without this line, the build stage would not wait for lint to finish and 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 this no-op COPY into a content-cache hit, so an ordering guarantee established on a cold cache does not automatically carry over; it has to be re-checked warm. This was re-proved in another repo in the org that uses the same file-dependency trick (there with a marker file in place of go.sum), and the ordering held. It has not been verified in this repo, which is single-stage and has no lint stage to order against. Any repo relying on a file-dependency trick for stage ordering should re-check it warm after adopting the bust rather than assuming this result transfers.
    • If the project uses //go:embed directives that reference build artifacts (e.g. a web frontend compiled in a separate stage), the lint stage must create placeholder files so the embed directives resolve. Example: RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css. The lint stage should not depend on the actual build output — it exists to fail fast.
    • If the project requires CGO or system libraries for linting (e.g. vips-dev), install them in the lint stage with apk add.
    • The build stage runs make test after compilation setup. Tests run in the build stage, not the lint stage, because they may require 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 the last cached result. In each stage the guard sits immediately below the ARG so a bare docker build . fails instead of reusing the empty cache key, and the value is expanded into the first check RUN so the cache miss does not rely on BuildKit's unreferenced-ARG handling. Both of those lines reference $CHECK_EPOCH, so both are value-keyed: each stage is invalidated at two independent points. The later RUNs in the same stage need no expansion of their own: they are already invalidated by their busted parent layer.
  • Every repo should have a Gitea Actions workflow (.gitea/workflows/) that runs script/cibuild (which runs docker build --build-arg CHECK_EPOCH="$epoch" .) 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 script/cibuild 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 20 seconds. Add a 30-second timeout in the Makefile.

  • 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:

    test:
    	@<test-command> || \
    		{ echo "--- Rerunning with -v for details ---"; \
    		  <test-command-with-v>; exit 1; }
    

    Go example:

    test:
    	@go test -timeout 30s -race -cover ./... || \
    		{ echo "--- Rerunning with -v for details ---"; \
    		  go test -timeout 30s -race -v ./...; exit 1; }
    

    Python example:

    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, *~), 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 such as .git unprefixed. 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.

  • .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.

  • 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 and must NEVER be modified by an agent, only manually by the user. Fetch from https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml. The canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed commit-pinned via go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5.

  • script/bootstrap in Go repos must install the pinned golangci-lint whenever the installed version does not match the pin — not merely when the binary is absent — and must then verify the install took effect by re-resolving the binary through PATH. The presence test if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi is wrong: it tests PATH presence and never version, so on any already-provisioned machine the pin is inert and a version bump is a no-op. Meanwhile the Dockerfile installs unconditionally into a clean image, so CI and local silently disagree about what the linter even is. Observed consequences: a local make check green while make docker rejected the same commit with six goconst findings, and a container linter surfacing thirteen findings the host run missed. A stale host linter does not merely fail to prove the tree is clean — it hides findings only the container can see. This is a deliberate departure from the node handling described above, which uses whatever node is installed: the linter version is the specific thing being held equal between host and container, so for it, presence is not enough.

    Comparing versions is necessary but not sufficient, because the obvious fix also fails green. go install writes to GOBIN (or GOPATH/bin) while callers resolve golangci-lint through PATH. If a different binary shadows it earlier in PATH, the install genuinely succeeds and changes nothing any caller will ever see: bootstrap prints success and the next make lint still runs the stale linter. That is worse than no fix, because it converts a known-stale toolchain into one everyone believes is pinned. The canonical form, placed in script/bootstrap after Go itself is present:

    # golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly
    # what `golangci-lint --version` prints for this ref; update both together.
    GOLANGCI_LINT_VERSION="2.12.2"
    GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
    
    # The version golangci-lint reports, resolved the way callers resolve it.
    # Prints nothing when the binary is absent, exits non-zero, or prints
    # something unparseable: all of those must read as "does not match".
    # The capture is the whole version token, not just its numeric prefix.
    # Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2
    # and skip the install, which is the defect this whole rule exists to close.
    # The trailing `|| true` is required, not tidiness. Under `set -o pipefail`
    # a non-zero --version would otherwise propagate out of the pipeline and
    # kill the script through `set -e` before the diagnostic below is printed.
    golangci_lint_version() {
        command -v golangci-lint >/dev/null 2>&1 || return 0
        golangci-lint --version 2>/dev/null | head -n 1 |
            sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true
    }
    
    ensure_golangci_lint() {
        if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then
            echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed"
            return 0
        fi
        echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION"
        go install "$GOLANGCI_LINT_REF"
    
        # go install writes to GOBIN (or GOPATH/bin); callers resolve through
        # PATH. Re-resolve through PATH and assert the install took effect.
        # `hash -r` is load-bearing: without it a shell that already resolved
        # a stale golangci-lint answers from its own lookup cache, and this
        # check false-fails with the shadowing message below.
        hash -r 2>/dev/null || true
        gcl_got="$(golangci_lint_version)"
        if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then
            echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \
                "and PATH resolves it"
            return 0
        fi
        gcl_bin="$(go env GOBIN)"
        [ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin"
        # Strip a trailing slash: GOBIN=/x/ would otherwise make the
        # "$gcl_bin"/* test below miss and misreport shadowing.
        while :; do
            case "$gcl_bin" in
                */) gcl_bin="${gcl_bin%/}" ;;
                *) break ;;
            esac
        done
        gcl_found="$(command -v golangci-lint 2>/dev/null || true)"
        echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \
            "$gcl_bin, but that is not what callers will get." >&2
        case "$gcl_found" in
            "")
                echo "bootstrap: PATH resolves no golangci-lint at all." \
                    "Add $gcl_bin to PATH, then re-run bootstrap." >&2
                ;;
            "$gcl_bin"/*)
                echo "bootstrap: PATH resolves $gcl_found, inside that same" \
                    "directory, reporting version ${gcl_got:-unparseable}." \
                    "Nothing is shadowing it, so the install itself did not" \
                    "produce the pinned version: check that" \
                    "GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2
                ;;
            *)
                echo "bootstrap: PATH resolves $gcl_found instead, reporting" \
                    "version ${gcl_got:-unparseable}. Remove that binary or" \
                    "put $gcl_bin earlier in PATH, then re-run bootstrap." >&2
                ;;
        esac
        exit 1
    }
    
    # The definitions above are inert on their own; the call site is part of
    # the canonical form. In a script/bootstrap that follows the "define all
    # functions, then call main" convention, this line belongs inside main()
    # next to the other ensure_* steps.
    ensure_golangci_lint
    

    Four properties are load-bearing; each guards a failure mode that otherwise fails green:

    • Compare the installed version against the pin, never test presence. This is what makes a version bump propagate to machines that already have some golangci-lint. 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, which compares equal to a 2.12.2 pin 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 path go install wrote to — and assert --version reports the pin. When it does not, fail non-zero and name the path command -v actually found, the version it reports, and the directory the install wrote to. That is a condition a human has to fix by hand, so bootstrap must not print success in it. Use hash -r first so the shell does not answer from its own lookup cache. Diagnose the cause from the resolved path rather than asserting one: only a path outside the install directory is shadowing. When the resolved path is inside it, nothing is shadowing and telling the operator to delete that binary or reorder PATH sends them after a fault that does not exist.
    • A mis-parse must fall through to reinstall, never to a false match. Absent binary, non-zero exit, empty output, and unrecognised output 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. Two function definitions with no call site are a silent no-op that reproduces the original defect exactly: exit 0, nothing installed, no output, stale linter still resolved. A success path that prints nothing is byte-identical to that no-op — same exit status, same empty output — so both success branches must print a confirmation naming the version. In a change about undetectable no-ops, "it printed nothing and exited 0" must not be the healthy signal.

    Keep it POSIX sh: no bashisms, no arrays, no [[, no grep -P.

    On the hash-pinning rule. @c0d3ddc9cf3faa61a4e378e879ece580256d76e5 is a commit hash, not a server-mutable version tag, and the go command verifies the fetched module against the checksum database — the mechanism the hash-pinning rule at the top of this document already names as acceptable for Go modules. Note that go install pkg@version runs in module-aware mode ignoring the go.mod in the current directory or any parent, so no repo go.sum is consulted for this install; the checksum database is what verifies it. The linter is a bootstrap prerequisite rather than part of any repo's module graph, which is why the canonical form installs it by commit-pinned ref instead of declaring it in go.mod. Whether a go.mod tool dependency — which would pin the hash in a committed, reviewable file instead — should replace this is an open decision, tracked at prompts#37.

    Keep GOLANGCI_LINT_VERSION and the ref in sync. The ref is a hash and carries no readable version, so the expected version is a separate string, and it must be exactly what --version prints for that ref — the comparison is an exact match on the whole version token. When the pinned commit carries a release tag the go command resolves the hash to that tag, so the string is simply the release number, 2.12.2 here. When it does not, the go command falls back to a pseudo-version and the binary reports something like 2.12.3-0.20260506110758-c0d3ddc9cf3f; that compares exactly like any other string, so it works, but it cannot be known without building the binary once and reading --version off it. Prefer pins on tagged releases for that reason — the expected string is then derivable from the ref — not because the comparison cannot handle the alternative.

    Because the comparison covers the whole token, a pre-release is never confused with its release: a host carrying 2.12.2-rc1 against a 2.12.2 pin compares unequal and gets reinstalled. This matters more than it looks, because a pre-release tag is still a tag, so a rule requiring merely that the pin be tagged would not catch it.

    Verifying a change to this logic requires a negative control run in an environment where a shadowing binary exists earlier in PATH than the install target. Without that, the control passes against the naive compare-then-install form as well and therefore proves nothing. Also check the mis-parse direction by feeding it unparseable --version output and confirming it reinstalls rather than reporting a match.

    Run those controls against the block as a consuming repo would adopt it — pasted into a script/bootstrap-shaped file that is then executed — not 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.

  • 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 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.
  • First commit of a new repo should contain only README.md.

  • Go module root: sneak.berlin/go/<name>. 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
    • 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/<path>.

  • 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