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
This commit is contained in:
2026-11-04 00:29:53 +00:00
parent 8400e4cd1f
commit fbbcdcb062
4 changed files with 481 additions and 366 deletions
+7
View File
@@ -21,6 +21,13 @@ fmt-check, and commit.
# Completed Steps
- 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#issuecomment-165193. Every rule
keeps its meaning and its place, and `must` and `should` stay as they were.
Long sentences are split, comma-chained lists are now bulleted lists, and
every reason stays. Code blocks, digests, URLs, file names, commands and
quoted strings are unchanged.
- 2026-11-03: The canonical golangci-lint is now v2.15.0, still built with
go1.27.0, so the `go` directive rule is unchanged (issue 142). Repositories
pick this up on their next re-vendor. Each item in the canonical
+36 -31
View File
@@ -105,37 +105,42 @@ with your task.
`services/api/.claude/`: add anchored entries for those directories.
- [ ] If the repo embeds a version in a binary: `.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=...`. `script/docker` and `script/cibuild` already
pass the version they compute on the host; it takes precedence. The
canonical `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its
checkout step, which otherwise clones shallow and fetches no tags, so a CI
build finds the tag too.
(`**/.git/config`, `**/.git/modules/**/config`):
- the repository's own
- each submodule's under `.git/modules/`
- 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, and an alpine one needs `apk add --no-cache git`. It
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
- the tag, the number of commits since it and the short commit
(`v1.2.3-4-gabc1234`), on a later commit
- the short commit, when no tag is reachable
The stage that compiles also marks its working directory safe for git
(`git config --system --add safe.directory /src`). A context sent as a tar
stream keeps the sender's file owners, and git refuses a checkout owned by
another user, so the version would come out empty. `ARG VERSION` has no
default. 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
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 plain `docker build .` fails. Pass
the version with `--build-arg VERSION=...`. `script/docker` and
`script/cibuild` already pass the version they compute on the host; it takes
precedence. The canonical `.gitea/workflows/check.yml` sets `fetch-depth: 0`
on its checkout step, which otherwise clones shallow and fetches no tags, so
a CI build finds the tag too.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push, checks out with `persist-credentials: false` and with
`fetch-depth: 0` (which fetches the tags `git describe` needs), carries
+36 -28
View File
@@ -75,34 +75,41 @@ Template files can be fetched from:
anchored entry.
- If the image embeds a version in a binary: `.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.
(`**/.git/config`, `**/.git/modules/**/config`):
- the repository's own
- each submodule's under `.git/modules/`
- 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, and an
alpine one needs `apk add --no-cache git`. It 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
- the tag, the number of commits since it and the short commit
(`v1.2.3-4-gabc1234`), on a later commit
- the short commit, when no tag is reachable
The stage that compiles also marks its working directory safe for git
(`git config --system --add safe.directory /src`). A context sent as a
tar stream keeps the sender's file owners, and git refuses a checkout
owned by another user, so the version would come out empty.
`ARG VERSION` has no default. 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 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 plain `docker build .` fails. Pass the version
with `--build-arg VERSION=...`, as `script/docker` and `script/cibuild`
already do.
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking
its tool directly rather than through `make` or `script/`, and the final
stage carries a `COPY --from=` of a harmless file from each so the image
@@ -147,6 +154,7 @@ Template files can be fetched from:
- Server: the final stage builds and runs the application
- Non-server: the final stage brings up the dev environment
- Image pinned by sha256 hash with version/date comment
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push, checks out with `persist-credentials: false` and
with `fetch-depth: 0` (which fetches the tags `git describe` needs),
+402 -307
View File
@@ -16,22 +16,32 @@ style conventions are in separate documents:
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- Cross-project documentation, such as this file, must include
`last_modified: YYYY-MM-DD` in its YAML front matter. The date is how a copy
is kept in sync with the authoritative source as policies evolve.
- **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.
Reference an external dependency by its content hash (Docker `@sha256:...`, Go
module hash in `go.sum`, npm integrity hash in lockfile, GitHub Actions
`@<commit-sha>`). 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.
- **External references must be pinned by cryptographic hash.** This is the
single most important rule in this document. It covers everything fetched from
a remote source, including:
- Docker base images
- Go modules
- npm packages
- GitHub Actions
A version tag such as `@v4`, `@latest` or `:3.21` is server-mutable, and
therefore a remote code execution vulnerability. Reference an external
dependency by its content hash:
- Docker: `@sha256:...`
- Go: the module hash in `go.sum`
- npm: the integrity hash in the lockfile
- GitHub Actions: `@<commit-sha>`
This also means never `curl | bash` to install tools like pyenv, nvm,
rustup, etc. Instead, download a specific release archive from GitHub,
verify its hash, and only then install. The hash is hardcoded in the
Dockerfile or script. Unverified install scripts are arbitrary remote code
execution. Double-check every external reference in every file before
committing.
There is one exception: a package from the distribution's own package index
needs no version and no hash. Pulling that index is also allowed. The
@@ -52,62 +62,90 @@ style conventions are in separate documents:
own, so the exception does not cover it.
- 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`.
- `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`
- `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).
pattern. The implementation of each Makefile target lives in an executable
script in `script/`, and the Makefile targets are thin shims that call them:
- `script/bootstrap`
- `script/setup`
- `script/test`
- `script/lint`
- `script/fmt`
- `script/fmt-check`
- `script/check`
- `script/docker`
The scripts must be POSIX sh: `#!/bin/sh`, `set -eu`, no bashisms. That is
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.
From the standard's canonical set we use `bootstrap`, `setup`, `test`, and
`cibuild`. `setup` makes the repo ready for development after a fresh clone:
it runs `bootstrap`, then `install-precommit`, plus any repo-specific
initialization.
`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. When node is missing or has
another major, it installs the PINNED node version via nvm, and installs
yarn and the packages under it. The node 24 the CI runner image ships is one
such other major. nvm itself is installed first if missing, from a
hash-verified GitHub release archive, never `curl | sh`. bash is installed
as an explicit prerequisite, since nvm requires bash. Only the major version
is compared, for two reasons. 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, and the Gitea workflow calls it. It
changes to the repo root, runs `script/bootstrap`, runs `script/check`, and
builds the image with the version. **`script/cibuild` runs
`script/bootstrap` first.** The workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host.
Without the bootstrap, 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. A bare
`yarn` then 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`. 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.
- `script/projectname` (literally that filename) simply outputs the
project's name.
Scripts that need the name call `script/projectname`, so those scripts stay
byte-identical across all repos. `script/docker`, for example, assembles its
image tag from it. Repo-type-specific pre-commit extras belong in
`script/precommit`, not in the hook itself; `go mod tidy` verification in Go
repos is one. 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
@@ -115,26 +153,33 @@ style conventions are in separate documents:
- 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.
- running a local development server (`make run`, `make dev`)
- re-initializing or migrating the database (`make db-reset`,
`make migrate`)
- building artifacts (`make build`)
- generating code
- seeding data
- anything else a developer would do regularly
If someone checks out the repo and types `make<tab>`, they should see every
meaningful operation available. A new contributor should be able to
understand the entire development workflow by reading the Makefile.
- 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.
`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
gate phases and the build stage start from their pinned base images and
install what those images lack. They do that in one of two ways:
- inline, as the canonical Go `Dockerfile` below does for `git`
- 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
@@ -149,10 +194,10 @@ style conventions are in separate documents:
**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.
two gates are always invoked by name here. It is also 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.
@@ -160,40 +205,46 @@ style conventions are in separate documents:
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
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.
Inside a phase the tool is invoked directly: `golangci-lint`, `go test`,
`eslint`, `prettier`. It is never invoked through `make lint` or
`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
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 `TMPDIR`, so the lock is
out of reach. The lint phase does keep golangci-lint's cache between check
builds (below), but it lints every checkout at `/src`, and a finding is
taken from that cache only for the same files, settings and golangci-lint
than location. A second checkout of the same content is therefore served the
first one's findings. A host-global lock in `$TMPDIR` also 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 `TMPDIR`, so the lock
is out of reach. The lint phase does keep golangci-lint's cache between
check builds (below). But it lints every checkout at `/src`, and a finding
is taken from that cache only for the same files, settings and golangci-lint
version.
- **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. In
every stage of the `Dockerfile`, every step that lints, tests or compiles
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps:
invalidates a `COPY` layer only when the copied content changes. On an
unchanged tree the check `RUN` is therefore 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`. The scripts that run `docker build` are these four, and
there is no fifth:
- `script/lint`
- `script/test`
- `script/cibuild`
- `script/docker`
`script/check` runs the two gate phases and `script/fmt-check`, and 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/bootstrap` to install a Python `test` phase's
@@ -207,23 +258,22 @@ style conventions are in separate documents:
the base image, their own lines or the files they copy change. 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
builds the layers above the line again and empties every cache mount, so
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile`
below mounts so that they 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 scripts do not pass `--no-cache`. That flag reruns every step too, but
it also builds the layers above the line again and empties every cache
mount. Go's build cache and golangci-lint's would then start empty every
time; the canonical Go `Dockerfile` below mounts them 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 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: 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.
- **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`:
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. The test
phase is based on the Debian Go image. The canonical Go repo `Dockerfile`:
```dockerfile
# In every stage ARG CACHEBUST comes right after the steps that install
@@ -314,25 +364,25 @@ style conventions are in separate documents:
```
Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- The lint phase uses the `golangci/golangci-lint` image directly, so
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
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
and a stage nothing depends on is not built at all. Without these two
lines a red gate would not fail the build.
- Keep the runtime stage last, 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.
- Keep the runtime stage last. If you add a stage after it, give it the same
two copies: a plain `docker build .` builds the last stage's chain and
nothing else.
- Check builds keep these, and none of them can hand back a stale result:
- The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the
install line changes, and downloads the modules again when `go.mod` or
install line changes. It downloads the modules again when `go.mod` or
`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
are kept in a layer rather than a cache mount because 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 verify` does), so a
file changed there would go unnoticed.
are kept in a layer rather than a cache mount, for two reasons. 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 verify` does), so a file changed there would go unnoticed.
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
root), mounted on the steps that compile: `golangci-lint run`, both
`go test` runs and `go build`. Go reuses a compiled package only when
@@ -340,19 +390,22 @@ style conventions are in separate documents:
is why both `go test` runs carry `-count=1`.
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
a package's findings only when all of these are unchanged:
- the package's files and those of every package it imports
- `go.mod`
- the linters and their settings in `.golangci.yml`
- the golangci-lint version
- In a Python `test` phase, the layer of the step that runs
`script/bootstrap` to install the packages from `Pipfile.lock`, as
described for that phase below.
- 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:
- If the project uses `//go:embed` directives 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-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
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. The test phase,
on the Debian Go image, installs the libraries its tests need the same
@@ -371,43 +424,57 @@ style conventions are in separate documents:
```
- `.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.
`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 `.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, and an
alpine one needs `apk add --no-cache git`. It 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
- the tag, the number of commits since it and the short commit
(`v1.2.3-4-gabc1234`), on a later commit
- the short commit, when no tag is reachable
The stage that compiles also marks its working directory safe for git
(`git config --system --add safe.directory /src`). A context sent as a
tar stream keeps the sender's file owners, and git refuses a checkout
owned by another user, so the version would come out empty.
`ARG VERSION` has no default. 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 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 plain `docker build .` fails. Pass the version
with `--build-arg VERSION=...`, as `script/docker` and `script/cibuild`
already do.
- **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`.
above `ARG CACHEBUST`, so check builds keep them.** The steps are:
- `COPY script/ script/`
- `COPY Pipfile Pipfile.lock ./`
- `RUN script/bootstrap`
`ARG CACHEBUST` comes right after them. Docker keeps what that `RUN`
installed until one of these changes:
- the base image
- `script/`
- `Pipfile`
- `Pipfile.lock`
- a line up to and including the `RUN`
The tests run after `ARG CACHEBUST`, on every check build. In every other
stage, a step that installs Python packages comes after `ARG CACHEBUST`.
- When `script/bootstrap` runs in that step, it installs nothing beyond
system packages, pipenv and its dependencies, and the packages from
`Pipfile.lock`. It installs no Python there: the tests use the base
@@ -426,11 +493,12 @@ style conventions are in separate documents:
their hashes. pipenv creates its virtual environment with the `virtualenv`
package. The packages that `virtualenv` adds come from wheels inside that
package, so the hash pip checked for it covers them. Two things in the
layer are not fixed by a hash. A package with no ready-built wheel for the
image is built from source. pip downloads the build tools without a hash
check, so a kept layer's build of such a package may differ from a new
one. And system packages keep the versions the package manager gave when
the layer was built; a new install may get newer ones.
layer are not fixed by a hash:
- A package with no ready-built wheel for the image is built from
source. pip downloads the build tools without a hash check, so a kept
layer's build of such a package may differ from a new one.
- System packages keep the versions the package manager gave when the
layer was built; a new install may get newer ones.
- As with Go's modules, the packages are kept in a layer, not a cache mount.
A cache mount is shared by every build on the host, and nothing checks its
files again.
@@ -462,48 +530,54 @@ style conventions are in separate documents:
```
- 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
runs `script/cibuild` on push. Its only other step checks out the repo, with
`persist-credentials: false`. `script/cibuild` needs no token, and without
that setting 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
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.
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`.
A new push then cancels the older run on the same branch, queued or running,
and no other: runs for replaced commits do not hold up the shared runner.
`script/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
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
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.
described above, each held to the 5-minute Docker build limit below, plus the
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`
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`.
- 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: 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.
and run only `script/lint` and `script/fmt-check`. `script/install-precommit`
installs the hook; 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
tests exist yet, add the most minimal test possible: importing the module
under test to verify it compiles/parses, for example. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
@@ -519,11 +593,11 @@ style conventions are in separate documents:
- **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). A first run that timed
on success, with just package/suite summaries. On failure it provides full
diagnostic detail: every test case, every assertion. A first run that timed
out gets no rerun, because the rerun would only wait for the same hang. The
command lives in the `test` phase of the `Dockerfile`, since `script/test`
builds that phase; the Makefile form below is the same pattern for any
builds that phase. The Makefile form below is the same pattern for any
repo-local invocation:
```makefile
@@ -560,16 +634,16 @@ style conventions are in separate documents:
exits 1. A failure with neither line in its output reruns with `-v`. The
kept output prints when the first run ends, not while it runs.
`-count=1` is required on both invocations: it defeats Go's test _result_
`-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
passing result in its cache directory (`GOCACHE`). When the same tests run
again on unchanged code, it prints that result, marked `(cached)`, without
running them. The directory lasts from one run to the next on a developer's
machine, where this target runs. It also lasts 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.
@@ -589,7 +663,7 @@ style conventions are in separate documents:
seconds run out. On a Mac without `timeout`, the target fails until it is
installed.
The `exit 1` ensures the target always fails after a rerun — the first run
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.
@@ -606,59 +680,65 @@ style conventions are in separate documents:
- 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.
- `.gitignore` should be comprehensive from the start:
- OS files (`.DS_Store`)
- editor files (`.swp`, `*~`)
- in-repo agent scratch directories (`.claude/`)
- `node_modules/`
- the repo's own build outputs
Fetch the standard `.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
`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,
therefore excludes only the copies at the repository root. `config/.env` and
`certs/server.key` still reach the context and can land in an image layer.
That 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
depth-independent pattern the `**/` prefix, and leave only genuinely
root-anchored entries unprefixed. Those are `.claude`, and the repo's own
host-built binary. The binary is written `/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
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 `.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
that name from the build. Anchoring carries a known gap, which 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
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:
`git describe --tags --always` gives.** The version is 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
@@ -673,30 +753,30 @@ style conventions are in separate documents:
```
`--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
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. 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
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
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.
- **No build artifacts in version control.** Code-derived data must never be
committed to the repository 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. 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.
@@ -706,53 +786,53 @@ style conventions are in separate documents:
feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
_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
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
`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
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.15.0 (released 2026-10-16), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481`,
which reports `2.15.0 built with go1.27.0 from 25c5369a`). A module's `go`
v2.15.0 (released 2026-10-16). It is pinned as the digest of the lint phase's
base image,
`golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481`,
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
was built with, or golangci-lint refuses to lint it: this release lints
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
the two prompted the change. The canonical copy can name linters that an older
golangci-lint rejects. 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
`PATH` only. On an already-provisioned machine the pin is then inert, and a
version bump is a silent no-op. The Dockerfile, installing into a clean image,
gets the pinned version, so a 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
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
- after installing, re-resolves the binary the way callers do, with
`hash -r` and then through `PATH`, not through the directory the installer
wrote to. It 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.
Node is the exception to the whole-token comparison. The canonical
`script/bootstrap` compares only its major version, for two reasons. 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`.
@@ -814,7 +894,7 @@ style conventions are in separate documents:
- 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.
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
@@ -834,10 +914,17 @@ style conventions are in separate documents:
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."
- **Description**: First line must include:
- the project name
- purpose
- category (web server, SPA, CLI tool, etc.)
- license
- author
Example: "µPaaS is an MIT-licensed Go web application by @sneak that
receives git-frontend webhooks and deploys applications via Docker in
realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
@@ -870,17 +957,25 @@ style conventions are in separate documents:
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.
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.
- 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:
only project-level config files:
- `README.md`
- `AGENTS.md`
- `Makefile`
- `Dockerfile`
- `LICENSE`
- `.gitignore`
- `.editorconfig`
- `REPO_POLICIES.md`
- 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