check / check (push) Waiting to run
The vendored files are copies from sneak/prompts e3f79c4, with this repository's own entries kept. The script/ builds pass a new CACHEBUST value instead of --no-cache, which emptied Go's build cache mount, so every make test compiled the standard library and every dependency with -race. Every Dockerfile stage declares ARG CACHEBUST first, so every RUN step still reruns on an unchanged tree. The steps that compile mount Go's build cache, and both go test runs keep -count=1. The lint and test builds write no image. script/bootstrap, script/fmt and script/fmt-check are the canonical files plus this repository's Go steps. Model: opus-5-5
757 lines
43 KiB
Markdown
757 lines
43 KiB
Markdown
---
|
|
title: Repository Policies
|
|
last_modified: 2026-10-15
|
|
---
|
|
|
|
This document covers repository structure, tooling, and workflow standards. Code
|
|
style conventions are in separate documents:
|
|
|
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
|
(general, bash, Docker)
|
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
|
|
|
---
|
|
|
|
- Cross-project documentation (such as this file) must include
|
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
|
with the authoritative source as policies evolve.
|
|
|
|
- **ALL external references must be pinned by cryptographic hash.** This
|
|
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
|
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
|
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
|
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
|
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
|
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
|
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
|
etc. Instead, download a specific release archive from GitHub, verify its hash
|
|
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
|
install scripts are arbitrary remote code execution. This is the single most
|
|
important rule in this document. Double-check every external reference in
|
|
every file before committing. There are zero exceptions to this rule.
|
|
|
|
- Every repo with software must have a root `Makefile` with these targets:
|
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
|
|
|
- Repos follow the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
pattern: the implementation of each Makefile target lives in an executable
|
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
|
for development after a fresh clone: runs `bootstrap`, then
|
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
|
(detected in that order; apt runs noninteractive). For node it uses the
|
|
installed node only when its major version is the pinned one; otherwise (node
|
|
missing, or another major, such as the node 24 the CI runner image ships) it
|
|
installs the PINNED node version via nvm and installs yarn and the packages
|
|
under it. nvm itself is installed first if missing, from a hash-verified
|
|
GitHub release archive (never `curl | sh`), with bash installed as an explicit
|
|
prerequisite since nvm requires bash. Only the major version is compared
|
|
because the `Dockerfile` stages start from a node image whose exact version is
|
|
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
|
|
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
|
|
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
|
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
|
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
|
nothing else, while `script/fmt-check` runs 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**: when
|
|
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
|
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
|
under another node. The host entrypoints that need yarn — `script/fmt` and
|
|
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
|
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
|
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
|
pinned node. A runner carrying nothing but docker and git then gets through
|
|
`script/check`. Four further scripts are our own extensions to the standard:
|
|
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
|
`script/precommit` is what the git pre-commit hook runs, and it calls
|
|
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
|
(the `make hooks` target shims to it); and `script/projectname` (literally
|
|
that filename) simply outputs the project's name. Scripts that need the name
|
|
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
|
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
|
`script/precommit`, not in the hook itself. Model scripts are at
|
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
|
must document the provided scripts in an **Entrypoints** section (see the
|
|
README requirements below).
|
|
|
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
|
instead of invoking the underlying tools directly. The Makefile is the single
|
|
source of truth for how these operations are run.
|
|
|
|
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
|
the required targets above, it should have targets for every common operation:
|
|
running a local development server (`make run`, `make dev`), re-initializing
|
|
or migrating the database (`make db-reset`, `make migrate`), building
|
|
artifacts (`make build`), generating code, seeding data, or anything else a
|
|
developer would do regularly. If someone checks out the repo and types
|
|
`make<tab>`, they should see every meaningful operation available. A new
|
|
contributor should be able to understand the entire development workflow by
|
|
reading the Makefile.
|
|
|
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
|
`lint` phase and a `test` phase, with the final stage depending 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 either inline, as the canonical Go `Dockerfile`
|
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
|
repo's own `Dockerfile` does for its yarn packages. The development
|
|
environment stage installs development prerequisites by running
|
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
|
|
|
- **Linting and testing 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:
|
|
|
|
```sh
|
|
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 `--target` names it.** That is why the
|
|
two gates are always invoked by name here, and why the final stage carries a
|
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
|
and tested nothing.
|
|
|
|
**The gate builds write no image.** With `--output type=cacheonly` the 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 `--output` nor `-t` writes
|
|
an untagged image and leaves it dangling, on every developer host and every
|
|
CI runner. `script/cibuild` and `script/docker` build the image that ships
|
|
and tag it, so each build replaces the previous image; each assigns the tag
|
|
on its own line before the build, so `set -e` stops it where
|
|
`script/projectname` fails.
|
|
|
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
|
themselves a `docker 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/fmt` writes the working tree, and `script/fmt-check` is 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, so a second checkout of the same content is served the first
|
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
|
exit non-zero with `parallel 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 own golangci-lint cache, its
|
|
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
|
|
|
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
|
invalidates a `COPY` layer only when the copied content changes, so on an
|
|
unchanged tree the check `RUN` is served 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`. `script/lint`, `script/test`, `script/cibuild` and
|
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
|
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
|
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
|
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
|
are compared by content and need nothing. A stage without the `ARG` line is
|
|
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
|
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
|
it reruns every step too, but it also empties every cache mount, and Go's
|
|
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
|
packages survive between check builds, would start empty every time. 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 prune` and friends destroy a build cache shared with every
|
|
other build on the host. When a check is added or changed, prove it works by
|
|
planting a defect it must catch and watching 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.
|
|
|
|
- **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,
|
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
|
`Dockerfile`:
|
|
|
|
```dockerfile
|
|
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
|
# on every build, which reruns every RUN step after it. The steps that
|
|
# compile mount Go's build cache, which survives from build to build.
|
|
|
|
# Lint phase
|
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
|
FROM golangci/golangci-lint@sha256:... AS lint
|
|
ARG CACHEBUST
|
|
WORKDIR /src
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
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.
|
|
# golang:1.x, YYYY-MM-DD
|
|
FROM golang@sha256:... AS test
|
|
ARG CACHEBUST
|
|
WORKDIR /src
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
|
{ 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 this stage cannot run
|
|
# unless lint and test passed.
|
|
# golang:1.x-alpine, YYYY-MM-DD
|
|
FROM golang@sha256:... AS builder
|
|
ARG CACHEBUST
|
|
COPY --from=lint /src/go.sum /dev/null
|
|
COPY --from=test /src/go.sum /dev/null
|
|
RUN apk add --no-cache git
|
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
|
RUN git config --system --add safe.directory /src
|
|
WORKDIR /src
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
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-lint` image directly (it has
|
|
both Go and the linter), so nothing needs installing.
|
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
|
and a stage nothing depends on is not built at all, so without these two
|
|
lines a red gate would not fail the build.
|
|
- Keep the runtime stage last, and 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.
|
|
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
|
it for root), and only on the steps that compile: `golangci-lint run`,
|
|
both `go test` runs and `go build`. It keeps compiled packages between
|
|
check builds, and Go reuses one only when all its inputs are unchanged, so
|
|
it saves the compile without skipping a check. Nothing else is kept:
|
|
golangci-lint's own cache would replay stored findings in place of a lint
|
|
run, and Go builds from whatever files a kept module cache holds without
|
|
checking them again (only `go mod verify` does), so a file changed there
|
|
would go unnoticed. Go's build cache also holds test results, which is why
|
|
both `go test` runs carry `-count=1`.
|
|
- If the project uses `//go:embed` directives that reference build artifacts
|
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
|
create placeholder files so the embed directives resolve. 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-lint` image is Debian-based and
|
|
has no `apk`, so install with `apt-get` under the Debian package name
|
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
|
lists in the same `RUN`, so the layer does not keep them:
|
|
|
|
```dockerfile
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
```
|
|
|
|
- `.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/`, and that of a
|
|
submodule keeping its own `.git` directory. `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`). The stage that compiles has `git` (the
|
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
|
takes the version from the `VERSION` build argument when one is given,
|
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
|
tagged commit; on a later commit, the tag, the number of commits since it
|
|
and the short commit (`v1.2.3-4-gabc1234`); and 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 VERSION` has no default, and the build fails if the context carries
|
|
`.git` and the version still comes out empty, `dev` or `unknown`. 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 `.git` is a file (a linked worktree, or a repository
|
|
checked out as a submodule) is the exception: that file points to a git
|
|
directory outside the build context, so the build cannot read the version
|
|
and a plain `docker build .` fails; pass the version with
|
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
|
do.
|
|
|
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
|
without it the checkout leaves the job's token in `.git/config` for every
|
|
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
|
tags `git describe` needs: by default it clones shallow with no tags, and a
|
|
tagged repository's CI build would stamp a bare short commit id. The
|
|
workflow's `concurrency` block groups runs by workflow and branch
|
|
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
|
so a new push 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/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
|
so a successful run means every check passed; a bare `docker build .` does not
|
|
carry the same guarantee, because its gate phases may come from the cache. The
|
|
image build gets a `CACHEBUST` value 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. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
|
|
shared runner after 20 minutes. That allows for the three Docker builds
|
|
described above (the test phase, the lint phase, then the image), each held to
|
|
the 5-minute Docker build limit below, plus the bootstrap. 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.
|
|
|
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
|
|
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
|
that shims to it.
|
|
|
|
- All repos with software must have tests that run via the platform-standard
|
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
|
tests exist yet, add the most minimal test possible — e.g. importing the
|
|
module under test to verify it compiles/parses. There is no excuse for
|
|
`make test` to be a no-op.
|
|
|
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
|
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
|
20 and 60 seconds is still green, but the overage must be filed as an
|
|
improvement bug against that repo. Add a 90-second timeout to the test
|
|
invocation (`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.
|
|
|
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
|
on success (just package/suite summaries) while providing full diagnostic
|
|
detail on failure (every test case, every assertion). The command lives in the
|
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
|
Makefile form below is the same pattern for any repo-local invocation:
|
|
|
|
```makefile
|
|
test:
|
|
@<test-command> || \
|
|
{ echo "--- Rerunning with -v for details ---"; \
|
|
<test-command-with-v>; exit 1; }
|
|
```
|
|
|
|
Go example:
|
|
|
|
```makefile
|
|
test:
|
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
|
{ echo "--- Rerunning with -v for details ---"; \
|
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
|
```
|
|
|
|
`-count=1` is 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`), and 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, and in the `test` phase of the
|
|
`Dockerfile`, which mounts it to keep compiled packages between check
|
|
builds, so the phase's `go test` runs carry `-count=1` too. A new
|
|
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
|
|
|
|
Python example:
|
|
|
|
```makefile
|
|
test:
|
|
@python -m pytest || \
|
|
{ echo "--- Rerunning with -v for details ---"; \
|
|
python -m pytest -v; exit 1; }
|
|
```
|
|
|
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
|
already proved the tests are broken, so the build must not pass even if a
|
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
|
for diagnostic output.
|
|
|
|
- Docker builds must complete in under 5 minutes.
|
|
|
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
|
directories.
|
|
|
|
- `main` must always pass `make check`, no exceptions.
|
|
|
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
|
must be in `.gitignore`. No exceptions.
|
|
|
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
|
`node_modules/`, and 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. These patterns are written to `.gitignore`'s own
|
|
semantics, in which an unanchored pattern already matches at every depth; they
|
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
|
|
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
|
across unmodified leaves secrets in the build context.** 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 `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
|
therefore excludes only the copies at the repository root, while `config/.env`
|
|
and `certs/server.key` still reach the context and can land in an image layer
|
|
— which is 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: `.claude`, and the repo's own host-built
|
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
|
and likewise for `.envrc` and 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 `.dockerignore` from
|
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and 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 — so under `COPY . .` the build context
|
|
inflates by a multiple of the repo and another session's unreviewed work can
|
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
|
unanchored. In `.dockerignore` it 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 that the canonical
|
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
|
file and not the tracker: the directory is created in the agent's working
|
|
directory, so a repo running agents in subdirectories still ships
|
|
`services/api/.claude/` and must add its own anchored entry there.
|
|
|
|
- **A plain `docker build .` of a clone stamps the version that
|
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
|
the version they compute on the host; it takes precedence. They do this
|
|
byte-identically across repos:
|
|
|
|
```sh
|
|
# 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" .
|
|
```
|
|
|
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
|
fallback is applied — 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 is `ARG VERSION` in the stage that compiles, declared
|
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
|
Dockerfile declares no such `ARG` is 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.yml` sets `fetch-depth: 0` on its checkout step.
|
|
|
|
- **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.
|
|
|
|
- **No build artifacts in version control.** Code-derived data (compiled
|
|
bundles, minified output, generated assets) must never be committed to the
|
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
|
should generate these at build time. Notable exception: Go protobuf generated
|
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
|
downloads code but does not execute code generation.
|
|
|
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
|
|
|
- Never force-push to `main`.
|
|
|
|
- Make all changes on a feature branch. You can do whatever you want on a
|
|
feature branch.
|
|
|
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
|
_NEVER_ be modified by an agent: fetch it from
|
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
|
because it cannot be written once for every repo: the `deny` list of the
|
|
`test-support` depguard rule, where a repo names its own test-support packages
|
|
by full import path. A repo adds entries there and changes nothing else, and a
|
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
|
image
|
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
|
directive 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 lints
|
|
`go 1.27.1` but not `go 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.yml` in the same commit, whichever of
|
|
the two prompted the change: the canonical copy can name linters that an older
|
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
|
`default: all` switches on until the canonical copy disables them.
|
|
|
|
- **`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, so on an already-provisioned machine the pin is inert and a
|
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
|
image, 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 `-` reports `2.12.2` for a host
|
|
running `2.12.2-rc1` and skips the install;
|
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
|
mismatch, so the failure direction is a redundant install and never a
|
|
skipped one;
|
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
|
then through `PATH`, not through the directory the installer wrote to —
|
|
and fails naming the resolved path, since an install that a shadowing
|
|
binary hides succeeds while changing nothing any caller sees;
|
|
- 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/bootstrap` compares only its major version, because the node 22
|
|
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
|
nvm cannot install a prebuilt node on alpine.
|
|
|
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -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`.
|
|
|
|
- When pinning images or packages by hash, add a comment above the reference
|
|
with the version and date (YYYY-MM-DD).
|
|
|
|
- Use `yarn`, not `npm`.
|
|
|
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
|
|
|
- Simple projects should be configured with environment variables.
|
|
|
|
- Dockerized web services listen on port 8080 by default, overridable with
|
|
`PORT`.
|
|
|
|
- **HTTP/web services must be hardened for production internet exposure before
|
|
tagging 1.0.** This means full compliance with security best practices
|
|
including, without limitation, all of the following:
|
|
- **Security headers** on every response:
|
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
|
and `includeSubDomains`.
|
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
|
unavoidable, and document the reason.
|
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
|
- `X-Content-Type-Options: nosniff`.
|
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
|
- `Permissions-Policy` restricting access to browser features the
|
|
application does not use (camera, microphone, geolocation, etc.).
|
|
- **Request and response limits:**
|
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
|
unbounded input.
|
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
|
against slowloris attacks.
|
|
- `WriteTimeout` on the `http.Server`.
|
|
- `IdleTimeout` on the `http.Server`.
|
|
- Per-handler execution time limits via `context.WithTimeout` or
|
|
chi/stdlib `middleware.Timeout`.
|
|
- **Authentication and session security:**
|
|
- Rate limiting on password-based authentication endpoints. API keys are
|
|
high-entropy and not susceptible to brute force, so they are exempt.
|
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
|
authenticated via `Authorization` header (Bearer token, API key) are
|
|
exempt because the browser does not attach these automatically.
|
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
|
MD5, or SHA.
|
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
|
`Strict`) attributes.
|
|
- **Reverse proxy awareness:**
|
|
- True client IP detection when behind a reverse proxy
|
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
|
forwarded headers only from a configured set of trusted proxy
|
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
|
- **CORS:**
|
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
|
only for public, unauthenticated read-only APIs.
|
|
- **Error handling:**
|
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
|
or other implementation details to the client. Return generic error
|
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
|
- **TLS:**
|
|
- Services never terminate TLS directly. They are always deployed behind
|
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
|
set by the application so that the browser enforces HTTPS end-to-end.
|
|
|
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
|
hardening measure exists for HTTP services and is not listed here, it is
|
|
still expected. When in doubt, harden.
|
|
|
|
- `README.md` is the primary documentation. Required sections:
|
|
- **Description**: First line must include the project name, purpose,
|
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard (with that link), then documents each provided `script/`
|
|
entrypoint and its purpose.
|
|
- **Rationale**: Why does this exist?
|
|
- **Design**: How is the program structured?
|
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
|
the todo list in the README so a new agent can pick up where the last one
|
|
left off.
|
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
|
`LICENSE` file in the repo root and a License section in the README.
|
|
- **Author**: [@sneak](https://sneak.berlin).
|
|
|
|
- First commit of a new repo should contain only `README.md`.
|
|
|
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
|
committing.
|
|
|
|
- Use SemVer.
|
|
|
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
|
the binary.
|
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
|
tracking table itself. Nothing else.
|
|
- `001_schema.sql` — the full application schema.
|
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
Never edit existing migrations after release.
|
|
|
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
|
settings: the standard file from
|
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
|
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
|
one for another language it uses. 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.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 tools
|
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
|
`cmd/`
|
|
- `configs/` — configuration templates and examples
|
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
|
- `docs/` — documentation and markdown (README.md stays in root)
|
|
- `internal/` — Go internal packages
|
|
- `internal/db/migrations/` — database migrations
|
|
- `pkg/` — Go library packages
|
|
- `share/` — systemd units, data files
|
|
- `static/` — static assets (images, fonts, etc.)
|
|
- `web/` — web frontend source
|
|
|
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
|
templates. Fetch them from
|
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
|
|
|
- New repos must contain at minimum:
|
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
|
- `Makefile`
|
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
|
`install-precommit`)
|
|
- `Dockerfile`, `.dockerignore`
|
|
- `.gitea/workflows/check.yml`
|
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
|
- Python: `pyproject.toml`
|
|
|
|
- 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.
|