1 Commits
Author SHA1 Message Date
sneak fbbcdcb062 Rewrite REPO_POLICIES.md sentence by sentence (closes #136)
check / check (push) Canceled after 0s
Every sentence of prompts/REPO_POLICIES.md is rewritten against the
sentence rules: one idea per sentence, about 30 words at most, lists as
bulleted lists, subject and verb early, the main rule before any
exception. No rule's meaning changed: nothing is added, removed,
loosened or tightened, every reason stays, and code blocks, digests,
URLs, file names, commands and quoted strings are unchanged. Both repo
checklists carry the same rewrite of the sentences they quote word for
word.

Model: fable-5-1
2026-11-04 00:29:53 +00:00
4 changed files with 148 additions and 183 deletions

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-11-04: Rewrote `prompts/REPO_POLICIES.md` sentence by sentence - 2026-11-03: Rewrote `prompts/REPO_POLICIES.md` sentence by sentence
(https://git.eeqj.de/sneak/prompts/issues/136), on sneak's yes at (https://git.eeqj.de/sneak/prompts/issues/136), on sneak's yes at
https://git.eeqj.de/sneak/prompts/issues/136#issuecomment-165193. Every rule https://git.eeqj.de/sneak/prompts/issues/136#issuecomment-165193. Every rule
keeps its meaning and its place, and `must` and `should` stay as they were. keeps its meaning and its place, and `must` and `should` stay as they were.
+8 -9
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-11-04 last_modified: 2026-11-03
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -112,14 +112,13 @@ with your task.
`git describe` does not need them, and each can hold a credential: a `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 password in a remote URL, or the token the CI checkout step stores there. A
submodule name may have a `config` segment, as in `config`, `deploy/config` submodule whose name has a `config` segment (`config`, `deploy/config`,
or `config/lib`. Such a submodule loses its whole git directory to `config/lib`) loses its whole git directory to `**/.git/modules/**/config`,
`**/.git/modules/**/config`, and Go's version stamping then fails the build. and Go's version stamping then fails the build. Give it a name without that
Give it a name without that segment (`git submodule add --name`). The stage segment (`git submodule add --name`). The stage that compiles has `git`: the
that compiles has `git`: the Debian Go image has it, and an alpine one needs Debian Go image has it, and an alpine one needs `apk add --no-cache git`. It
`apk add --no-cache git`. That stage takes the version from the `VERSION` takes the version from the `VERSION` build argument when one is given,
build argument when one is given, otherwise from otherwise from `git describe --tags --always`. That gives:
`git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
- the tag, the number of commits since it and the short commit - the tag, the number of commits since it and the short commit
(`v1.2.3-4-gabc1234`), on a later commit (`v1.2.3-4-gabc1234`), on a later commit
+7 -8
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-11-04 last_modified: 2026-11-03
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -82,13 +82,12 @@ Template files can be fetched from:
`git describe` does not need them, and each can hold a credential: a `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 password in a remote URL, or the token the CI checkout step stores
there. A submodule name may have a `config` segment, as in `config`, there. A submodule whose name has a `config` segment (`config`,
`deploy/config` or `config/lib`. Such a submodule loses its whole git `deploy/config`, `config/lib`) loses its whole git directory to
directory to `**/.git/modules/**/config`, and Go's version stamping then `**/.git/modules/**/config`, and Go's version stamping then fails the
fails the build. Give it a name without that segment build. Give it a name without that segment (`git submodule add --name`).
(`git submodule add --name`). The stage that compiles has `git`: the The stage that compiles has `git`: the Debian Go image has it, and an
Debian Go image has it, and an alpine one needs alpine one needs `apk add --no-cache git`. It takes the version from the
`apk add --no-cache git`. That stage takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
`git describe --tags --always`. That gives: `git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
+132 -165
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-11-04 last_modified: 2026-11-03
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -16,9 +16,9 @@ style conventions are in separate documents:
--- ---
- Cross-project documentation (such as this file) must include - 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 `last_modified: YYYY-MM-DD` in its YAML front matter. The date is how a copy
with the authoritative source as policies evolve. is kept in sync with the authoritative source as policies evolve.
- **External references must be pinned by cryptographic hash.** This is the - **External references must be pinned by cryptographic hash.** This is the
single most important rule in this document. It covers everything fetched from single most important rule in this document. It covers everything fetched from
@@ -88,10 +88,10 @@ style conventions are in separate documents:
- `script/check` - `script/check`
- `script/docker` - `script/docker`
The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they The scripts must be POSIX sh: `#!/bin/sh`, `set -eu`, no bashisms. That is
run in minimal containers; alpine images, for example, have no bash. Locate so they run in minimal containers; alpine images, for example, have no bash.
the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd` there Locate the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd`
before acting. there before acting.
From the standard's canonical set we use `bootstrap`, `setup`, `test`, and From the standard's canonical set we use `bootstrap`, `setup`, `test`, and
`cibuild`. `setup` makes the repo ready for development after a fresh clone: `cibuild`. `setup` makes the repo ready for development after a fresh clone:
@@ -103,30 +103,31 @@ style conventions are in separate documents:
that order; apt runs noninteractive. For node it uses the installed node that order; apt runs noninteractive. For node it uses the installed node
only when its major version is the pinned one. When node is missing or has only when its major version is the pinned one. When node is missing or has
another major, it installs the PINNED node version via nvm, and installs another major, it installs the PINNED node version via nvm, and installs
yarn and the packages under it. The CI runner image ships node 24, for yarn and the packages under it. The node 24 the CI runner image ships is one
example. nvm itself is installed first if missing, from a hash-verified such other major. nvm itself is installed first if missing, from a
GitHub release archive, never `curl | sh`. bash is installed as an explicit hash-verified GitHub release archive, never `curl | sh`. bash is installed
prerequisite, since nvm requires bash. Only the major version is compared as an explicit prerequisite, since nvm requires bash. Only the major version
because the `Dockerfile` stages start from a node image whose exact version is compared, for two reasons. The `Dockerfile` stages start from a node
is not the pin, and nvm cannot install a prebuilt node on alpine. yarn is image whose exact version is not the pin, and nvm cannot install a prebuilt
pinned via `corepack prepare yarn@<version> --activate`. Never install node on alpine. yarn is pinned via
"latest" or "lts"; always exact versions. `corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions.
`script/cibuild` runs the CI build, and the Gitea workflow calls it. The `script/cibuild` runs the CI build, and the Gitea workflow calls it. It
script changes to the repo root, runs `script/bootstrap`, runs changes to the repo root, runs `script/bootstrap`, runs `script/check`, and
`script/check`, and builds the image with the version. **`script/cibuild` builds the image with the version. **`script/cibuild` runs
runs `script/bootstrap` first**, because the workflow checks out the repo `script/bootstrap` first.** The workflow checks out the repo and runs
and runs nothing else, while `script/fmt-check` runs the formatter on the nothing else, while `script/fmt-check` runs the formatter on the host.
host. On a pristine checkout with nothing installed, the run dies there, Without the bootstrap, on a pristine checkout with nothing installed, the
after the containerised gates have passed. **The bootstrap alone is not run dies there, after the containerised gates have passed. **The bootstrap
enough.** When `script/bootstrap` installs node and yarn under nvm, it alone is not enough.** When `script/bootstrap` installs node and yarn under
leaves neither on the `PATH` of the shell that called it. A bare `yarn` then nvm, it leaves neither on the `PATH` of the shell that called it. A bare
either exits 127 or runs under another node. The host entrypoints that need `yarn` then either exits 127 or runs under another node. The host
yarn, `script/fmt` and `script/fmt-check`, therefore use the same test as entrypoints that need yarn, `script/fmt` and `script/fmt-check`, therefore
`script/bootstrap`. When the node on `PATH` has the pinned major version, use the same test as `script/bootstrap`. When the node on `PATH` has the
they run the `yarn` on `PATH`. Otherwise they load nvm through pinned major version, they run the `yarn` on `PATH`. Otherwise they load nvm
`$HOME/.nvm/nvm.sh` and run yarn under the pinned node. A runner carrying through `$HOME/.nvm/nvm.sh` and run yarn under the pinned node. A runner
nothing but docker and git then gets through `script/check`. carrying nothing but docker and git then gets through `script/check`.
Four further scripts are our own extensions to the standard: Four further scripts are our own extensions to the standard:
- `script/check` runs `script/test`, `script/lint` and `script/fmt-check`. - `script/check` runs `script/test`, `script/lint` and `script/fmt-check`.
@@ -169,7 +170,7 @@ style conventions are in separate documents:
cannot be built unless they pass. For non-server repos the final stage brings 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 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 gate phases and the build stage start from their pinned base images and
install what those images lack in either of these ways: install what those images lack. They do that in one of two ways:
- inline, as the canonical Go `Dockerfile` below does for `git` - inline, as the canonical Go `Dockerfile` below does for `git`
- by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile` - by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile`
does for its yarn packages does for its yarn packages
@@ -193,9 +194,9 @@ style conventions are in separate documents:
**A stage that is not the last one in the file is built only when the final **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 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 two gates are always invoked by name here. It is also why the final stage
`COPY --from=` of a harmless file from each of them. Without that carries a `COPY --from=` of a harmless file from each of them. Without that
dependency, a plain `docker build .` builds the last stage alone and exits 0 edge, a plain `docker build .` builds the last stage alone and exits 0
having linted and tested nothing. having linted and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase **The gate builds write no image.** With `--output type=cacheonly` the phase
@@ -204,12 +205,12 @@ style conventions are in separate documents:
image holds the toolchain. A build given neither `--output` nor `-t` writes 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 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 CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image. Each script assigns and tag it, so each build replaces the previous image. Each assigns the tag
the tag on its own line before the build, so `set -e` stops it where on its own line before the build, so `set -e` stops it where
`script/projectname` fails. `script/projectname` fails.
Inside a phase the tool is invoked directly: `golangci-lint`, `go test`, Inside a phase the tool is invoked directly: `golangci-lint`, `go test`,
`eslint`, `prettier`. The tool is never invoked through `make lint` or `eslint`, `prettier`. It is never invoked through `make lint` or
`script/test`. Those are themselves a `docker build`, and would recurse into `script/test`. Those are themselves a `docker build`, and would recurse into
a daemon that does not exist in a build step. Formatting is the exception 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 and stays on the host: `script/fmt` writes the working tree, and
@@ -223,11 +224,9 @@ style conventions are in separate documents:
caller cannot tell from real findings. Both have produced wrong verdicts in caller cannot tell from real findings. Both have produced wrong verdicts in
this org, in both directions. A container has its own `TMPDIR`, so the lock this org, in both directions. A container has its own `TMPDIR`, so the lock
is out of reach. The lint phase does keep golangci-lint's cache between is out of reach. The lint phase does keep golangci-lint's cache between
check builds (below), but it lints every checkout at `/src`. A finding is check builds (below). But it lints every checkout at `/src`, and a finding
taken from that cache only when all of these are the same: is taken from that cache only for the same files, settings and golangci-lint
- the files version.
- the settings
- the golangci-lint version
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker - **Any build that runs checks is given a new `CACHEBUST` value.** Docker
invalidates a `COPY` layer only when the copied content changes. On an invalidates a `COPY` layer only when the copied content changes. On an
@@ -235,7 +234,8 @@ style conventions are in separate documents:
executes, and the build still exits 0. Every `docker build` in `script/` executes, and the build still exits 0. Every `docker build` in `script/`
therefore passes `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the 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's process id. So no two builds share a value, not even the three in one
`script/cibuild`. These are the four, and there is no fifth: `script/cibuild`. The scripts that run `docker build` are these four, and
there is no fifth:
- `script/lint` - `script/lint`
- `script/test` - `script/test`
- `script/cibuild` - `script/cibuild`
@@ -251,17 +251,13 @@ style conventions are in separate documents:
packages from `Pipfile.lock` packages from `Pipfile.lock`
A stage with none of these steps declares `ARG CACHEBUST` right after A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that also installs anything else comes after `ARG CACHEBUST` `FROM`. A step that installs anything else as well, such as a
too. A `script/bootstrap` that also installs node packages is one. A new `script/bootstrap` that also installs node packages, comes after it too. A
value reruns every `RUN` step after that line; `COPY` steps are compared by new value reruns every `RUN` step after that line; `COPY` steps are compared
content and need nothing. Docker keeps the layers above the line until one by content and need nothing. Docker keeps the layers above the line until
of these changes: the base image, their own lines or the files they copy change. A stage
- the base image without the `ARG` line is served from cache on an unchanged tree, so a
- their own lines repository re-vendors the scripts and adds the `ARG` lines in one commit.
- the files they copy
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`. That flag reruns every step too, but The scripts do not pass `--no-cache`. That flag reruns every step too, but
it also builds the layers above the line again and empties every cache it also builds the layers above the line again and empties every cache
mount. Go's build cache and golangci-lint's would then start empty every mount. Go's build cache and golangci-lint's would then start empty every
@@ -270,19 +266,14 @@ style conventions are in separate documents:
ran: a sub-second build reporting success is a cache hit, not a result. 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 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 build cache shared with every other build on the host. When a check is added
or changed, prove it works: or changed, prove it works: plant a defect it must catch, watch the run fail
- plant a defect it must catch on it, then revert the defect. A green run alone shows neither that the
- watch the run fail on it check ran nor that it covers what it should.
- 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 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 The lint phase is based on the `golangci/golangci-lint` image, pinned by hash,
hash), so lint failures surface in seconds rather than after a full compile. so lint failures surface in seconds rather than after a full compile. The test
The test phase is based on the Debian Go image. The canonical Go repo phase is based on the Debian Go image. The canonical Go repo `Dockerfile`:
`Dockerfile`:
```dockerfile ```dockerfile
# In every stage ARG CACHEBUST comes right after the steps that install # In every stage ARG CACHEBUST comes right after the steps that install
@@ -376,22 +367,22 @@ style conventions are in separate documents:
- The lint phase uses the `golangci/golangci-lint` image directly, so - The lint phase uses the `golangci/golangci-lint` image directly, so
nothing needs installing: it has both Go and the linter. nothing needs installing: it has both Go and the linter.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the dependency on that phase. BuildKit runs stages in parallel purpose is the ordering edge. BuildKit runs stages in parallel by default,
by default, and a stage nothing depends on is not built at all. Without and a stage nothing depends on is not built at all. Without these two
these two lines a red gate would not fail the build. lines a red gate would not fail the build.
- Keep the runtime stage last. If you add a stage after it, give it the same - Keep the runtime stage last. If you add a stage after it, give it the same
two copies. A plain `docker build .` builds the last stage's chain and two copies: a plain `docker build .` builds the last stage's chain and
nothing else. nothing else.
- Check builds keep these, and none of them can hand back a stale result: - 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 - The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the modules. Docker installs the packages again when the base image or the
install line changes. Docker downloads the modules again when `go.mod` install line changes. It downloads the modules again when `go.mod` or
or `go.sum` changes. Go checks every module against `go.sum` as it `go.sum` changes. Go checks every module against `go.sum` as it
downloads it, and no later build can change a kept layer. The modules downloads it, and no later build can change a kept layer. The modules
are kept in a layer rather than a cache mount. A cache mount is shared are kept in a layer rather than a cache mount, for two reasons. A
by every build on the host, and Go builds from whatever files the cache mount is shared by every build on the host. And Go builds from
mount holds without checking them again (only `go mod verify` does). A whatever files it holds without checking them again (only
file changed there would go unnoticed. `go mod verify` does), so a file changed there would go unnoticed.
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for - Go's build cache (`/root/.cache/go-build`, where Go keeps it for
root), mounted on the steps that compile: `golangci-lint run`, both root), mounted on the steps that compile: `golangci-lint run`, both
`go test` runs and `go build`. Go reuses a compiled package only when `go test` runs and `go build`. Go reuses a compiled package only when
@@ -440,13 +431,12 @@ style conventions are in separate documents:
`git describe` does not need them, and each can hold a credential: a `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 password in a remote URL, or the token the CI checkout step stores
there. A submodule name may have a `config` segment, as in `config`, there. A submodule whose name has a `config` segment (`config`,
`deploy/config` or `config/lib`. Such a submodule loses its whole git `deploy/config`, `config/lib`) loses its whole git directory to
directory to `**/.git/modules/**/config`, and Go's version stamping then `**/.git/modules/**/config`, and Go's version stamping then fails the
fails the build. Give it a name without that segment build. Give it a name without that segment (`git submodule add --name`).
(`git submodule add --name`). The stage that compiles has `git`: the The stage that compiles has `git`: the Debian Go image has it, and an
Debian Go image has it, and an alpine one needs alpine one needs `apk add --no-cache git`. It takes the version from the
`apk add --no-cache git`. That stage takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
`git describe --tags --always`. That gives: `git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
@@ -486,30 +476,24 @@ style conventions are in separate documents:
The tests run after `ARG CACHEBUST`, on every check build. In every other The tests run after `ARG CACHEBUST`, on every check build. In every other
stage, a step that installs Python packages comes after `ARG CACHEBUST`. stage, a step that installs Python packages comes after `ARG CACHEBUST`.
- When `script/bootstrap` runs in that step, it installs nothing beyond - When `script/bootstrap` runs in that step, it installs nothing beyond
these: system packages, pipenv and its dependencies, and the packages from
- system packages `Pipfile.lock`. It installs no Python there: the tests use the base
- pipenv and its dependencies image's Python, which the image's hash pins. If it installs anything else
- the packages from `Pipfile.lock` there, the step goes after `ARG CACHEBUST`.
`script/bootstrap` installs no Python there: the tests use the base
image's Python, which the image's hash pins. If it installs anything
else there, the step goes after `ARG CACHEBUST`.
- PyPI is not trusted, so `script/bootstrap` installs pipenv and its - PyPI is not trusted, so `script/bootstrap` installs pipenv and its
dependencies by hash. It does so with `pip install --require-hashes -r` dependencies by hash. It does so with `pip install --require-hashes -r`
and a requirements file in `script/` that lists each of them with `==` and and a requirements file in `script/` that lists each of them with `==` and
its `--hash`. pip refuses to install a dependency the file does not list. its `--hash`. pip refuses to install a dependency the file does not list.
- It then runs `pipenv install --deploy`, with `--dev` when the tests need - It then runs `pipenv install --deploy`, with `--dev` when the tests need
the development packages. `--deploy` does all of these: the development packages. `--deploy` installs only what `Pipfile.lock`
- installs only what `Pipfile.lock` names names, checks every package against the lockfile's hashes, and fails when
- checks every package against the lockfile's hashes the lockfile is out of date with `Pipfile`.
- fails when the lockfile is out of date with `Pipfile` - A kept layer holds nothing a new install would refuse: pip checked pipenv
- A kept layer holds nothing a new install would refuse: pip checked the and its dependencies, and pipenv checked the lockfile's packages, against
hashes of pipenv and its dependencies, and pipenv checked the hashes of their hashes. pipenv creates its virtual environment with the `virtualenv`
the lockfile's packages. pipenv creates its virtual environment with the package. The packages that `virtualenv` adds come from wheels inside that
`virtualenv` package. The packages that `virtualenv` adds come from wheels package, so the hash pip checked for it covers them. Two things in the
inside that package, so the hash pip checked for it covers them. Two layer are not fixed by a hash:
things in the layer are not fixed by a hash:
- A package with no ready-built wheel for the image is built from - A package with no ready-built wheel for the image is built from
source. pip downloads the build tools without a hash check, so a kept source. pip downloads the build tools without a hash check, so a kept
layer's build of such a package may differ from a new one. layer's build of such a package may differ from a new one.
@@ -519,10 +503,9 @@ style conventions are in separate documents:
A cache mount is shared by every build on the host, and nothing checks its A cache mount is shared by every build on the host, and nothing checks its
files again. files again.
In the example below, `script/bootstrap` does all of these: In this example, `script/bootstrap` finds `make` and `git` already
- finds `make` and `git` already installed installed, installs pipenv and its dependencies by hash, and runs
- installs pipenv and its dependencies by hash `pipenv install --dev --deploy`:
- runs `pipenv install --dev --deploy`
```dockerfile ```dockerfile
# Test phase. Both pytest runs share one 90-second timeout, so a hang # Test phase. Both pytest runs share one 90-second timeout, so a hang
@@ -547,12 +530,12 @@ style conventions are in separate documents:
``` ```
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - 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 runs `script/cibuild` on push. Its only other step checks out the repo, with
repo, with `persist-credentials: false`. `script/cibuild` needs no token, and `persist-credentials: false`. `script/cibuild` needs no token, and without
without that setting the checkout leaves the job's token in `.git/config` for that setting the checkout leaves the job's token in `.git/config` for every
every later step. The checkout step also sets `fetch-depth: 0`, which fetches later step. The checkout step also sets `fetch-depth: 0`, which fetches the
the tags `git describe` needs. By default it clones shallow with no tags, and tags `git describe` needs. By default it clones shallow with no tags, and a
a tagged repository's CI build would stamp a bare short commit id. The tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`. (`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`.
A new push then cancels the older run on the same branch, queued or running, A new push then cancels the older run on the same branch, queued or running,
@@ -566,7 +549,7 @@ style conventions are in separate documents:
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the 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 shared runner after 20 minutes. That allows for the three Docker builds
described above, each held to the 5-minute Docker build limit below, plus the described above, each held to the 5-minute Docker build limit below, plus the
bootstrap. Those builds are the test phase, the lint phase, then the image. A bootstrap. The three are the test phase, the lint phase, then the image. A
separate workflow limited to `main` by a `branches` list under `on: push` 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 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. that list and push, then remove the branch from the list again before merging.
@@ -660,9 +643,9 @@ style conventions are in separate documents:
passing result in its cache directory (`GOCACHE`). When the same tests run passing result in its cache directory (`GOCACHE`). When the same tests run
again on unchanged code, it prints that result, marked `(cached)`, without 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 running them. The directory lasts from one run to the next on a developer's
machine, where this target runs. The directory also lasts in the `test` machine, where this target runs. It also lasts in the `test` phase of the
phase of the `Dockerfile`, which mounts it to keep compiled packages between `Dockerfile`, which mounts it to keep compiled packages between check
check builds, so the phase's `go test` runs carry `-count=1` too. A new 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. `CACHEBUST` value (above) is what makes that step run on an unchanged tree.
Python example: Python example:
@@ -720,14 +703,14 @@ style conventions are in separate documents:
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root. `config/.env` and therefore excludes only the copies at the repository root. `config/.env` and
`certs/server.key` still reach the context and can land in an image layer. `certs/server.key` still reach the context and can land in an image layer.
Such a `.dockerignore` is more dangerous than a short file with no secret That is more dangerous than a short file with no secret patterns at all,
patterns at all, because it reads as solved and stops anyone looking. Give because it reads as solved and stops anyone looking. Give every
every depth-independent pattern the `**/` prefix, and leave only genuinely depth-independent pattern the `**/` prefix, and leave only genuinely
root-anchored entries unprefixed. Those are `.claude`, and the repo's own root-anchored entries unprefixed. Those are `.claude`, and the repo's own
host-built binary. The binary is written `/myapp` and never `**/myapp`, which host-built binary. The binary is written `/myapp` and never `**/myapp`, which
would also match `cmd/myapp/` and delete the package directory from the would also match `cmd/myapp/` and delete the package directory from the
context. Matching is case-sensitive, and an all-caps copy of each pattern context. Matching is case-sensitive, and an ALL-CAPS twin per pattern still
still misses `Server.Key`. So secret names use character ranges: misses `Server.Key`. So secret names use character ranges:
`**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for `.envrc` and the `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for `.envrc` and the
extensionless SSH keys. Where such a pattern also catches something the build extensionless SSH keys. Where such a pattern also catches something the build
needs, re-include it with a negation (`!docs/example.env`). Deleting the needs, re-include it with a negation (`!docs/example.env`). Deleting the
@@ -743,12 +726,11 @@ style conventions are in separate documents:
be copied into an image layer. In `.gitignore` the entry is `.claude/`, be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap. The canonical that name from the build. Anchoring carries a known gap, which the canonical
`.dockerignore` states the gap in its own comment, since consuming repos `.dockerignore` states in its own comment, since consuming repos receive the
receive the file and not the tracker. The gap is that the directory is created file and not the tracker. The directory is created in the agent's working
in the agent's working directory. A repo running agents in subdirectories directory, so a repo running agents in subdirectories still ships
therefore still ships `services/api/.claude/` and must add its own anchored `services/api/.claude/` and must add its own anchored entry there.
entry there.
- **A plain `docker build .` of a clone stamps the version that - **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives.** The version is derived from the `.git` `git describe --tags --always` gives.** The version is derived from the `.git`
@@ -772,10 +754,10 @@ style conventions are in separate documents:
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
than failing. The `[ -n "$version" ]` line is the single place the fallback than failing. The `[ -n "$version" ]` line is the single place the fallback
is applied. That line is a live check that fires on a build from an export is applied. It is a live check that fires on a build from an export with no
with no `.git`, and on a repository with no commits yet. Do not fold it into `.git`, and on a repository with no commits yet. Do not fold it into the
the substitution as `|| echo unknown`, which makes the guard unreachable. substitution as `|| echo unknown`, which makes the guard unreachable. The
The Dockerfile's side is `ARG VERSION` in the stage that compiles, declared Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped. Passing `VERSION` to a repo whose 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 Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
@@ -790,15 +772,11 @@ style conventions are in separate documents:
transfers only the delta from the previous build. transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data must never be - **No build artifacts in version control.** Code-derived data must never be
committed to the repository if it can be avoided. Code-derived data includes: committed to the repository if it can be avoided: compiled bundles, minified
- compiled bundles output, generated assets. The build process (e.g. Dockerfile, Makefile) should
- minified output generate these at build time. Notable exception: Go protobuf generated files
- generated assets (`.pb.go`) ARE committed. Repos need to work with `go get`, which downloads
code but does not execute code generation.
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 use `git add -A` or `git add .`. Always stage files explicitly by name.
@@ -818,8 +796,8 @@ style conventions are in separate documents:
`test-support` depguard rule. There a repo names its own test-support packages `test-support` depguard 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 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 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 v2.15.0 (released 2026-10-16). It is pinned as the digest of the lint phase's
phase's base image, base image,
`golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481`, `golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481`,
which reports `2.15.0 built with go1.27.0 from 25c5369a`. A module's `go` which reports `2.15.0 built with go1.27.0 from 25c5369a`. A module's `go`
directive must not name a newer Go minor version than the one golangci-lint directive must not name a newer Go minor version than the one golangci-lint
@@ -839,27 +817,22 @@ style conventions are in separate documents:
disagree about what the tool even is. The canonical form: disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version - 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 token. A parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install. running `2.12.2-rc1` and skips the install;
- treats `--version` output as a mismatch when it is any of these: - treats absent, non-zero, empty or unrecognised `--version` output as a
- absent mismatch, so the failure direction is a redundant install and never a
- non-zero skipped one;
- empty
- unrecognised
So a failure causes a redundant install, never a skipped one.
- after installing, re-resolves the binary the way callers do, with - after installing, re-resolves the binary the way callers do, with
`hash -r` and then through `PATH`, not through the directory the installer `hash -r` and then through `PATH`, not through the directory the installer
wrote to. It fails and names the resolved path. A shadowing binary can wrote to. It fails naming the resolved path, since an install that a
hide an install, which then succeeds while no caller sees any change. shadowing binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths. A - 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 function defined and never invoked has the same exit status and the same
empty output as one that worked. empty output as one that worked.
Node is the exception to the whole-token comparison. The canonical Node is the exception to the whole-token comparison. The canonical
`script/bootstrap` compares only node's major version, because the `script/bootstrap` compares only its major version, for two reasons. The
`Dockerfile` stages start from a node 22 alpine image, which is not the node 22 alpine image the `Dockerfile` stages start from is not the exact
exact pin, and nvm cannot install a prebuilt node on alpine. pin, and nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
@@ -927,15 +900,9 @@ style conventions are in separate documents:
an explicit allowlist of known origins. Wildcard (`*`) is acceptable an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs. only for public, unauthenticated read-only APIs.
- **Error handling:** - **Error handling:**
- Internal errors must never leak implementation details to the client, - Internal errors must never leak stack traces, SQL queries, file paths,
such as: or other implementation details to the client. Return generic error
- stack traces messages in production; detailed errors only when `DEBUG` is enabled.
- SQL queries
- file paths
Return generic error messages in production; detailed errors only
when `DEBUG` is enabled.
- **TLS:** - **TLS:**
- Services never terminate TLS directly. They are always deployed behind - Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain a TLS-terminating reverse proxy. The service itself listens on plain