Every sentence of prompts/REPO_POLICIES.md is rewritten against the sentence rules: one idea per sentence, about 30 words at most, lists as bulleted lists, subject and verb early, the main rule before any exception. No rule's meaning changed: nothing is added, removed, loosened or tightened, every reason stays, and code blocks, digests, URLs, file names, commands and quoted strings are unchanged. Both repo checklists carry the same rewrite of the sentences they quote word for word. Model: fable-5-1
53 KiB
title, last_modified
| title | last_modified |
|---|---|
| Repository Policies | 2026-11-04 |
This document covers repository structure, tooling, and workflow standards. Code style conventions are in separate documents:
- Code Styleguide (general, bash, Docker)
- Go
- JavaScript
- Python
- Go HTTP Server Conventions
- API Policies
-
Cross-project documentation (such as this file) must include
last_modified: YYYY-MM-DDin the YAML front matter so it can be kept in sync with the authoritative source as policies evolve. -
External references must be pinned by cryptographic hash. This is the single most important rule in this document. It covers everything fetched from a remote source, including:
- Docker base images
- Go modules
- npm packages
- GitHub Actions
A version tag such as
@v4,@latestor:3.21is server-mutable, and therefore a remote code execution vulnerability. Reference an external dependency by its content hash:- Docker:
@sha256:... - Go: the module hash in
go.sum - npm: the integrity hash in the lockfile
- GitHub Actions:
@<commit-sha>
This also means never
curl | bashto install tools like pyenv, nvm, rustup, etc. Instead, download a specific release archive from GitHub, verify its hash, and only then install. The hash is hardcoded in the Dockerfile or script. Unverified install scripts are arbitrary remote code execution. Double-check every external reference in every file before committing.There is one exception: a package from the distribution's own package index needs no version and no hash. Pulling that index is also allowed. The distribution is trusted. In a
Dockerfile, it is the pinned base image's distribution. On a host, it is the host's. These commands are examples:apt-get updateapt-get installapk updateapk add
These are not trusted, so everything from them stays pinned by hash:
- Docker Hub
- PyPI
- npm
go get
A package source that a
Dockerfileadds itself is not the distribution's own, so the exception does not cover it. -
Every repo with software must have a root
Makefilewith these targets:make bootstrapmake setupmake testmake lintmake fmt(writes)make fmt-check(read-only)make check(runstest,lint,fmt-check)make dockermake 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/, and the Makefile targets are thin shims that call them:script/bootstrapscript/setupscript/testscript/lintscript/fmtscript/fmt-checkscript/checkscript/docker
The scripts must be POSIX sh (
#!/bin/sh,set -eu, no bashisms) so they run in minimal containers; alpine images, for example, have no bash. Locate the repo root with$(cd "$(dirname "$0")/.." && pwd -P), andcdthere before acting.From the standard's canonical set we use
bootstrap,setup,test, andcibuild.setupmakes the repo ready for development after a fresh clone: it runsbootstrap, theninstall-precommit, plus any repo-specific initialization.script/bootstrapinstalls 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 only when its major version is the pinned one. When node is missing or has another major, it installs the PINNED node version via nvm, and installs yarn and the packages under it. The CI runner image ships node 24, for example. nvm itself is installed first if missing, from a hash-verified GitHub release archive, nevercurl | sh. bash is installed as an explicit prerequisite, since nvm requires bash. Only the major version is compared because theDockerfilestages start from a node image whose exact version is not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned viacorepack prepare yarn@<version> --activate. Never install "latest" or "lts"; always exact versions.script/cibuildruns the CI build, and the Gitea workflow calls it. The script changes to the repo root, runsscript/bootstrap, runsscript/check, and builds the image with the version.script/cibuildrunsscript/bootstrapfirst, because the workflow checks out the repo and runs nothing else, whilescript/fmt-checkruns the formatter on the host. On a pristine checkout with nothing installed, the run dies there, after the containerised gates have passed. The bootstrap alone is not enough. Whenscript/bootstrapinstalls node and yarn under nvm, it leaves neither on thePATHof the shell that called it. A bareyarnthen either exits 127 or runs under another node. The host entrypoints that need yarn,script/fmtandscript/fmt-check, therefore use the same test asscript/bootstrap. When the node onPATHhas the pinned major version, they run theyarnonPATH. Otherwise they load nvm through$HOME/.nvm/nvm.shand run yarn under the pinned node. A runner carrying nothing but docker and git then gets throughscript/check.Four further scripts are our own extensions to the standard:
script/checkrunsscript/test,script/lintandscript/fmt-check.script/precommitis what the git pre-commit hook runs, and it callsscript/check.script/install-precommitinstalls the git pre-commit hook. Themake hookstarget shims to it.script/projectname(literally that filename) simply outputs the project's name.
Scripts that need the name call
script/projectname, so those scripts stay byte-identical across all repos.script/docker, for example, assembles its image tag from it. Repo-type-specific pre-commit extras belong inscript/precommit, not in the hook itself;go mod tidyverification in Go repos is one. Model scripts are athttps://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
- 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. - running a local development server (
-
Every repo should have a
Dockerfile, and it carries the repo's gates: alintphase and atestphase. The final stage depends on both, so the image cannot be built unless they pass. For non-server repos the final stage brings up a development environment; for server repos it is the runtime image. The gate phases and the build stage start from their pinned base images and install what those images lack in either of these ways:- inline, as the canonical Go
Dockerfilebelow does forgit - by running
script/bootstrap, as thepromptsrepo's ownDockerfiledoes for its yarn packages
The development environment stage installs development prerequisites by running
script/bootstraprather than duplicating its installs inline. A stage that runsscript/bootstrapCOPYsscript/and the dependency manifests (package.json+yarn.lock,go.mod+go.sum, etc.) before running it. - inline, as the canonical Go
-
Linting and testing run in Docker, as phases of the
Dockerfile. There is no separate lint file.script/lintandscript/testeach build one phase and nothing else:docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target lint --output type=cacheonly . docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target test --output type=cacheonly .A stage that is not the last one in the file is built only when the final stage's chain depends on it, or when
--targetnames it. That is why the two gates are always invoked by name here, and why the final stage carries aCOPY --from=of a harmless file from each of them. Without that dependency, a plaindocker build .builds the last stage alone and exits 0 having linted and tested nothing.The gate builds write no image. With
--output type=cacheonlythe phase runs and a failing step fails the build, but the result is not exported. Nothing uses those images, and writing one out is slow: a Go test phase's image holds the toolchain. A build given neither--outputnor-twrites an untagged image and leaves it dangling, on every developer host and every CI runner.script/cibuildandscript/dockerbuild the image that ships and tag it, so each build replaces the previous image. Each script assigns the tag on its own line before the build, soset -estops it wherescript/projectnamefails.Inside a phase the tool is invoked directly:
golangci-lint,go test,eslint,prettier. The tool is never invoked throughmake lintorscript/test. Those are themselves adocker build, and would recurse into a daemon that does not exist in a build step. Formatting is the exception and stays on the host:script/fmtwrites the working tree, andscript/fmt-checkis its read-only twin.No lint verdict may come from a host invocation of the linter. On a shared host golangci-lint reads a result cache keyed on file content rather than location. A second checkout of the same content is therefore served the first one's findings. A host-global lock in
$TMPDIRalso makes concurrent runs exit non-zero withparallel golangci-lint is running, a status a caller cannot tell from real findings. Both have produced wrong verdicts in this org, in both directions. A container has its ownTMPDIR, so the lock is out of reach. The lint phase does keep golangci-lint's cache between check builds (below), but it lints every checkout at/src. A finding is taken from that cache only when all of these are the same:- the files
- the settings
- the golangci-lint version
-
Any build that runs checks is given a new
CACHEBUSTvalue. Docker invalidates aCOPYlayer only when the copied content changes. On an unchanged tree the checkRUNis therefore served from cache, nothing executes, and the build still exits 0. Everydocker buildinscript/therefore passes--build-arg CACHEBUST="$(date +%s)-$$", the time and the script's process id. So no two builds share a value, not even the three in onescript/cibuild. Four scripts do this, and there is no fifth:script/lintscript/testscript/cibuildscript/docker
script/checkruns the two gate phases andscript/fmt-check, and builds no image of its own. In every stage of theDockerfile, every step that lints, tests or compiles comes afterARG CACHEBUST.ARG CACHEBUSTcomes right after these steps:- the steps that install system packages
- the steps that download Go's modules
- the step that runs
script/bootstrapto install a Pythontestphase's packages fromPipfile.lock
A stage with none of these steps declares
ARG CACHEBUSTright afterFROM. A step that also installs anything else comes afterARG CACHEBUSTtoo. Ascript/bootstrapthat also installs node packages is one. A new value reruns everyRUNstep after that line;COPYsteps are compared by content and need nothing. Docker keeps the layers above the line until one of these changes:- the base image
- their own lines
- the files they copy
A stage without the
ARGline is served from cache on an unchanged tree, so a repository re-vendors the scripts and adds theARGlines in one commit. The scripts do not pass--no-cache. That flag reruns every step too, but it also builds the layers above the line again and empties every cache mount. Go's build cache and golangci-lint's would then start empty every time; the canonical GoDockerfilebelow mounts them so that they survive between check builds. A baredocker build .is not evidence that anything ran: a sub-second build reporting success is a cache hit, not a result. Never invalidate by pruning:docker builder pruneand friends destroy a build cache shared with every other build on the host. When a check is added or changed, prove it works:- plant a defect it must catch
- watch the run fail on it
- revert the defect
A green run alone shows neither that the check ran nor that it covers what it should.
-
The gate phases are separate stages, and the build stage depends on both. The lint phase is based on the
golangci/golangci-lintimage (pinned by hash), so lint failures surface in seconds rather than after a full compile. The test phase is based on the Debian Go image. The canonical Go repoDockerfile:# In every stage ARG CACHEBUST comes right after the steps that install # system packages and download Go's modules, or right after FROM in a # stage with neither, and every step that lints, tests or compiles # comes after it. script/ passes a new value on every build, which # reruns every RUN step after it. Docker keeps the layers above it # until the base image, their own lines or go.mod and go.sum change. # The steps that compile mount Go's build cache, and golangci-lint run # also mounts golangci-lint's; both survive from build to build. # Lint phase # 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 ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/golangci-lint \ golangci-lint run --config .golangci.yml ./... # Test phase. -race needs cgo and so a C compiler, which the Debian Go # image ships and the alpine one does not. -count=1 because the mounted # cache also holds test results, which go test would otherwise replay. # A first run that timed out gets no rerun, because a rerun would only # wait for the same hang. # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ status=0; \ output=$(go test -count=1 -timeout 90s -race -cover ./... 2>&1) || status=$?; \ printf '%s\n' "$output"; \ test "$status" -eq 0 && exit 0; \ case "$output" in \ *"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \ esac; \ echo "--- Rerunning with -v for details ---"; \ go test -count=1 -timeout 90s -race -v ./...; \ exit 1 # Build stage. Nothing is wanted from either phase above; the copies # are what make BuildKit build them first, so nothing after them can # run unless lint and test passed. # golang:1.x-alpine, YYYY-MM-DD FROM golang@sha256:... AS builder # git comes from the base image's own distribution package index, which # is trusted, so it needs no version and no hash. RUN apk add --no-cache git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null # A tar-stream context keeps the sender's file owners, which git refuses. RUN git config --system --add safe.directory /src COPY . . # The VERSION build arg when one is given, otherwise # `git describe --tags --always` on the .git in the build context. With # .git present, a version that is still empty, dev or unknown fails the # build: git is missing or could not read the checkout. ARG VERSION RUN --mount=type=cache,target=/root/.cache/go-build \ VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ case "$VERSION" in ""|dev|unknown) \ echo "version is '$VERSION' although .git is present" >&2; \ exit 1 ;; \ esac; \ fi; \ CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /app ./cmd/app/ # Runtime stage, and the last one FROM alpine@sha256:... ARG CACHEBUST COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"]Key points:
-
The lint phase uses the
golangci/golangci-lintimage directly, so nothing needs installing: it has both Go and the linter. -
COPY --from=<phase> /src/go.sum /dev/nullis a no-op copy whose only purpose is the dependency on that phase. BuildKit runs stages in parallel by default, and a stage nothing depends on is not built at all. Without these two lines a red gate would not fail the build. -
Keep the runtime stage last. If you add a stage after it, give it the same two copies. A plain
docker build .builds the last stage's chain and nothing else. -
Check builds keep these, and none of them can hand back a stale result:
- The layers above
ARG CACHEBUST: the system packages and Go's modules. Docker installs the packages again when the base image or the install line changes. Docker downloads the modules again whengo.modorgo.sumchanges. Go checks every module againstgo.sumas it downloads it, and no later build can change a kept layer. The modules are kept in a layer rather than a cache mount. A cache mount is shared by every build on the host, and Go builds from whatever files the mount holds without checking them again (onlygo mod verifydoes). A file changed there would go unnoticed. - Go's build cache (
/root/.cache/go-build, where Go keeps it for root), mounted on the steps that compile:golangci-lint run, bothgo testruns andgo build. Go reuses a compiled package only when all its inputs are unchanged. The cache also holds test results, which is why bothgo testruns carry-count=1. - golangci-lint's cache (
/root/.cache/golangci-lint, where the pinned image keeps it), mounted ongolangci-lint run. golangci-lint reuses a package's findings only when all of these are unchanged:- the package's files and those of every package it imports
go.mod- the linters and their settings in
.golangci.yml - the golangci-lint version
- In a Python
testphase, the layer of the step that runsscript/bootstrapto install the packages fromPipfile.lock, as described for that phase below.
- The layers above
-
If the project uses
//go:embeddirectives that reference build artifacts, the lint phase must create placeholder files so the embed directives resolve. A web frontend compiled in a separate stage is such an artifact. Example:RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css. -
If the project requires CGO or system libraries for linting, install them in the lint phase. The
golangci/golangci-lintimage is Debian-based and has noapk. So install withapt-getunder the Debian package name (libvips-dev, where alpine saysvips-dev), and delete the package lists in the sameRUN, so the layer does not keep them. The test phase, on the Debian Go image, installs the libraries its tests need the same way. Either install goes right afterFROM, aboveARG CACHEBUST, so Docker keeps it until the base image or the install line changes:FROM golangci/golangci-lint@sha256:... AS lint RUN apt-get update \ && apt-get install -y --no-install-recommends libvips-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY go.mod go.sum ./ RUN go mod download ARG CACHEBUST -
.dockerignorelets.gitinto the build context. It keeps out every gitconfigat any depth (**/.git/config,**/.git/modules/**/config):- the repository's own
- each submodule's under
.git/modules/ - that of a submodule keeping its own
.gitdirectory
git describedoes not need them, and each can hold a credential: a password in a remote URL, or the token the CI checkout step stores there. Some submodule names have aconfigsegment, as inconfig,deploy/configorconfig/lib. Such a submodule loses its whole git directory to**/.git/modules/**/config, and Go's version stamping then fails the build. Give it a name without that segment (git submodule add --name). The stage that compiles hasgit: the Debian Go image has it, and an alpine one needsapk add --no-cache git. That stage takes the version from theVERSIONbuild argument when one is given, otherwise fromgit describe --tags --always. That gives:- the tag, on a tagged commit
- the tag, the number of commits since it and the short commit
(
v1.2.3-4-gabc1234), on a later commit - the short commit, when no tag is reachable
The stage that compiles also marks its working directory safe for git (
git config --system --add safe.directory /src). A context sent as a tar stream keeps the sender's file owners, and git refuses a checkout owned by another user, so the version would come out empty.ARG VERSIONhas no default. The build fails if the context carries.gitand the version still comes out empty,devorunknown. A plaindocker build .with no build arguments must succeed; a Dockerfile that refuses an empty build argument drops that refusal and keeps the argument. A checkout whose.gitis a file is the exception: a linked worktree, or a repository checked out as a submodule. That file points to a git directory outside the build context, so the build cannot read the version and a plaindocker build .fails. Pass the version with--build-arg VERSION=..., asscript/dockerandscript/cibuildalready do.
-
-
A Python
testphase that installs its packages fromPipfile.lockdoes it aboveARG CACHEBUST, so check builds keep them. The steps are:COPY script/ script/COPY Pipfile Pipfile.lock ./RUN script/bootstrap
ARG CACHEBUSTcomes right after them. Docker keeps what thatRUNinstalled until one of these changes:- the base image
script/PipfilePipfile.lock- a line up to and including the
RUN
The tests run after
ARG CACHEBUST, on every check build. In every other stage, a step that installs Python packages comes afterARG CACHEBUST.-
When
script/bootstrapruns in that step, it installs nothing beyond these:- system packages
- pipenv and its dependencies
- the packages from
Pipfile.lock
script/bootstrapinstalls no Python there: the tests use the base image's Python, which the image's hash pins. If it installs anything else there, the step goes afterARG CACHEBUST. -
PyPI is not trusted, so
script/bootstrapinstalls pipenv and its dependencies by hash. It does so withpip install --require-hashes -rand a requirements file inscript/that lists each of them with==and its--hash. pip refuses to install a dependency the file does not list. -
script/bootstrapthen runspipenv install --deploy, with--devwhen the tests need the development packages.--deploydoes all of these:- installs only what
Pipfile.locknames - checks every package against the lockfile's hashes
- fails when the lockfile is out of date with
Pipfile
- installs only what
-
A kept layer holds nothing a new install would refuse: pip checked the hashes of pipenv and its dependencies, and pipenv checked the hashes of the lockfile's packages. pipenv creates its virtual environment with the
virtualenvpackage. The packages thatvirtualenvadds come from wheels inside that package, so the hash pip checked for it covers them. Two things in the layer are not fixed by a hash:- A package with no ready-built wheel for the image is built from source. pip downloads the build tools without a hash check, so a kept layer's build of such a package may differ from a new one.
- System packages keep the versions the package manager gave when the layer was built; a new install may get newer ones.
-
As with Go's modules, the packages are kept in a layer, not a cache mount. A cache mount is shared by every build on the host, and nothing checks its files again.
In the example below,
script/bootstrapdoes all of these:- finds
makeandgitalready installed - installs pipenv and its dependencies by hash
- runs
pipenv install --dev --deploy
# Test phase. Both pytest runs share one 90-second timeout, so a hang # uses it once and gets no rerun. # python:3.x-slim, YYYY-MM-DD FROM python@sha256:... AS test # make and git are base tools that script/bootstrap installs when they # are missing. Installed here, they stay in their own layer when # script/ changes. RUN apt-get update \ && apt-get install -y --no-install-recommends make git \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY script/ script/ COPY Pipfile Pipfile.lock ./ RUN script/bootstrap ARG CACHEBUST COPY . . RUN timeout 90 sh -c 'pipenv run pytest || { \ echo "--- Rerunning with -v for details ---"; \ pipenv run pytest -v; exit 1; }' -
Every repo should have a Gitea Actions workflow (
.gitea/workflows/) that runsscript/cibuildon push. The workflow's only other step checks out the repo, withpersist-credentials: false.script/cibuildneeds no token, and without that setting the checkout leaves the job's token in.git/configfor every later step. The checkout step also setsfetch-depth: 0, which fetches the tagsgit describeneeds. By default it clones shallow with no tags, and a tagged repository's CI build would stamp a bare short commit id. The workflow'sconcurrencyblock groups runs by workflow and branch (${{ github.workflow }}-${{ github.ref }}) withcancel-in-progress: true. A new push then cancels the older run on the same branch, queued or running, and no other: runs for replaced commits do not hold up the shared runner.script/cibuildbootstraps, runs the gate phases, and then builds the image, so a successful run means every check passed. A baredocker build .does not carry the same guarantee, because its gate phases may come from the cache. The image build gets aCACHEBUSTvalue of its own and so runs the gate phases a second time. That is the price of the rule above, and it is worth paying. The image that ships is built from a run of its own gates rather than from a cache entry. Thecheckjob setstimeout-minutes: 20, so a hung build frees the shared runner after 20 minutes. That allows for the three Docker builds described above, each held to the 5-minute Docker build limit below, plus the bootstrap. Those builds are the test phase, the lint phase, then the image. A separate workflow limited tomainby abrancheslist underon: pushcannot be checked by review. To try a change to it, add the feature branch to that list and push, then remove the branch from the list again before merging. Keep any job in it that publishes behindif: github.ref_name == 'main', so the run from the feature branch publishes nothing. -
Use platform-standard formatters:
blackfor Pythonprettierfor JS/CSS/Markdown/HTMLgo fmtfor Go
Always use default configuration, with two exceptions:
- four-space indents (except Go)
proseWrap: alwaysfor Markdown (hard-wrap at 80 columns)
Documentation and writing repos (Markdown, HTML, CSS) should also have
.prettierrcand.prettierignore. -
Pre-commit hook: runs
script/precommit, which callsscript/check. If local testing is not possible in the repo,script/precommitmay skipscript/testand run onlyscript/lintandscript/fmt-check.script/install-precommitinstalls the hook; the Makefile must provide amake hookstarget 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: importing the module under test to verify it compiles/parses, for example. There is no excuse formake testto be a no-op. -
make testmust 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 (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. A hung package uses the backstop once, because a first run that timed out gets no rerun.-timeoutapplies to each package, so several hung packages can each use it. -
The test command should use the conditional verbose rerun pattern. Run tests without
-v(verbose) first. If tests fail, automatically rerun with-vto show full output. This keeps CI logs anddocker buildoutput clean on success, with just package/suite summaries. On failure it provides full diagnostic detail: every test case, every assertion. A first run that timed out gets no rerun, because the rerun would only wait for the same hang. The command lives in thetestphase of theDockerfile, sincescript/testbuilds that phase. The Makefile form below is the same pattern for any repo-local invocation:test: @<test-command> || \ { echo "--- Rerunning with -v for details ---"; \ <test-command-with-v>; exit 1; }Go example:
test: @status=0; \ output=$$(go test -count=1 -timeout 90s -race -cover ./... 2>&1) || status=$$?; \ printf '%s\n' "$$output"; \ test "$$status" -eq 0 && exit 0; \ case "$$output" in \ *"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \ esac; \ echo "--- Rerunning with -v for details ---"; \ go test -count=1 -timeout 90s -race -v ./...; \ exit 1The first run's output is kept so that the recipe can look in it for what Go prints for a hang:
panic: test timed outmeans the test's own timer fired.*** Test killedmeansgo teststopped a test that ignored that timer, about a minute later.
Go prints the goroutine stacks with either line, as long as the test can still respond. A rerun would only wait for the same hang, so the recipe exits 1. A failure with neither line in its output reruns with
-v. The kept output prints when the first run ends, not while it runs.-count=1is required on both invocations. It defeats Go's test result cache, so neither run can report a stored pass in place of running the tests. It leaves the build cache alone, so it costs the runtime of the suite and no recompilation.That cache is Go's own, separate from Docker's layer cache. Go stores a passing result in its cache directory (
GOCACHE). When the same tests run again on unchanged code, it prints that result, marked(cached), without running them. The directory lasts from one run to the next on a developer's machine, where this target runs. The directory also lasts in thetestphase of theDockerfile, which mounts it to keep compiled packages between check builds, so the phase'sgo testruns carry-count=1too. A newCACHEBUSTvalue (above) is what makes that step run on an unchanged tree.Python example:
test: @timeout 90 sh -c 'python -m pytest || { \ echo "--- Rerunning with -v for details ---"; \ python -m pytest -v; exit 1; }'Both pytest runs share that one 90-second timeout, as in the
Dockerfileexample above, so a hang uses it once and gets no rerun. Ctrl-C does not stop a runningmake test, which goes on until the tests end or the 90 seconds run out. On a Mac withouttimeout, the target fails until it is installed.The
exit 1ensures 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. A hung package uses the 90-second test backstop once, because a first run that timed out gets no rerun.
-
make checkmust not modify any files in the repo. Tests may use temporary directories. -
mainmust always passmake check, no exceptions. -
Never commit secrets.
.envfiles, credentials, API keys, and private keys must be in.gitignore. No exceptions. -
.gitignoreshould be comprehensive from the start:- OS files (
.DS_Store) - editor files (
.swp,*~) - in-repo agent scratch directories (
.claude/) node_modules/- the repo's own build outputs
Fetch the standard
.gitignorefromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignorewhen setting up a new repo. A repo's.gitignoreis the standard file followed by the repo's own entries, such as its binaries. A re-vendor replaces the standard part and keeps those entries. These patterns are written to.gitignore's own semantics, in which an unanchored pattern already matches at every depth. They are not a.dockerignoreand must not be transplanted into one unmodified. - OS files (
-
.dockerignoredoes not use.gitignoresemantics, and copying patterns across unmodified leaves secrets in the build context. Docker matches withmoby/patternmatcher:filepath.Matchsemantics plus a**extension. So*does not cross/, and a pattern without a leading**/is anchored at the build-context root. A.dockerignorelisting.env,*.pemand*.keytherefore excludes only the copies at the repository root.config/.envandcerts/server.keystill reach the context and can land in an image layer. Such a.dockerignoreis more dangerous than a short file with no secret patterns at all, because it reads as solved and stops anyone looking. Give every depth-independent pattern the**/prefix, and leave only genuinely root-anchored entries unprefixed. Those are.claude, and the repo's own host-built binary. The binary is written/myappand never**/myapp, which would also matchcmd/myapp/and delete the package directory from the context. Matching is case-sensitive, and an all-caps copy of each pattern still missesServer.Key. So secret names use character ranges:**/*.[kK][eE][yY],**/*.[pP][eE][mM], and likewise for.envrcand the extensionless SSH keys. Where such a pattern also catches something the build needs, re-include it with a negation (!docs/example.env). Deleting the pattern reopens the exposure for every other file it covers. Fetch the standard.dockerignorefromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignoreand extend it with the repo's own artifacts. -
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. UnderCOPY . .the build context therefore inflates by a multiple of the repo, and another session's unreviewed work can be copied into an image layer. In.gitignorethe entry is.claude/, unanchored. In.dockerignoreit is.claude, anchored and with no**/prefix, because the prefixed form would also delete any nested directory of that name from the build. Anchoring carries a known gap. The canonical.dockerignorestates the gap in its own comment, since consuming repos receive the file and not the tracker. The gap is that the directory is created in the agent's working directory. A repo running agents in subdirectories therefore still shipsservices/api/.claude/and must add its own anchored entry there. -
A plain
docker build .of a clone stamps the version thatgit describe --tags --alwaysgives. The version is derived from the.gitin the build context, as the canonicalDockerfileabove shows. Without its failure check, a missinggitor an unreadable checkout would leave-X main.Version=empty, and the build would still exit 0.script/dockerandscript/cibuildpass the version they compute on the host; it takes precedence. They do this byte-identically across repos:# The version and the tag each get their own line: a failing command # substitution inside an argument does not trip `set -e`, so the inline # form degrades to an empty constant. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" tag="$(script/projectname)" docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ -t "$tag" .--alwaysmakes an untagged repo yield an abbreviated commit hash rather than failing. The[ -n "$version" ]line is the single place the fallback is applied. That line is a live check that fires on a build from an export with no.git, and on a repository with no commits yet. Do not fold it into the substitution as|| echo unknown, which makes the guard unreachable. The Dockerfile's side isARG VERSIONin the stage that compiles, declared there becauseARGis stage-scoped. PassingVERSIONto a repo whose Dockerfile declares no suchARGis ignored and costs nothing, which is why the scripts stay byte-identical. One consequence for CI: the standard checkout action clones shallow and fetches no tags, so the canonical.gitea/workflows/check.ymlsetsfetch-depth: 0on its checkout step. -
Verify
.dockerignoreby enumerating the image, not by reading the patterns. Plant files at the root and at least two directories deep. Build a probe image that doesCOPY . ., and list what actually landed (docker run --rm --entrypoint find IMAGE /app). Thetransferring contextsize is not a substitute: a nested secret is a few bytes, and BuildKit transfers only the delta from the previous build. -
No build artifacts in version control. Code-derived data must never be committed to the repository if it can be avoided. Code-derived data includes:
- compiled bundles
- minified output
- generated assets
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 withgo get, which downloads code but does not execute code generation. -
Never use
git add -Aorgit 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.ymlis standardized. The vendored copy in a consuming repo must NEVER be modified by an agent. Fetch it fromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.ymland keep it byte-identical, so that no repo can quietly loosen its own linting. Linter configuration changes are made to the canonical copy in thepromptsrepo 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: thedenylist of thetest-supportdepguard rule. There 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.15.0 (released 2026-10-16). The version is pinned as the digest of the lint phase's base image,golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481, which reports2.15.0 built with go1.27.0 from 25c5369a. A module'sgodirective must not name a newer Go minor version than the one golangci-lint was built with, or golangci-lint refuses to lint it. This release lintsgo 1.27.1but notgo 1.28. That digest is the only pin, since no repo installs golangci-lint on the host. A repo sets the lint phase digest to the one named here and re-vendors.golangci.ymlin the same commit, whichever of the two prompted the change. The canonical copy can name linters that an older golangci-lint rejects. A newer golangci-lint can add linters thatdefault: allswitches on until the canonical copy disables them. -
script/bootstrapinstalls a pinned tool by comparing versions, never by testing presence. Anif ! command -v <tool>; then install; figuard testsPATHonly. On an already-provisioned machine the pin is then inert, and a version bump is a silent no-op. The Dockerfile, installing into a clean image, gets the pinned version, so a localmake checkandmake dockercan disagree about what the tool even is. The canonical form:-
compares the installed version against the pin over the whole version token. A parser that stops at the first
-reports2.12.2for a host running2.12.2-rc1and skips the install. -
treats
--versionoutput as a mismatch when it is any of these:- absent
- non-zero
- empty
- unrecognised
So a failure causes a redundant install, never a skipped one.
-
after installing, re-resolves the binary the way callers do, with
hash -rand then throughPATH, not through the directory the installer wrote to. A shadowing binary can hide an install, which then succeeds while no caller sees any change. In that case it fails and names the resolved path. -
is actually called, and prints the version on both success paths. A function defined and never invoked has the same exit status and the same empty output as one that worked.
Node is the exception to the whole-token comparison. The canonical
script/bootstrapcompares only node's major version. That is because theDockerfilestages start from a node 22 alpine image, which is not the exact pin, and nvm cannot install a prebuilt node on alpine.Keep it POSIX sh: no arrays, no
[[, nogrep -P.A Go tool a repo needs on the host is installed with
go installpinned to a commit hash (go install <package>@<commit hash>). It is never tracked as ago.modtool dependency or through atools.gofile, either of which pulls the tool's own dependencies into the repo'sgo.modandgo.sum. -
-
When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD).
-
Use
yarn, notnpm. -
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) withmax-ageof at least one year andincludeSubDomains.Content-Security-Policy(CSP) with a restrictive default policy (default-src 'self'as a baseline, tightened per-resource as needed). Never useunsafe-inlineorunsafe-evalunless unavoidable, and document the reason.X-Frame-Options: DENY(orSAMEORIGINif framing is required). Prefer theframe-ancestorsCSP directive as the primary control.X-Content-Type-Options: nosniff.Referrer-Policy: strict-origin-when-cross-origin(or stricter).Permissions-Policyrestricting 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).
ReadTimeoutandReadHeaderTimeouton thehttp.Serverto defend against slowloris attacks.WriteTimeouton thehttp.Server.IdleTimeouton thehttp.Server.- Per-handler execution time limits via
context.WithTimeoutor chi/stdlibmiddleware.Timeout.
- Maximum request body size enforced on all endpoints (e.g. Go
-
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
Authorizationheader (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, andSameSite=Lax(orStrict) 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 trustX-Forwarded-Forunconditionally.
- True client IP detection when behind a reverse proxy
(
-
CORS:
- Authenticated endpoints must restrict
Access-Control-Allow-Originto an explicit allowlist of known origins. Wildcard (*) is acceptable only for public, unauthenticated read-only APIs.
- Authenticated endpoints must restrict
-
Error handling:
-
Internal errors must never leak implementation details to the client. Those details include:
- stack traces
- SQL queries
- file paths
Return generic error messages in production; detailed errors only when
DEBUGis 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
Securecookie flags must still be set by the application so that the browser enforces HTTPS end-to-end.
- 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
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.mdis the primary documentation. Required sections:-
Description: First line must include:
- the project name
- purpose
- category (web server, SPA, CLI tool, etc.)
- license
- 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
LICENSEfile 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 rungo mod tidybefore 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.sqldirectly. - Post-1.0.0: add new numbered migration files for each schema change. Never edit existing migrations after release.
-
All repos should have an
.editorconfigenforcing the project's indentation settings. It is the standard file fromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig, followed by the repo's own sections, such as one for another language it uses. The standard file sets tabs forMakefileand Go files. A re-vendor replaces the standard part and keeps those sections. -
Avoid putting files in the repo root unless necessary. Root should contain only project-level config files:
README.mdAGENTS.mdMakefileDockerfileLICENSE.gitignore.editorconfigREPO_POLICIES.md- language-specific config
Everything else goes in a subdirectory. Canonical subdirectory names:
bin/— executable scripts and toolscmd/— Go command entrypoints; thin only: onemain.goper binary whose body is a single call intointernal/orpkg/, no project logic incmd/configs/— configuration templates and examplesdeploy/— deployment manifests (k8s, compose, terraform)docs/— documentation and markdown (README.md stays in root)internal/— Go internal packagesinternal/db/migrations/— database migrationspkg/— Go library packagesshare/— systemd units, data filesstatic/— static assets (images, fonts, etc.)web/— web frontend source
-
When setting up a new repo, files from the
promptsrepo may be used as templates. Fetch them fromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/<path>. -
New repos must contain at minimum:
README.md,.git,.gitignore,.editorconfigLICENSE,REPO_POLICIES.md(copy from thepromptsrepo)Makefilescript/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
-
Guidance for coding agents lives in one
AGENTS.mdat the repository root. It is never committed under a file or directory named after one agent tool, such asCLAUDE.mdor.claude/, and never split into separate memory files.