The rules now sit under headings, the pinning rule first. Each rule that was stated in several places is stated once. From 1,145 lines and 8,427 words to 1,012 lines and 7,709 words. Code blocks are unchanged, except that the Makefile copies of the Go and Python test commands are gone: the Dockerfile examples carry the same commands. Both checklists name the `.golangci.yml` section. Model: fable-5-1
50 KiB
title, last_modified
| title | last_modified |
|---|---|
| Repository Policies | 2026-11-06 |
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
Pin external references by hash
External references must be pinned by cryptographic hash. This is the single most important rule in this document. It covers what we download and install ourselves in scripts and Dockerfiles. Reference each by its content hash:
- Docker base images:
@sha256:... - Go modules: the module hash in
go.sum - Python packages from PyPI: the package hash in
Pipfile.lock - npm packages: the
checksuminyarn.lock - GitHub Actions:
@<commit-sha> - release archives fetched with
curlorwget: a hash hardcoded in the Dockerfile or script
Packages from the distribution's own package manager are outside this rule. They
need no version and no hash, because the distribution is trusted. In a
Dockerfile, it is the pinned base image's distribution. On a host, it is the
host's. The host's package manager stands for its distribution, and that
includes nix and brew. Updating the package index and upgrading installed
packages are fine too. This command, for example, is allowed:
apt update && apt upgrade -y && apt install -y build-essential
A package source that a Dockerfile adds itself is not the distribution's own,
so its packages are pinned like any other download.
- A version tag such as
@v4,@latestor:3.21is server-mutable, and therefore a remote code execution vulnerability. - An unverified install script is arbitrary remote code execution. Never
curl | bashto install a tool such as rustup. Instead, download a specific release archive from GitHub, verify its hash, and only then install. - Docker Hub, PyPI, the npm registry and
go getare not trusted, so everything from them stays pinned by hash. - Above each image or package pinned by hash, add a comment with the version and date (YYYY-MM-DD).
- Double-check every external reference in every file before committing.
node, yarn, npm, python, pip and venv come from the distribution wherever possible:
- node means the distribution's newest Node package, such as
nodejs-currenton Alpine 3.24. - pip and venv are the installer tools bundled with python.
- If the distribution's version is too old, use a newer distribution, never a
download. That rules out nvm, pyenv, corepack and
npm install -g yarn. - yarn's release file is third-party code, and git pins it.
yarn set versionwrites it to.yarn/releases/once, given an exact version.
Git and secrets
- Never commit secrets.
.envfiles, credentials, API keys, and private keys must be in.gitignore. No exceptions. - Make all changes on a feature branch. You can do whatever you want on a feature branch.
- Never force-push to
main. - Never use
git add -Aorgit add .. Always stage files explicitly by name. - No build artifacts in version control. Code-derived data must never be
committed if it can be avoided: 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.
Entrypoints
Repos follow the
Scripts to Rule Them All
pattern. Every repo with software must have a root Makefile with the targets
below. Each target is a thin shim that calls an executable script in script/,
where its implementation lives:
make bootstrap:script/bootstrapinstalls all dependencies.make setup:script/setupreadies a fresh clone for development. It runsbootstrap, theninstall-precommit, plus any repo-specific initialization.make test:script/testmake lint:script/lintmake fmt:script/fmt(writes)make fmt-check:script/fmt-check(read-only)make check:script/checkrunsscript/test,script/lintandscript/fmt-check.make docker:script/dockermake hooks:script/install-precommitinstalls the git pre-commit hook.
Three more scripts have no target:
script/precommitis what the pre-commit hook runs. It callsscript/check. Repo-type-specific extras belong in it, not in the hook itself;go mod tidyverification in Go repos is one. If local testing is not possible in the repo, it may skipscript/testand run onlyscript/lintandscript/fmt-check.script/cibuildruns the CI build, and the Gitea workflow calls it.script/projectname(literally that filename) outputs the project's name. Scripts that need the name call it, so they stay byte-identical across all repos.script/docker, for example, assembles its image tag from it.
From the standard's canonical set we use bootstrap, setup, test, and
cibuild. check, precommit, install-precommit and projectname are our
own extensions to the standard.
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), and cd there before acting.
A model Makefile is at
https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile. Model scripts are
at https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>.
Run the repo's own entrypoints, never the underlying tools directly. The
script/ scripts are the single source of truth for how each operation runs.
Calling them directly is fine, and so is calling the Makefile targets. The
Makefile targets must still exist, as the thin shims described above.
An agent whose work is confined to one package may run go test directly on
that package's suite, or on individual tests in it, while iterating. This is the
only exception to this rule. go build, go vet and the linter still go
through the entrypoints. A run while iterating is not a gate, so the full
make check is still required before every push.
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
Typing make<tab> in a checkout should show every meaningful operation. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
Tests and checks
- All repos with software must have tests that run via the platform-standard
test framework (
go test,pytest,jest/vitest, etc.). There is no excuse formake testto be a no-op. If no meaningful tests exist yet, add the most minimal one: importing the module under test to verify it compiles/parses, for example. 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). This backstop sits above the hard cap on purpose: it catches a hung test, not a merely slow one.-timeoutapplies to each package, so several hung packages can each use it. - Docker builds must complete in under 5 minutes.
make checkmust not modify any files in the repo. Tests may use temporary directories.mainmust always passmake check, no exceptions.
The test command should use the conditional verbose rerun pattern. Run tests
without -v (verbose) first. If they fail, rerun automatically with -v.
Success then leaves CI logs and docker build output clean, with just
package/suite summaries. Failure shows every test case and every assertion.
- The rerun always ends in
exit 1. The first run already proved the tests are broken, so the build must not pass even if a flaky test succeeds on the second attempt. The rerun exists solely for diagnostic output. - A first run that timed out gets no rerun, which would only wait for the same hang. A hung package therefore uses the 90-second backstop once.
The command lives in the test phase of the Dockerfile, since script/test
builds that phase. The canonical Go Dockerfile and the Python test phase
example below carry it. The Makefile form below is the same pattern for any
repo-local invocation. In a Makefile, each $ of those two commands is written
$$.
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
script/bootstrap
script/bootstrap installs all dependencies idempotently and assumes nothing is
present.
- A tool from the distribution has no pin, so it is installed only when it is missing. make, git, node and yarn are such tools. They come from nix, apt, brew, or apk, detected in that order; apt runs noninteractive.
- A tool from outside the distribution is never installed as "latest" or "lts"; it is always an exact version.
node and yarn
Use yarn, not npm.
script/bootstrapinstalls node, yarn and the node packages only whenyarn.lockis in the tree. ADockerfilestage that needs no yarn therefore copies noyarn.lock. The Go and Python stages below are such stages.- It installs the node packages with
yarn install --immutable, which fails when a package'schecksumis wrong, or whenpackage.jsonis out of step withyarn.lock. - Each repository commits the yarn that runs.
yarnPathin.yarnrc.ymlpoints at the release file in.yarn/releases/. The distribution's yarn only starts that file, and that file does the work. So Alpine's yarn 1 and Debian 13's yarn 4.1 run the same committed yarn. Corepack never runs, and no yarn is downloaded. .yarnrc.ymlholdsyarnPath,nodeLinker: node-modulesandenableTelemetry: false. Without the last, yarn sends usage reports from any host that is not CI.- By default, yarn runs no dependency's install script. A dependency that must
run one is named in
package.json, as"dependenciesMeta": { "<name>": { "built": true } }. .gitattributescarries.yarn/releases/** binary, so diffs and merges treat the release file as one opaque file.- yarn 1 ignores
--immutableand exits 0, so on a tree with no.yarnrc.ymlthe install is not frozen. A repository therefore re-vendorsscript/bootstrapand converts itsyarn.lockin one commit. - Debian and Ubuntu name the yarn command
yarnpkg.script/bootstrap,script/fmtandscript/fmt-checkeach carry the same small function that runs whichever name is present. With neither name onPATH,script/fmtandscript/fmt-checkprint one line saying to runscript/bootstrap, and exit 1. - A
Dockerfilestage that needs yarn may start from any distribution that packages node and yarn. Before it runsscript/bootstrap, it copiespackage.json,yarn.lock,.yarnrc.ymland.yarn/releases/. With only the first two, the install fails, and Alpine's yarn 1 says to enable corepack. Never do that; copy the missing files instead.
Pinned tools
script/bootstrap installs a pinned tool by comparing versions, never by
testing presence. An if ! command -v <tool>; then install; fi guard tests
PATH only. On an already-provisioned machine the pin is then inert, and a
version bump is a silent no-op. The Dockerfile's clean image still gets the
pinned version, so a local make check and make docker can 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 that is absent, non-zero, empty or unrecognised as a mismatch. A failure then causes a redundant install, never a skipped one. - after installing, re-resolves the binary the way callers do:
hash -r, thenPATH, not the directory the installer wrote to. A shadowing binary can hide an install that reported success. 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.
- stays POSIX sh: no arrays, no
[[, nogrep -P.
A Go tool a repo needs on the host is installed with go install pinned to a
commit hash (go install <package>@<commit hash>). It is never tracked as a
go.mod tool dependency or through a tools.go file, either of which pulls the
tool's own dependencies into the repo's go.mod and go.sum.
Dockerfile
Every repo should have a Dockerfile, and it carries the repo's gates: a lint
phase and a test phase. 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 lint and test gates run in Docker, as phases of the Dockerfile. There
is no separate lint file. script/lint and script/test each 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 those copies, a plaindocker build .builds the last stage's chain alone and exits 0 having linted and tested nothing. Keep the final stage last. If you add a stage after it, give it the same two copies. - 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 build given neither--outputnor-tleaves an untagged image dangling on every developer host and CI runner.script/cibuildandscript/dockertag the image that ships, so each build replaces the previous image. - Inside a phase the tool is invoked directly:
golangci-lint,go test,eslint,prettier. It is never invoked throughmake lintorscript/test, which are themselves adocker buildand 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, which a caller cannot tell from real findings. A container has its ownTMPDIR, out of the lock's reach. The lint phase does keep golangci-lint's cache between check builds, but it lints every checkout at/src. It reuses a finding only on the conditions listed under the canonical GoDockerfilebelow.
What each stage installs:
- The gate phases and the build stage start from their pinned base images. They
install what those images lack inline, as the canonical Go
Dockerfilebelow does forgit. Or they runscript/bootstrap, as thepromptsrepo's ownDockerfiledoes for its yarn packages. - The development environment stage installs development prerequisites by
running
script/bootstrap. Only system packages may be installed inline above it, so that check builds keep them; thepromptsrepo's ownDockerfiledoes this. - A stage that runs
script/bootstrapfirst COPYsscript/and the dependency manifests it needs:go.modandgo.sum, for example, or the four yarn files named above.
CACHEBUST
Any build that runs checks is given a new CACHEBUST value. On an unchanged
tree Docker otherwise serves the check RUN from cache: nothing executes, and
the build still exits 0.
Every docker build in script/ 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 one script/cibuild. Four
scripts do this, and there is no fifth: script/lint, script/test,
script/cibuild and script/docker. script/check builds no image of its own.
In every stage of the Dockerfile, every step that lints, tests or compiles
comes after ARG CACHEBUST. ARG CACHEBUST comes 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 CACHEBUST right after FROM. A
step that also installs anything else comes after ARG CACHEBUST too. A
script/bootstrap that also installs node packages is one.
- A new value reruns every
RUNstep after that line;COPYsteps are compared by content and need nothing. Docker keeps the layers above the line until the base image, their own lines or the files they copy change. - 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 rebuilds the layers above the line and empties every cache mount. The canonical GoDockerfilebelow mounts Go's build cache and golangci-lint's so that they survive between check builds. - A bare
docker 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, then revert the defect. A green run alone shows neither that the check ran nor that it covers what it should.
Canonical Go Dockerfile
The gate phases are separate stages, and the build stage depends on both.
The lint phase is based on the golangci/golangci-lint image (pinned by hash),
so lint failures surface in seconds rather than after a full compile. That image
has both Go and the linter, so nothing needs installing. The test phase is based
on the Debian Go image.
# 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:
-
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. Go checks every module againstgo.sumas it downloads it, and no later build can change a kept layer. - 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. - 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
- The layers above
-
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 it holds without checking them again (only
go mod verifydoes). A file changed there would go unnoticed. -
The test command keeps the first run's output, to 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. On either line the command exits 1 without a rerun. A failure with neither line reruns with-v. The kept output prints when the first run ends, not while it runs. -
-count=1is required on bothgo testinvocations. 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. That directory lasts between runs on a developer's machine, and in thetestphase, which mounts it to keep compiled packages. -
-count=1defeats only that test result cache. It leaves the build cache alone, so it costs the runtime of the suite and no recompilation. The cache is Go's own, separate from Docker's layer cache, whichCACHEBUSThandles. -
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: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
Python test phase
A Python test phase that installs its packages from Pipfile.lock does it
above ARG CACHEBUST, so check builds keep them. The steps are
COPY script/ script/, COPY Pipfile Pipfile.lock ./ and
RUN script/bootstrap, with ARG CACHEBUST right after them. Docker keeps what
that RUN installed until the base image, script/, Pipfile, Pipfile.lock
or a line up to and including the RUN changes.
The tests run after ARG CACHEBUST, on every check build. In every other stage,
a step that installs Python packages comes after ARG CACHEBUST.
In that step, script/bootstrap:
- installs nothing beyond system packages, pipenv and its dependencies, and the
packages from
Pipfile.lock. If it installs anything else there, the step goes afterARG CACHEBUST. - installs no Python: the tests use the distribution's Python, which a step above it installs.
- puts pipenv in a virtual environment at
$HOME/.pipenv, because Debian's Python refuses a system-widepip install. It creates the environment withpython3 -m venvwhen it is missing, then uses that environment'spipandpipenv. On a host, it first installs the distribution's python and venv packages when they are missing. One script then works on a host and in aDockerfile. - installs pipenv and its dependencies by hash, because PyPI is not trusted. It
runs
pip install --require-hashes -ron a requirements file inscript/that lists each of them with==and its--hash. pip refuses to install a dependency the file does not list. - then runs
pipenv install --deploy, with--devwhen the tests need the development packages.--deployinstalls only whatPipfile.locknames, checks every package against the lockfile's hashes, and fails when the lockfile is out of date withPipfile. - sets
PIP_ONLY_BINARY=:all:for both installs, and pipenv passes it on to pip. A package with no ready-built wheel for the image then stops the install. Without the setting, pip builds that package from source and downloads the build tools without a hash check.
Pipfile names no python_version, because pipenv install --deploy stops
when the distribution's Python differs from it.
A kept layer holds nothing a new install would refuse. pip checked the hashes of
pipenv and its dependencies, and pipenv those of the lockfile's packages. pipenv
creates its virtual environment with the virtualenv package. The packages that
virtualenv adds come from wheels inside it, so the hash pip checked for it
covers them. System packages, Python among them, are not fixed by a hash. They
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, which every build on the host shares and nothing checks again.
A Python repository's script/bootstrap adds these lines in main, above the
last echo. The ensurepip test fails when python is missing, and also when
Debian's venv package is missing:
if [ ! -x "$HOME/.pipenv/bin/pip" ]; then
if ! python3 -m ensurepip --version >/dev/null 2>&1; then
pkg_install python3 python3-venv python3 python3
fi
python3 -m venv "$HOME/.pipenv"
fi
export PIP_ONLY_BINARY=:all:
"$HOME/.pipenv/bin/pip" install --require-hashes -r script/requirements.txt
"$HOME/.pipenv/bin/pipenv" install --dev --deploy
Make script/requirements.txt once, with
echo pipenv | pip-compile --generate-hashes --allow-unsafe -o script/requirements.txt -.
pip-compile comes from Alpine's py3-pip-tools package; Debian and Ubuntu do
not package pip-tools. Make the first Pipfile.lock with
$HOME/.pipenv/bin/pipenv lock, after the pip install line has installed
pipenv.
In the example below, script/bootstrap finds make, git, python and venv
already installed:
# Test phase. Both pytest runs share one 90-second timeout, so a hang
# uses it once and gets no rerun.
# debian:trixie-slim, YYYY-MM-DD
FROM debian@sha256:... AS test
# Python, pip and venv come from the distribution, which is trusted, so
# they need no version and no hash. 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 ca-certificates make git \
python3 python3-venv \
&& rm -rf /var/lib/apt/lists/*
# script/bootstrap installs pipenv in a virtual environment at
# $HOME/.pipenv, and the tests below run that pipenv.
ENV PATH=/root/.pipenv/bin:$PATH
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; }'
Run from a make test target on a host, that test command:
- does not stop on Ctrl-C. It goes on until the tests end or the 90 seconds run out.
- fails on a Mac without
timeout, until it is installed. - needs
$HOME/.pipenv/binonPATH.
Version
A plain docker build . of a clone stamps the version that
git describe --tags --always gives. 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 derives it from the .git in the build context, as the
canonical Go Dockerfile above shows:
- It takes the version from the
VERSIONbuild argument when one is given, otherwise fromgit describe.ARG VERSIONis declared in that stage, becauseARGis stage-scoped, and has no default. - It has
git: the Debian Go image has it, and an alpine one needsapk add --no-cache git. - It 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. - The build fails if the context carries
.gitand the version still comes out empty,devorunknown. Otherwise a missinggitor an unreadable checkout would leave-X main.Version=empty, and the build would still exit 0. - A plain
docker 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.
.dockerignore lets .git into the build context. It keeps out every git
config at 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 describe does not need them, and each can hold a credential: a password in
a remote URL, or the token the CI checkout step stores there. A submodule whose
name has a config segment (config, deploy/config, config/lib) 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).
script/docker and script/cibuild pass 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. It 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. - Passing
VERSIONto a repo whose Dockerfile declares no suchARGis ignored and costs nothing, which is why the scripts stay byte-identical.
CI
Every repo should have a Gitea Actions workflow (.gitea/workflows/) that runs
script/cibuild on push. The workflow's only other step checks out the repo.
The canonical .gitea/workflows/check.yml sets these:
persist-credentials: falseon the checkout step.script/cibuildneeds no token, and without that setting the checkout leaves the job's token in.git/configfor every later step.fetch-depth: 0on the checkout step, which fetches the tagsgit describeneeds. The default is a shallow clone with no tags, and a tagged repository's CI build would then stamp a bare short commit id.- a
concurrencyblock that 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. timeout-minutes: 20on thecheckjob, so a hung build frees the shared runner after 20 minutes. That allows for the bootstrap and three Docker builds, each held to the 5-minute limit.
script/cibuild changes to the repo root, runs script/bootstrap, runs
script/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. Without it, a pristine checkout dies there, after the containerised gates have passed.- Its three Docker builds are the test phase, the lint phase, then the image. So
a successful run means every check passed, which a bare
docker build .does not show. - The image build gets a
CACHEBUSTvalue of its own and so runs the gate phases a second time. That price is worth paying: the image that ships is built from a run of its own gates rather than from a cache entry.
A separate workflow limited to main by a branches list under on: push
cannot 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 behind if: github.ref_name == 'main', so the
run from the feature branch publishes nothing.
.gitignore and .dockerignore
.gitignore should be comprehensive from the start:
- OS files (
.DS_Store) - editor files (
.swp,*~) - in-repo agent scratch directories (
.claude/) node_modules/.yarn/*, with!.yarn/releasesso that git keeps yarn's release file- the repo's own build outputs
Fetch the standard .gitignore from
https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore when setting up a
new repo. A repo's .gitignore is 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.
.dockerignore does not use .gitignore semantics, and copying patterns
across unmodified leaves secrets in the build context. The standard
.gitignore patterns are written to .gitignore's own semantics, in which an
unanchored pattern already matches at every depth. They must not be transplanted
into a .dockerignore unmodified. Docker matches with moby/patternmatcher:
filepath.Match semantics 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. That is worse than a 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,.yarn/*,!.yarn/releases, 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 misses
Server.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. Under COPY . . 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, which the canonical
.dockerignorestates in its own comment. 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.
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). 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.
.golangci.yml
.golangci.yml is standardized. The vendored copy in a consuming repo must
NEVER be modified by an agent. Fetch it from
https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml and keep it
byte-identical, so that no repo can quietly loosen its own linting.
- Linter configuration changes are made to the canonical copy in the
promptsrepo and reach consuming repos by re-vendoring. An agent may open a PR against canonical, which only the user merges. - One list is exempt from byte-identity, because it cannot be written once for
every repo: the
denylist 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. That digest is the only pin, since no repo installs golangci-lint on the host. - A module's
godirective 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. - 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.
Formatting
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)
proseWrap: alwaysfor Markdown (hard-wrap at 80 columns)
Documentation and writing repos (Markdown, HTML, CSS) should also have
.prettierrc and .prettierignore.
All repos should have an .editorconfig enforcing the project's indentation
settings. It is the standard file from
https://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 for Makefile and Go files. A re-vendor replaces the standard
part and keeps those sections.
Web services
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.WriteTimeoutandIdleTimeouton 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 unconditionally. - 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. - Error handling: Internal errors must never leak implementation details to
the client: 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.
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.
Database migrations
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.
README
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
LICENSEfile in the repo root. - Author: @sneak.
Repository layout
Avoid putting files in the repo root unless necessary. Root should contain only
project-level config files: README.md, AGENTS.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 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
Guidance for coding agents lives in one AGENTS.md at the repository root. It
is never committed under a file or directory named after one agent tool, such as
CLAUDE.md or .claude/, and never split into separate memory files.
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,.yarnrc.yml,.yarn/releases/,.gitattributes,.prettierrc,.prettierignore - Python:
pyproject.toml
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>. First commit of a
new repo should contain only README.md.
Other conventions
- Write all dates as YYYY-MM-DD (ISO 8601).
- 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. - Use SemVer.
- Go module root:
sneak.berlin/go/<name>. Always rungo mod tidybefore committing. - Simple projects should be configured with environment variables.