Rewrite REPO_POLICIES.md sentence by sentence (closes #136)
check / check (push) Canceled after 0s
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:
@@ -21,6 +21,13 @@ fmt-check, and commit.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
pick this up on their next re-vendor. Each item in the canonical
|
||||||
|
|||||||
@@ -105,37 +105,42 @@ with your task.
|
|||||||
`services/api/.claude/`: add anchored entries for those directories.
|
`services/api/.claude/`: add anchored entries for those directories.
|
||||||
- [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into
|
- [ ] 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
|
the build context. It keeps out every git `config` at any depth
|
||||||
(`**/.git/config`, `**/.git/modules/**/config`): the repository's own,
|
(`**/.git/config`, `**/.git/modules/**/config`):
|
||||||
each submodule's under `.git/modules/`, and that of a submodule keeping
|
- the repository's own
|
||||||
its own `.git` directory. `git describe` does not need them, and each can
|
- each submodule's under `.git/modules/`
|
||||||
hold a credential: a password in a remote URL, or the token the CI
|
- that of a submodule keeping its own `.git` directory
|
||||||
checkout step stores there. A submodule whose name has a `config` segment
|
|
||||||
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to
|
`git describe` does not need them, and each can hold a credential: a
|
||||||
`**/.git/modules/**/config`, and Go's version stamping then fails the
|
password in a remote URL, or the token the CI checkout step stores there. A
|
||||||
build: give it a name without that segment (`git submodule add --name`).
|
submodule whose name has a `config` segment (`config`, `deploy/config`,
|
||||||
The stage that compiles has `git` (the Debian Go image has it; an alpine
|
`config/lib`) loses its whole git directory to `**/.git/modules/**/config`,
|
||||||
one needs `apk add --no-cache git`) and takes the version from the
|
and Go's version stamping then fails the build. Give it a name without that
|
||||||
`VERSION` build argument when one is given, otherwise from
|
segment (`git submodule add --name`). The stage that compiles has `git`: the
|
||||||
`git describe --tags --always`. That gives the tag on a tagged commit; on
|
Debian Go image has it, and an alpine one needs `apk add --no-cache git`. It
|
||||||
a later commit, the tag, the number of commits since it and the short
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
|
otherwise from `git describe --tags --always`. That gives:
|
||||||
reachable. The stage that compiles also marks its working directory safe
|
- the tag, on a tagged commit
|
||||||
for git (`git config --system --add safe.directory /src`): a context sent
|
- the tag, the number of commits since it and the short commit
|
||||||
as a tar stream keeps the sender's file owners, and git refuses a checkout
|
(`v1.2.3-4-gabc1234`), on a later commit
|
||||||
owned by another user, so the version would come out empty. `ARG VERSION`
|
- the short commit, when no tag is reachable
|
||||||
has no default, and the build fails if the context carries `.git` and the
|
|
||||||
version still comes out empty, `dev` or `unknown`. A plain
|
The stage that compiles also marks its working directory safe for git
|
||||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
(`git config --system --add safe.directory /src`). A context sent as a tar
|
||||||
refuses an empty build argument drops that refusal and keeps the argument.
|
stream keeps the sender's file owners, and git refuses a checkout owned by
|
||||||
A checkout whose `.git` is a file (a linked worktree, or a repository
|
another user, so the version would come out empty. `ARG VERSION` has no
|
||||||
checked out as a submodule) is the exception: that file points to a git
|
default. The build fails if the context carries `.git` and the version still
|
||||||
directory outside the build context, so the build cannot read the version
|
comes out empty, `dev` or `unknown`. A plain `docker build .` with no build
|
||||||
and a plain `docker build .` fails; pass the version with
|
arguments must succeed; a Dockerfile that refuses an empty build argument
|
||||||
`--build-arg VERSION=...`. `script/docker` and `script/cibuild` already
|
drops that refusal and keeps the argument. A checkout whose `.git` is a file
|
||||||
pass the version they compute on the host; it takes precedence. The
|
is the exception: a linked worktree, or a repository checked out as a
|
||||||
canonical `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its
|
submodule. That file points to a git directory outside the build context, so
|
||||||
checkout step, which otherwise clones shallow and fetches no tags, so a CI
|
the build cannot read the version and a plain `docker build .` fails. Pass
|
||||||
build finds the tag too.
|
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
|
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
|
||||||
push, checks out with `persist-credentials: false` and with
|
push, checks out with `persist-credentials: false` and with
|
||||||
`fetch-depth: 0` (which fetches the tags `git describe` needs), carries
|
`fetch-depth: 0` (which fetches the tags `git describe` needs), carries
|
||||||
|
|||||||
@@ -75,34 +75,41 @@ Template files can be fetched from:
|
|||||||
anchored entry.
|
anchored entry.
|
||||||
- If the image embeds a version in a binary: `.dockerignore` lets `.git`
|
- 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
|
into the build context. It keeps out every git `config` at any depth
|
||||||
(`**/.git/config`, `**/.git/modules/**/config`): the repository's own,
|
(`**/.git/config`, `**/.git/modules/**/config`):
|
||||||
each submodule's under `.git/modules/`, and that of a submodule keeping
|
- the repository's own
|
||||||
its own `.git` directory. `git describe` does not need them, and each can
|
- each submodule's under `.git/modules/`
|
||||||
hold a credential: a password in a remote URL, or the token the CI
|
- that of a submodule keeping its own `.git` directory
|
||||||
checkout step stores there. A submodule whose name has a `config` segment
|
|
||||||
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to
|
`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
|
`**/.git/modules/**/config`, and Go's version stamping then fails the
|
||||||
build: give it a name without that segment (`git submodule add --name`).
|
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
|
The stage that compiles has `git`: the Debian Go image has it, and an
|
||||||
one needs `apk add --no-cache git`) and takes the version from the
|
alpine one needs `apk add --no-cache git`. It 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 the tag on a tagged commit; on
|
`git describe --tags --always`. That gives:
|
||||||
a later commit, the tag, the number of commits since it and the short
|
- the tag, on a tagged commit
|
||||||
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
|
- the tag, the number of commits since it and the short commit
|
||||||
reachable. The stage that compiles also marks its working directory safe
|
(`v1.2.3-4-gabc1234`), on a later commit
|
||||||
for git (`git config --system --add safe.directory /src`): a context sent
|
- the short commit, when no tag is reachable
|
||||||
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`
|
The stage that compiles also marks its working directory safe for git
|
||||||
has no default, and the build fails if the context carries `.git` and the
|
(`git config --system --add safe.directory /src`). A context sent as a
|
||||||
version still comes out empty, `dev` or `unknown`. A plain
|
tar stream keeps the sender's file owners, and git refuses a checkout
|
||||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
owned by another user, so the version would come out empty.
|
||||||
refuses an empty build argument drops that refusal and keeps the argument.
|
`ARG VERSION` has no default. The build fails if the context carries
|
||||||
A checkout whose `.git` is a file (a linked worktree, or a repository
|
`.git` and the version still comes out empty, `dev` or `unknown`. A
|
||||||
checked out as a submodule) is the exception: that file points to a git
|
plain `docker build .` with no build arguments must succeed; a
|
||||||
directory outside the build context, so the build cannot read the version
|
Dockerfile that refuses an empty build argument drops that refusal and
|
||||||
and a plain `docker build .` fails; pass the version with
|
keeps the argument. A checkout whose `.git` is a file is the exception:
|
||||||
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
a linked worktree, or a repository checked out as a submodule. That file
|
||||||
do.
|
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
|
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking
|
||||||
its tool directly rather than through `make` or `script/`, and the final
|
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
|
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
|
- Server: the final stage builds and runs the application
|
||||||
- Non-server: the final stage brings up the dev environment
|
- Non-server: the final stage brings up the dev environment
|
||||||
- Image pinned by sha256 hash with version/date comment
|
- Image pinned by sha256 hash with version/date comment
|
||||||
|
|
||||||
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
|
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
|
||||||
`script/cibuild` on push, checks out with `persist-credentials: false` and
|
`script/cibuild` on push, checks out with `persist-credentials: false` and
|
||||||
with `fetch-depth: 0` (which fetches the tags `git describe` needs),
|
with `fetch-depth: 0` (which fetches the tags `git describe` needs),
|
||||||
|
|||||||
+402
-307
@@ -16,22 +16,32 @@ 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 includes
|
- **External references must be pinned by cryptographic hash.** This is the
|
||||||
Docker base images, Go modules, npm packages, GitHub Actions, and anything
|
single most important rule in this document. It covers everything fetched from
|
||||||
else fetched from a remote source. Version tags (`@v4`, `@latest`, `:3.21`,
|
a remote source, including:
|
||||||
etc.) are server-mutable and therefore remote code execution vulnerabilities.
|
- Docker base images
|
||||||
Reference an external dependency by its content hash (Docker `@sha256:...`, Go
|
- Go modules
|
||||||
module hash in `go.sum`, npm integrity hash in lockfile, GitHub Actions
|
- npm packages
|
||||||
`@<commit-sha>`). This also means never `curl | bash` to install tools like
|
- GitHub Actions
|
||||||
pyenv, nvm, rustup, etc. Instead, download a specific release archive from
|
|
||||||
GitHub, verify its hash (hardcoded in the Dockerfile or script), and only then
|
A version tag such as `@v4`, `@latest` or `:3.21` is server-mutable, and
|
||||||
install. Unverified install scripts are arbitrary remote code execution. This
|
therefore a remote code execution vulnerability. Reference an external
|
||||||
is the single most important rule in this document. Double-check every
|
dependency by its content hash:
|
||||||
external reference in every file before committing.
|
- 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
|
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
|
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.
|
own, so the exception does not cover it.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
- `make bootstrap`
|
||||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
- `make setup`
|
||||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
- `make test`
|
||||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
- `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
|
- Repos follow the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[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
|
pattern. The implementation of each Makefile target lives in an executable
|
||||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
script in `script/`, and the Makefile targets are thin shims that call them:
|
||||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
- `script/bootstrap`
|
||||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
- `script/setup`
|
||||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
- `script/test`
|
||||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
- `script/lint`
|
||||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
- `script/fmt`
|
||||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
- `script/fmt-check`
|
||||||
for development after a fresh clone: runs `bootstrap`, then
|
- `script/check`
|
||||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
- `script/docker`
|
||||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
|
||||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
The scripts must be POSIX sh: `#!/bin/sh`, `set -eu`, no bashisms. That is
|
||||||
(detected in that order; apt runs noninteractive). For node it uses the
|
so they run in minimal containers; alpine images, for example, have no bash.
|
||||||
installed node only when its major version is the pinned one; otherwise (node
|
Locate the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd`
|
||||||
missing, or another major, such as the node 24 the CI runner image ships) it
|
there before acting.
|
||||||
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
|
From the standard's canonical set we use `bootstrap`, `setup`, `test`, and
|
||||||
GitHub release archive (never `curl | sh`), with bash installed as an explicit
|
`cibuild`. `setup` makes the repo ready for development after a fresh clone:
|
||||||
prerequisite since nvm requires bash. Only the major version is compared
|
it runs `bootstrap`, then `install-precommit`, plus any repo-specific
|
||||||
because the `Dockerfile` stages start from a node image whose exact version is
|
initialization.
|
||||||
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
|
`script/bootstrap` installs all dependencies idempotently and assumes
|
||||||
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
nothing is present. Base tools come from nix, apt, brew, or apk, detected in
|
||||||
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
that order; apt runs noninteractive. For node it uses the installed node
|
||||||
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
only when its major version is the pinned one. When node is missing or has
|
||||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
another major, it installs the PINNED node version via nvm, and installs
|
||||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
yarn and the packages under it. The node 24 the CI runner image ships is one
|
||||||
pristine checkout with nothing installed the run dies there, after the
|
such other major. nvm itself is installed first if missing, from a
|
||||||
containerised gates have passed. **The bootstrap alone is not enough**: when
|
hash-verified GitHub release archive, never `curl | sh`. bash is installed
|
||||||
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
as an explicit prerequisite, since nvm requires bash. Only the major version
|
||||||
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
is compared, for two reasons. The `Dockerfile` stages start from a node
|
||||||
under another node. The host entrypoints that need yarn — `script/fmt` and
|
image whose exact version is not the pin, and nvm cannot install a prebuilt
|
||||||
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
node on alpine. yarn is pinned via
|
||||||
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or
|
||||||
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
"lts"; always exact versions.
|
||||||
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/cibuild` runs the CI build, and the Gitea workflow calls it. It
|
||||||
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
changes to the repo root, runs `script/bootstrap`, runs `script/check`, and
|
||||||
`script/precommit` is what the git pre-commit hook runs, and it calls
|
builds the image with the version. **`script/cibuild` runs
|
||||||
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
`script/bootstrap` first.** The workflow checks out the repo and runs
|
||||||
(the `make hooks` target shims to it); and `script/projectname` (literally
|
nothing else, while `script/fmt-check` runs the formatter on the host.
|
||||||
that filename) simply outputs the project's name. Scripts that need the name
|
Without the bootstrap, on a pristine checkout with nothing installed, the
|
||||||
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
run dies there, after the containerised gates have passed. **The bootstrap
|
||||||
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
alone is not enough.** When `script/bootstrap` installs node and yarn under
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
nvm, it leaves neither on the `PATH` of the shell that called it. A bare
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`yarn` then either exits 127 or runs under another node. The host
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
entrypoints that need yarn, `script/fmt` and `script/fmt-check`, therefore
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
use the same test as `script/bootstrap`. When the node on `PATH` has the
|
||||||
README requirements below).
|
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.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
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 Makefile is authoritative documentation for how the repo is used. Beyond
|
||||||
the required targets above, it should have targets for every common operation:
|
the required targets above, it should have targets for every common operation:
|
||||||
running a local development server (`make run`, `make dev`), re-initializing
|
- running a local development server (`make run`, `make dev`)
|
||||||
or migrating the database (`make db-reset`, `make migrate`), building
|
- re-initializing or migrating the database (`make db-reset`,
|
||||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
`make migrate`)
|
||||||
developer would do regularly. If someone checks out the repo and types
|
- building artifacts (`make build`)
|
||||||
`make<tab>`, they should see every meaningful operation available. A new
|
- generating code
|
||||||
contributor should be able to understand the entire development workflow by
|
- seeding data
|
||||||
reading the Makefile.
|
- 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
|
- 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
|
`lint` phase and a `test` phase. The final stage depends on both, so the image
|
||||||
image cannot be built unless they pass. For non-server repos the final stage
|
cannot be built unless they pass. For non-server repos the final stage brings
|
||||||
brings up a development environment; for server repos it is the runtime image.
|
up a development environment; for server repos it is the runtime image. The
|
||||||
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 either inline, as the canonical Go `Dockerfile`
|
install what those images lack. They do that in one of two ways:
|
||||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
- inline, as the canonical Go `Dockerfile` below does for `git`
|
||||||
repo's own `Dockerfile` does for its yarn packages. The development
|
- by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile`
|
||||||
environment stage installs development prerequisites by running
|
does for its yarn packages
|
||||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
|
||||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
The development environment stage installs development prerequisites by
|
||||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
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
|
- **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
|
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
|
**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 edge a
|
carries a `COPY --from=` of a harmless file from each of them. Without that
|
||||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
edge, a plain `docker build .` builds the last stage alone and exits 0
|
||||||
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
|
||||||
runs and a failing step fails the build, but the result is not exported.
|
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
|
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 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
|
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` — never through `make lint` or `script/test`, which are
|
`eslint`, `prettier`. It is never invoked through `make lint` or
|
||||||
themselves a `docker build` and would recurse into a daemon that does not
|
`script/test`. Those are themselves a `docker build`, and would recurse into
|
||||||
exist in a build step. Formatting is the exception and stays on the host:
|
a daemon that does not exist in a build step. Formatting is the exception
|
||||||
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
and stays on the host: `script/fmt` writes the working tree, and
|
||||||
read-only twin.
|
`script/fmt-check` is its read-only twin.
|
||||||
|
|
||||||
**No lint verdict may come from a host invocation of the linter.** On a
|
**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
|
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
|
than location. A second checkout of the same content is therefore served the
|
||||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
first one's findings. A host-global lock in `$TMPDIR` also makes concurrent
|
||||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
runs exit non-zero with `parallel golangci-lint is running`, a status a
|
||||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
caller cannot tell from real findings. Both have produced wrong verdicts in
|
||||||
org, in both directions. A container has its own `TMPDIR`, so the lock is
|
this org, in both directions. A container has its own `TMPDIR`, so the lock
|
||||||
out of reach. The lint phase does keep golangci-lint's cache between check
|
is out of reach. The lint phase does keep golangci-lint's cache between
|
||||||
builds (below), but it lints every checkout at `/src`, and a finding is
|
check builds (below). But it lints every checkout at `/src`, and a finding
|
||||||
taken from that cache only for the same files, settings and golangci-lint
|
is taken from that cache only for the same files, settings and golangci-lint
|
||||||
version.
|
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, so on an
|
invalidates a `COPY` layer only when the copied content changes. On an
|
||||||
unchanged tree the check `RUN` is served from cache, nothing executes, and the
|
unchanged tree the check `RUN` is therefore served from cache, nothing
|
||||||
build still exits 0. Every `docker build` in `script/` therefore passes
|
executes, and the build still exits 0. Every `docker build` in `script/`
|
||||||
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
|
therefore passes `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the
|
||||||
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`. `script/lint`, `script/test`, `script/cibuild` and
|
`script/cibuild`. The scripts that run `docker build` are these four, and
|
||||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
there is no fifth:
|
||||||
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
- `script/lint`
|
||||||
every stage of the `Dockerfile`, every step that lints, tests or compiles
|
- `script/test`
|
||||||
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps:
|
- `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 install system packages
|
||||||
- the steps that download Go's modules
|
- the steps that download Go's modules
|
||||||
- the step that runs `script/bootstrap` to install a Python `test` phase's
|
- 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
|
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
|
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.
|
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
|
The scripts do not pass `--no-cache`. That flag reruns every step too, but
|
||||||
builds the layers above the line again and empties every cache mount, so
|
it also builds the layers above the line again and empties every cache
|
||||||
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile`
|
mount. Go's build cache and golangci-lint's would then start empty every
|
||||||
below mounts so that they survive between check builds, would start empty
|
time; the canonical Go `Dockerfile` below mounts them so that they survive
|
||||||
every time. A bare `docker build .` is not evidence that anything ran: a
|
between check builds. A bare `docker build .` is not evidence that anything
|
||||||
sub-second build reporting success is a cache hit, not a result. Never
|
ran: a sub-second build reporting success is a cache hit, not a result.
|
||||||
invalidate by pruning — `docker builder prune` and friends destroy a build
|
Never invalidate by pruning: `docker builder prune` and friends destroy a
|
||||||
cache shared with every other build on the host. When a check is added or
|
build cache shared with every other build on the host. When a check is added
|
||||||
changed, prove it works by planting a defect it must catch and watching the
|
or changed, prove it works: plant a defect it must catch, watch the run fail
|
||||||
run fail on it, then revert the defect. A green run alone shows neither that
|
on it, then revert the defect. A green run alone shows neither that the
|
||||||
the check ran nor that it covers what it should.
|
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
|
||||||
and 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
|
||||||
@@ -314,25 +364,25 @@ style conventions are in separate documents:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
- The lint phase uses the `golangci/golangci-lint` image directly, so
|
||||||
both Go and the linter), so nothing needs installing.
|
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 ordering edge. BuildKit runs stages in parallel by default,
|
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.
|
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
|
- Keep the runtime stage last. If you add a stage after it, give it the same
|
||||||
same two copies. A plain `docker build .` builds the last stage's chain
|
two copies: a plain `docker build .` builds the last stage's chain and
|
||||||
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, 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
|
`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 because a cache mount is
|
are kept in a layer rather than a cache mount, for two reasons. A
|
||||||
shared by every build on the host, and Go builds from whatever files
|
cache mount is shared by every build on the host. And Go builds from
|
||||||
it holds without checking them again (only `go mod verify` does), so 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
|
||||||
@@ -340,19 +390,22 @@ style conventions are in separate documents:
|
|||||||
is why both `go test` runs carry `-count=1`.
|
is why both `go test` runs carry `-count=1`.
|
||||||
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
|
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
|
||||||
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
|
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
|
a package's findings only when all of these are unchanged:
|
||||||
package it imports, `go.mod`, the linters and their settings in
|
- the package's files and those of every package it imports
|
||||||
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
- `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
|
- In a Python `test` phase, the layer of the step that runs
|
||||||
`script/bootstrap` to install the packages from `Pipfile.lock`, as
|
`script/bootstrap` to install the packages from `Pipfile.lock`, as
|
||||||
described for that phase below.
|
described for that phase below.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
artifacts, the lint phase must create placeholder files so the embed
|
||||||
create placeholder files so the embed directives resolve. Example:
|
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`.
|
`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
|
- 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
|
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
|
(`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,
|
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
|
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
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`):
|
||||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
- the repository's own
|
||||||
submodule keeping its own `.git` directory. `git describe` does not need
|
- each submodule's under `.git/modules/`
|
||||||
them, and each can hold a credential: a password in a remote URL, or the
|
- that of a submodule keeping its own `.git` directory
|
||||||
token the CI checkout step stores there. A submodule whose name has a
|
|
||||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
`git describe` does not need them, and each can hold a credential: a
|
||||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
password in a remote URL, or the token the CI checkout step stores
|
||||||
then fails the build: give it a name without that segment
|
there. A submodule whose name has a `config` segment (`config`,
|
||||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
`deploy/config`, `config/lib`) loses its whole git directory to
|
||||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
`**/.git/modules/**/config`, and Go's version stamping then fails the
|
||||||
takes the version from the `VERSION` build argument when one is given,
|
build. Give it a name without that segment (`git submodule add --name`).
|
||||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
The stage that compiles has `git`: the Debian Go image has it, and an
|
||||||
tagged commit; on a later commit, the tag, the number of commits since it
|
alpine one needs `apk add --no-cache git`. It takes the version from the
|
||||||
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
`VERSION` build argument when one is given, otherwise from
|
||||||
tag is reachable. The stage that compiles also marks its working directory
|
`git describe --tags --always`. That gives:
|
||||||
safe for git (`git config --system --add safe.directory /src`): a context
|
- the tag, on a tagged commit
|
||||||
sent as a tar stream keeps the sender's file owners, and git refuses a
|
- the tag, the number of commits since it and the short commit
|
||||||
checkout owned by another user, so the version would come out empty.
|
(`v1.2.3-4-gabc1234`), on a later commit
|
||||||
`ARG VERSION` has no default, and the build fails if the context carries
|
- the short commit, when no tag is reachable
|
||||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
|
||||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
The stage that compiles also marks its working directory safe for git
|
||||||
refuses an empty build argument drops that refusal and keeps the argument.
|
(`git config --system --add safe.directory /src`). A context sent as a
|
||||||
A checkout whose `.git` is a file (a linked worktree, or a repository
|
tar stream keeps the sender's file owners, and git refuses a checkout
|
||||||
checked out as a submodule) is the exception: that file points to a git
|
owned by another user, so the version would come out empty.
|
||||||
directory outside the build context, so the build cannot read the version
|
`ARG VERSION` has no default. The build fails if the context carries
|
||||||
and a plain `docker build .` fails; pass the version with
|
`.git` and the version still comes out empty, `dev` or `unknown`. A
|
||||||
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
plain `docker build .` with no build arguments must succeed; a
|
||||||
do.
|
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
|
- **A Python `test` phase that installs its packages from `Pipfile.lock` does it
|
||||||
above `ARG CACHEBUST`, so check builds keep them.** The steps are
|
above `ARG CACHEBUST`, so check builds keep them.** The steps are:
|
||||||
`COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and
|
- `COPY script/ script/`
|
||||||
`RUN script/bootstrap`, with `ARG CACHEBUST` right after them. Docker keeps
|
- `COPY Pipfile Pipfile.lock ./`
|
||||||
what that `RUN` installed until the base image, `script/`, `Pipfile`,
|
- `RUN script/bootstrap`
|
||||||
`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
|
`ARG CACHEBUST` comes right after them. Docker keeps what that `RUN`
|
||||||
installs Python packages comes after `ARG CACHEBUST`.
|
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
|
- When `script/bootstrap` runs in that step, it installs nothing beyond
|
||||||
system packages, pipenv and its dependencies, and the packages from
|
system packages, pipenv and its dependencies, and the packages from
|
||||||
`Pipfile.lock`. It installs no Python there: the tests use the base
|
`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`
|
their hashes. pipenv creates its virtual environment with the `virtualenv`
|
||||||
package. The packages that `virtualenv` adds come from wheels inside that
|
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
|
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
|
layer are not fixed by a hash:
|
||||||
image is built from source. pip downloads the build tools without a hash
|
- A package with no ready-built wheel for the image is built from
|
||||||
check, so a kept layer's build of such a package may differ from a new
|
source. pip downloads the build tools without a hash check, so a kept
|
||||||
one. And system packages keep the versions the package manager gave when
|
layer's build of such a package may differ from a new one.
|
||||||
the layer was built; a new install may get newer ones.
|
- 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.
|
- 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
|
A cache mount is shared by every build on the host, and nothing checks its
|
||||||
files again.
|
files again.
|
||||||
@@ -462,48 +530,54 @@ 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, and checks out the repo as its only other step,
|
runs `script/cibuild` on push. Its only other step checks out the repo, with
|
||||||
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
`persist-credentials: false`. `script/cibuild` needs no token, and without
|
||||||
without it the checkout leaves the job's token in `.git/config` for every
|
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
|
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
|
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`.
|
||||||
so a new push cancels the older run on the same branch, queued or running, and
|
A new push then cancels the older run on the same branch, queued or running,
|
||||||
no other: runs for replaced commits do not hold up the shared runner.
|
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,
|
`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
|
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
|
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
|
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
|
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 (the test phase, the lint phase, then the image), each held to
|
described above, each held to the 5-minute Docker build limit below, plus the
|
||||||
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
|
bootstrap. The three are the test phase, the lint phase, then the image. A
|
||||||
limited to `main` by a `branches` list under `on: push` cannot be checked by
|
separate workflow limited to `main` by a `branches` list under `on: push`
|
||||||
review: to try a change to it, add the feature branch to that list and push,
|
cannot be checked by review. To try a change to it, add the feature branch to
|
||||||
then remove the branch from the list again before merging. Keep any job in it
|
that list and push, then remove the branch from the list again before merging.
|
||||||
that publishes behind `if: github.ref_name == 'main'`, so the run from the
|
Keep any job in it that publishes behind `if: github.ref_name == 'main'`, so
|
||||||
feature branch publishes nothing.
|
the run from the feature branch publishes nothing.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters:
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
- `black` for Python
|
||||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
- `prettier` for JS/CSS/Markdown/HTML
|
||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
- `go fmt` for Go
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
|
||||||
|
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
|
- 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`
|
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
|
and run only `script/lint` and `script/fmt-check`. `script/install-precommit`
|
||||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
installs the hook; the Makefile must provide a `make hooks` target that shims
|
||||||
that shims to it.
|
to it.
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
tests exist yet, add the most minimal test possible: importing the module
|
||||||
module under test to verify it compiles/parses. There is no excuse for
|
under test to verify it compiles/parses, for example. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
- `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
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
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
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
on success (just package/suite summaries) while providing full diagnostic
|
on success, with just package/suite summaries. On failure it provides full
|
||||||
detail on failure (every test case, every assertion). A first run that timed
|
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
|
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`
|
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:
|
repo-local invocation:
|
||||||
|
|
||||||
```makefile
|
```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
|
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.
|
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
|
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
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
and no recompilation.
|
and no recompilation.
|
||||||
|
|
||||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
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
|
passing result in its cache directory (`GOCACHE`). When the same tests run
|
||||||
run again on unchanged code it prints that result, marked `(cached)`,
|
again on unchanged code, it prints that result, marked `(cached)`, without
|
||||||
without running them. The directory lasts from one run to the next on a
|
running them. The directory lasts from one run to the next on a developer's
|
||||||
developer's machine, where this target runs, and in the `test` phase of the
|
machine, where this target runs. It also lasts in the `test` phase of the
|
||||||
`Dockerfile`, which mounts it to keep compiled packages between check
|
`Dockerfile`, which mounts it to keep compiled packages between 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.
|
||||||
@@ -589,7 +663,7 @@ style conventions are in separate documents:
|
|||||||
seconds run out. On a Mac without `timeout`, the target fails until it is
|
seconds run out. On a Mac without `timeout`, the target fails until it is
|
||||||
installed.
|
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
|
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
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
for diagnostic output.
|
for diagnostic output.
|
||||||
@@ -606,59 +680,65 @@ style conventions are in separate documents:
|
|||||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start:
|
||||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
- OS files (`.DS_Store`)
|
||||||
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
- editor files (`.swp`, `*~`)
|
||||||
`.gitignore` from
|
- in-repo agent scratch directories (`.claude/`)
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
- `node_modules/`
|
||||||
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
- the repo's own build outputs
|
||||||
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
|
Fetch the standard `.gitignore` from
|
||||||
semantics, in which an unanchored pattern already matches at every depth; they
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting
|
||||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
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
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
across unmodified leaves secrets in the build context.** Docker matches with
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension. So
|
||||||
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
`*` does not cross `/`, and a pattern without a leading `**/` is anchored at
|
||||||
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, while `config/.env`
|
therefore excludes only the copies at the repository root. `config/.env` and
|
||||||
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.
|
||||||
— which is more dangerous than a short file with no secret patterns at all,
|
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
|
because it reads as solved and stops anyone looking. Give every
|
||||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
depth-independent pattern the `**/` prefix, and leave only genuinely
|
||||||
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
root-anchored entries unprefixed. Those are `.claude`, and the repo's own
|
||||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
host-built binary. The binary is written `/myapp` and never `**/myapp`, which
|
||||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
would also match `cmd/myapp/` and delete the package directory from the
|
||||||
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
context. Matching is case-sensitive, and an ALL-CAPS twin per pattern still
|
||||||
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
misses `Server.Key`. So secret names use character ranges:
|
||||||
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
`**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for `.envrc` and the
|
||||||
also catches something the build needs, re-include it with a negation
|
extensionless SSH keys. Where such a pattern also catches something the build
|
||||||
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
needs, re-include it with a negation (`!docs/example.env`). Deleting the
|
||||||
other file it covers. Fetch the standard `.dockerignore` from
|
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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
it with the repo's own artifacts.
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
- **In-repo agent scratch belongs in both files, written to each file's own
|
- **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
|
semantics.** `.claude/` holds one worktree per in-flight agent, an entire
|
||||||
additional checkout of the repo — so under `COPY . .` the build context
|
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
|
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/`,
|
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 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
|
`.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
|
directory, so a repo running agents in subdirectories still ships
|
||||||
`services/api/.claude/` and must add its own anchored entry there.
|
`services/api/.claude/` and must add its own anchored 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**, derived from the `.git` in the build
|
`git describe --tags --always` gives.** The version is derived from the `.git`
|
||||||
context as the canonical `Dockerfile` above shows. Without its failure check,
|
in the build context, as the canonical `Dockerfile` above shows. Without its
|
||||||
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
failure check, a missing `git` or an unreadable checkout would leave
|
||||||
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
`-X main.Version=` empty, and the build would still exit 0. `script/docker`
|
||||||
the version they compute on the host; it takes precedence. They do this
|
and `script/cibuild` pass the version they compute on the host; it takes
|
||||||
byte-identically across repos:
|
precedence. They do this byte-identically across repos:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# The version and the tag each get their own line: a failing command
|
# 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
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
than failing, and the `[ -n "$version" ]` line is the single place the
|
than failing. The `[ -n "$version" ]` line is the single place the fallback
|
||||||
fallback is applied — a live check that fires on a build from an export with
|
is applied. It is a live check that fires on a build from an export with no
|
||||||
no `.git` and on a repository with no commits yet. Do not fold it into the
|
`.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
|
substitution as `|| echo unknown`, which makes the guard unreachable. 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
|
||||||
checkout action clones shallow and fetches no tags, so the canonical
|
checkout action clones shallow and fetches no tags, so the canonical
|
||||||
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
- **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
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
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 (compiled
|
- **No build artifacts in version control.** Code-derived data must never be
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
committed to the repository if it can be avoided: compiled bundles, minified
|
||||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
output, generated assets. The build process (e.g. Dockerfile, Makefile) should
|
||||||
should generate these at build time. Notable exception: Go protobuf generated
|
generate these at build time. Notable exception: Go protobuf generated files
|
||||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
(`.pb.go`) ARE committed. Repos need to work with `go get`, which downloads
|
||||||
downloads code but does not execute code generation.
|
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.
|
||||||
|
|
||||||
@@ -706,53 +786,53 @@ style conventions are in separate documents:
|
|||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
- `.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
|
`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
|
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
|
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,
|
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
|
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
|
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), pinned as the digest of the lint phase's base
|
v2.15.0 (released 2026-10-16). It is pinned as the digest of the lint phase's
|
||||||
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
|
||||||
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
|
`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
|
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
|
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
|
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
|
golangci-lint rejects. A newer golangci-lint can add linters that
|
||||||
`default: all` switches on until the canonical copy disables them.
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
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
|
`PATH` only. On an already-provisioned machine the pin is then inert, and a
|
||||||
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
version bump is a silent no-op. The Dockerfile, installing into a clean image,
|
||||||
image, gets the pinned version, so a local `make check` and `make docker` can
|
gets the pinned version, so a local `make check` and `make docker` can
|
||||||
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 absent, non-zero, empty or unrecognised `--version` output as a
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
mismatch, so the failure direction is a redundant install and never a
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
skipped one;
|
skipped one;
|
||||||
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
- after installing, re-resolves the binary the way callers do, with
|
||||||
then through `PATH`, not through the directory the installer wrote to —
|
`hash -r` and then through `PATH`, not through the directory the installer
|
||||||
and fails naming the resolved path, since an install that a shadowing
|
wrote to. It fails naming the resolved path, since an install that a
|
||||||
binary hides succeeds while changing nothing any caller sees;
|
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 its major version, because the node 22
|
`script/bootstrap` compares only its major version, for two reasons. The
|
||||||
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
node 22 alpine image the `Dockerfile` stages start from is not the exact
|
||||||
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`.
|
||||||
|
|
||||||
@@ -814,7 +894,7 @@ style conventions are in separate documents:
|
|||||||
- True client IP detection when behind a reverse proxy
|
- True client IP detection when behind a reverse proxy
|
||||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
forwarded headers only from a configured set of trusted proxy
|
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:**
|
- **CORS:**
|
||||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
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.
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include:
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
- the project name
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
- purpose
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
- 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.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[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.
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings: the standard file from
|
settings. It is the standard file from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, followed by
|
||||||
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
the repo's own sections, such as one for another language it uses. The
|
||||||
one for another language it uses. A re-vendor replaces the standard part and
|
standard file sets tabs for `Makefile` and Go files. A re-vendor replaces the
|
||||||
keeps those sections.
|
standard part and keeps those sections.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
only project-level config files:
|
||||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
- `README.md`
|
||||||
and language-specific config). Everything else goes in a subdirectory.
|
- `AGENTS.md`
|
||||||
Canonical subdirectory names:
|
- `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
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
- `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
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
|||||||
Reference in New Issue
Block a user