Author SHA1 Message Date
clawbot c1b469d2d0 Commit yarn 4 in the tree and install with --immutable (closes #149)
check / check (push) Canceled after 0s
Follows sneak's ruling at
#149 (comment). This
repository now carries yarn 4.18.1 under .yarn/releases, and whichever
yarn the distribution packages hands the work to it. yarn.lock is
converted once, and script/bootstrap installs with --immutable instead
of stopping on yarn's major version. Node on Alpine is nodejs-current.
Every Dockerfile stage that needs yarn also copies .yarnrc.yml and
.yarn/releases/. The policies and both checklists say so, with the
steps for converting and for a new repository.

Model: opus-5-5
2026-11-04 18:00:43 +00:00
13 changed files with 100 additions and 944 deletions

No files matched your search

+4 -11
View File
@@ -35,12 +35,6 @@
# `services/api/.claude/` and must add its own anchored entry. # `services/api/.claude/` and must add its own anchored entry.
.claude .claude
# yarn's install state and cache stay out, and its release file stays
# in. Docker obeys the last matching line, so these two lines sit above
# the secret patterns, which must win under .yarn/releases/.
.yarn/*
!.yarn/releases
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the # convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`. # build needs one: `!docs/example.env`.
@@ -61,8 +55,11 @@
**/[iI][dD]_[eE][dD]25519 **/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK] **/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in. # Dependencies: restored inside the image, never copied in. yarn's install
# state and cache stay out, and its release file stays in.
**/node_modules **/node_modules
.yarn/*
!.yarn/releases
# OS metadata. # OS metadata.
**/.DS_Store **/.DS_Store
@@ -76,7 +73,3 @@
**/.idea **/.idea
**/.vscode **/.vscode
**/*.sublime-* **/*.sublime-*
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `**/myapp`, which also matches `cmd/myapp/`.
+3 -3
View File
@@ -22,9 +22,9 @@ node_modules/
.yarn/* .yarn/*
!.yarn/releases !.yarn/releases
# Secrets. Unanchored, so each matches at every depth. Matching is # Secrets. Unanchored like every entry above, so each matches at every
# case-sensitive on Linux, so names use character ranges rather than a # depth. Matching is case-sensitive on Linux, so names use character
# lowercase form that misses `Server.Key`. # ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are # convention. Only the templates `example.env` and `sample.env` are
+3 -3
View File
@@ -6,8 +6,8 @@
# system packages. script/ passes a new value on every build. That value # system packages. script/ passes a new value on every build. That value
# reruns every RUN step below the ARG line, and the install step keeps # reruns every RUN step below the ARG line, and the install step keeps
# its layer. node and yarn come from the distribution, which is trusted, # its layer. node and yarn come from the distribution, which is trusted,
# so they need no version and no hash. That yarn only starts the release # so they need no version and no hash. That yarn hands the work to the
# file in .yarn/releases/, so every stage copies that file and # release file in .yarn/releases/, so every stage copies that file and
# .yarnrc.yml before it runs script/bootstrap. # .yarnrc.yml before it runs script/bootstrap.
# #
# alpine 3.24.3, 2026-11-04 # alpine 3.24.3, 2026-11-04
@@ -42,7 +42,7 @@ RUN script/bootstrap
COPY . . COPY . .
RUN timeout 90 test/update-policy.sh RUN echo "No tests defined."
# Development environment, and the last stage: a plain `docker build .` # Development environment, and the last stage: a plain `docker build .`
# names no target and so builds this one. Nothing is wanted from the two # names no target and so builds this one. Nothing is wanted from the two
+1 -4
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy .PHONY: bootstrap setup test lint fmt fmt-check check docker hooks
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -30,6 +30,3 @@ docker:
hooks: hooks:
@script/install-precommit @script/install-precommit
update-policy:
@script/update-policy
+2 -5
View File
@@ -118,8 +118,8 @@ alpine. We provide:
`script/docker` for the image tag `script/docker` for the image tag
- `script/test` — - `script/test` —
`docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`, `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`,
building the `test` phase of the `Dockerfile`, which runs building the `test` phase of the `Dockerfile` without writing an image (no
`test/update-policy.sh`, without writing an image tests defined here)
- `script/lint` — - `script/lint` —
`docker build --build-arg CACHEBUST="$(date +%s)-$$" --target lint --output type=cacheonly .`, `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target lint --output type=cacheonly .`,
building the `lint` phase, which runs prettier over the markdown files, building the `lint` phase, which runs prettier over the markdown files,
@@ -141,9 +141,6 @@ alpine. We provide:
`script/check` `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
- `script/update-policy` — fetch the vendored policy files from this repo,
commit them and push (our own extension); it is for the repos that vendor them
and stops when run here
`make hooks` installs the pre-commit hook that runs `script/precommit`. `make hooks` installs the pre-commit hook that runs `script/precommit`.
-23
View File
@@ -21,22 +21,6 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-11-09: `script/update-policy` and `make update-policy` update a repo's
vendored policy files with one command
(https://git.eeqj.de/sneak/prompts/issues/162). They fetch the files from
`prompts`, keep the repo's own entries, commit and push. The canonical
`.dockerignore` now ends with the comment that the repo's own entries go
below. The template repositories get the script in their own issues.
- 2026-11-07: Go code is now formatted with `gofmt` and then `golines -m 77`
(https://git.eeqj.de/sneak/prompts/issues/160). `prompts/REPO_POLICIES.md`
says how `script/fmt`, `script/fmt-check` and `script/bootstrap` do it and
pins `golines`. The Go style guide and both checklists say the same. `lll`
stays at 88, since `golines` leaves long strings and comments as they are.
- 2026-11-06: The pinning rule in `prompts/REPO_POLICIES.md` now opens with its
scope: what we download and install ourselves in scripts and Dockerfiles
(https://git.eeqj.de/sneak/prompts/issues/154). Distribution packages are
outside the rule, and updating and upgrading them is allowed. Both checklists
say the same.
- 2026-11-04: Each repository now commits yarn 4 and installs with - 2026-11-04: Each repository now commits yarn 4 and installs with
`yarn install --immutable` (https://git.eeqj.de/sneak/prompts/issues/149), on `yarn install --immutable` (https://git.eeqj.de/sneak/prompts/issues/149), on
sneak's ruling at sneak's ruling at
@@ -52,13 +36,6 @@ fmt-check, and commit.
and yarn. and yarn.
- Both checklists give the steps, and the ignore files keep yarn's install - Both checklists give the steps, and the ignore files keep yarn's install
state out of git and the build context. state out of git and the build context.
- 2026-11-04: An agent whose work is confined to one package may run `go test`
directly on that package's suite, or on individual tests in it, while
iterating (https://git.eeqj.de/sneak/prompts/issues/51), on sneak's ruling at
https://git.eeqj.de/sneak/prompts/issues/51#issuecomment-167600. The full
`make check` is still required before every push, and the `Makefile` gains
nothing. The rule on Docker now names the lint and test gates, because a run
while iterating is not one.
- 2026-11-04: node, yarn, npm, python, pip and venv now come from the - 2026-11-04: node, yarn, npm, python, pip and venv now come from the
distribution with no version and no hash distribution with no version and no hash
(https://git.eeqj.de/sneak/prompts/issues/146), on sneak's ruling at (https://git.eeqj.de/sneak/prompts/issues/146), on sneak's ruling at
+5 -8
View File
@@ -1,16 +1,13 @@
--- ---
title: Code Styleguide — Go title: Code Styleguide — Go
last_modified: 2026-11-07 last_modified: 2026-10-18
--- ---
1. Format Go code with `gofmt` and then `golines -m 77`, through `make fmt`. 1. Try to hard wrap long lines at 77 characters or less.
Don't commit anything `make fmt` would change. The only exception is when
committing things that aren't yet syntactically valid, which should only
happen pre-v0.0.1 or on a non-`main` branch.
1. Hard wrap long lines at 77 characters or less. `golines` does this for code. 1. Don't commit anything that hasn't been `go fmt`'d. The only exception is
Wrap comments and long string literals by hand, since `golines` leaves them when committing things that aren't yet syntactically valid, which should
as they are. only happen pre-v0.0.1 or on a non-`main` branch.
1. Even if you are planning to deal with only positive integers, use 1. Even if you are planning to deal with only positive integers, use
`int`/`int64` types instead of `uint`/`uint64` types. This is for `int`/`int64` types instead of `uint`/`uint64` types. This is for
+27 -44
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-11-09 last_modified: 2026-11-04
--- ---
Use this checklist when beginning work in a repo that may not yet conform to our Use this checklist when beginning work in a repo that may not yet conform to our
@@ -165,16 +165,15 @@ with your task.
in the same commit, set the lint phase digest to the one named in the in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`) `.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`, - [ ] JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`,
`.gitattributes`, `.prettierrc`, `.prettierignore` (fetch the last two `.prettierrc`, `.prettierignore` (fetch the last two from
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and
and
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierignore`). `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierignore`).
- [ ] Any repository with `yarn.lock`: that file is in yarn 4's format. If - [ ] JS: `yarn.lock` is in yarn 4's format. If its second line says
its second line says `yarn lockfile v1`, convert it with the steps `yarn lockfile v1`, convert it with the steps below. Do them in one
below. Do them in one commit with the re-vendored scripts, because commit with the re-vendored scripts, because yarn 1 ignores
yarn 1 ignores `--immutable` and exits 0 on a tree with no `--immutable` and exits 0 on a tree with no `.yarnrc.yml`. Run them
`.yarnrc.yml`. Run them with any distribution's node and yarn, and use with any distribution's node and yarn, and use `yarnpkg` on Debian and
`yarnpkg` on Debian and Ubuntu. Ubuntu.
1. Read the version from `yarnPath` in 1. Read the version from `yarnPath` in
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`.
2. Run `yarn set version <version>`. It writes 2. Run `yarn set version <version>`. It writes
@@ -188,14 +187,12 @@ with your task.
`npmMinimalAgeGate` from `.yarnrc.yml`. The conversion wrote them. `npmMinimalAgeGate` from `.yarnrc.yml`. The conversion wrote them.
They allow install scripts, any git repository and packages of any They allow install scripts, any git repository and packages of any
age, which yarn otherwise refuses. age, which yarn otherwise refuses.
6. In `package.json`, name any package that must run its install script, 6. Name any package that must run its install script in `package.json`,
as `"dependenciesMeta": { "<name>": { "built": true } }`, and run as `"dependenciesMeta": { "<name>": { "built": true } }`, and run
`yarn install` again. `yarn install` again.
7. Add `.yarn/*` and `!.yarn/releases` above the secret patterns in 7. Add `.yarn/*` and `!.yarn/releases` to `.gitignore` and
`.gitignore` and `.dockerignore`. Docker obeys the last matching `.dockerignore`, `.yarn/` to `.prettierignore`, and
line, and the secret patterns must win. Add `.yarn/` to `.yarn/releases/** binary` to `.gitattributes`.
`.prettierignore`, and `.yarn/releases/** binary` to
`.gitattributes`.
8. Run `yarn install --immutable`. It must pass and change nothing. 8. Run `yarn install --immutable`. It must pass and change nothing.
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above) - [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
@@ -205,20 +202,12 @@ with your task.
- [ ] `Makefile` exists in root — reference - [ ] `Makefile` exists in root — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile` `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, - [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
`hooks`, `update-policy` `hooks`
- [ ] Target implementations live in `script/` (scripts-to-rule-them-all); - [ ] Target implementations live in `script/` (scripts-to-rule-them-all);
Makefile targets are thin shims calling them — model scripts at Makefile targets are thin shims calling them — model scripts at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>` `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
- [ ] `script/precommit` exists and the pre-commit hook (installed by - [ ] `script/precommit` exists and the pre-commit hook (installed by
`script/install-precommit`, shimmed by `make hooks`) runs it `script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] `script/update-policy` exists, identical to the model script, and
`make update-policy` shims to it. Before its first use, the repo's own
entries in `.gitignore`, `.editorconfig` and `.dockerignore` go below the
comment that ends each canonical file. An entry above that comment which
the canonical file lacks makes the script stop before it writes anything,
naming the file and the entry. It stops the same way on a line in
`.golangci.yml` that the canonical file lacks, apart from comments and the
repo's entries in the `deny` list of the `test-support` rule.
- [ ] README has an **Entrypoints** section documenting the `script/` - [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard entrypoints and linking the standard
- [ ] `script/lint` and `script/test` each run - [ ] `script/lint` and `script/test` each run
@@ -242,9 +231,8 @@ with your task.
- [ ] `script/bootstrap` installs node, yarn and the node packages only when - [ ] `script/bootstrap` installs node, yarn and the node packages only when
`yarn.lock` is in the tree. node and yarn come from the distribution when `yarn.lock` is in the tree. node and yarn come from the distribution when
they are missing. It installs the node packages with they are missing. It installs the node packages with
`yarn install --immutable`. The distribution's yarn only starts the `yarn install --immutable`. Whichever yarn the distribution packages hands
release file that `yarnPath` in `.yarnrc.yml` names, and that file does the work to the release file that `yarnPath` in `.yarnrc.yml` names.
the work.
- [ ] A `Dockerfile` stage that needs yarn may start from any distribution that - [ ] A `Dockerfile` stage that needs yarn may start from any distribution that
packages node and yarn. It copies `package.json`, `yarn.lock`, packages node and yarn. It copies `package.json`, `yarn.lock`,
`.yarnrc.yml` and `.yarn/releases/` before it runs `script/bootstrap`. A `.yarnrc.yml` and `.yarn/releases/` before it runs `script/bootstrap`. A
@@ -267,34 +255,29 @@ with your task.
# Formatting # Formatting
- [ ] Platform-standard formatter is configured (`black`, `prettier`, and for Go - [ ] Platform-standard formatter is configured (`black`, `prettier`, `go fmt`)
`gofmt` then `golines -m 77`) - [ ] Default formatter config, only exception: four-space indents (except Go)
- [ ] Default formatter config, except four-space indents (not Go),
`proseWrap: always` for Markdown, and the Go 77-column limit
- [ ] Go: `script/bootstrap` installs `golines` with `go install` pinned to a
commit hash; `script/fmt-check` fails when `gofmt` or `golines -m 77`
would change a file
- [ ] All files pass `make fmt-check` - [ ] All files pass `make fmt-check`
# Git Hygiene # Git Hygiene
- [ ] Pre-commit hook is installed (`make hooks`) - [ ] Pre-commit hook is installed (`make hooks`)
- [ ] No secrets in the repo (`.env`, keys, credentials) - [ ] No secrets in the repo (`.env`, keys, credentials)
- [ ] No mutable references (tags, `@latest`) to what the repo downloads and - [ ] No mutable references in Dockerfiles or scripts (tags, `@latest`) — all
installs itself in scripts and Dockerfiles — all pinned by cryptographic pinned by cryptographic hash with version/date comment. The one exception
hash with version/date comment. Distribution packages are outside this is a package from the distribution's own package index, which needs no
rule. They need no version and no hash, and updating and upgrading them is version and no hash. Pulling that index is also allowed. In a
fine too. In a `Dockerfile`, the distribution is the pinned base image's. `Dockerfile`, the distribution is the pinned base image's. On a host, it
On a host, it is the host's, and the host's package manager stands for it, is the host's, and the host's package manager stands for it, nix and brew
nix and brew included. A package source that a `Dockerfile` adds itself is included. A package source that a `Dockerfile` adds itself does not get
not the distribution's own, so its packages are pinned this exception
- [ ] node, yarn, npm, python, pip and venv come from the distribution wherever - [ ] node, yarn, npm, python, pip and venv come from the distribution wherever
possible, never from nvm, pyenv, corepack or `npm install -g yarn`. If the possible, never from nvm, pyenv, corepack or `npm install -g yarn`. If the
distribution's version is too old, use a newer distribution. node means distribution's version is too old, use a newer distribution. node means
the distribution's newest Node package, such as `nodejs-current` on Alpine the distribution's newest Node package, such as `nodejs-current` on Alpine
3.24 3.24
- [ ] yarn's release file is in git under `.yarn/releases/`, written once by - [ ] yarn's release file is in git under `.yarn/releases/`, written once by
`yarn set version` with an exact version (repositories with `yarn.lock`) `yarn set version` with an exact version
- [ ] Using `yarn`, not `npm` (JS projects) - [ ] Using `yarn`, not `npm` (JS projects)
# Directory Structure # Directory Structure
+17 -26
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-11-09 last_modified: 2026-11-04
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -62,8 +62,6 @@ Template files can be fetched from:
`CLAUDE.md` or `.claude/`, and never separate memory files `CLAUDE.md` or `.claude/`, and never separate memory files
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- The repo's own entries go at the end of the file, where a re-vendor keeps
them.
- Extend `.dockerignore` with the repo's own host-built artifacts, giving - Extend `.dockerignore` with the repo's own host-built artifacts, giving
every depth-independent pattern a `**/` prefix — but write a repo-root every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match binary anchored, `/myapp` and never `**/myapp`, which would also match
@@ -181,11 +179,10 @@ Template files can be fetched from:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and, `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
in the same commit, set the lint phase digest to the one named in the in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` paragraph of `REPO_POLICIES.md`) `.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS, or any repository that runs prettier through yarn: make the first - [ ] JS: make the first `yarn.lock` with the steps below.
`yarn.lock` with the steps below. `script/bootstrap` installs no yarn `script/bootstrap` installs no yarn until `yarn.lock` exists, so run
until `yarn.lock` exists, so run them with any distribution's node and them with any distribution's node and yarn, such as Alpine's
yarn, such as Alpine's (`apk add nodejs-current yarn`). Use `yarnpkg` (`apk add nodejs-current yarn`). Use `yarnpkg` on Debian and Ubuntu.
on Debian and Ubuntu.
1. Read the version from `yarnPath` in 1. Read the version from `yarnPath` in
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`.
2. Run `yarn set version <version>`. It writes 2. Run `yarn set version <version>`. It writes
@@ -213,9 +210,9 @@ are thin shims calling them. Model scripts:
- It installs node, yarn and the node packages only when `yarn.lock` is in - It installs node, yarn and the node packages only when `yarn.lock` is in
the tree. node and yarn come from that package manager when they are the tree. node and yarn come from that package manager when they are
missing. missing.
- It installs the node packages with `yarn install --immutable`. The - It installs the node packages with `yarn install --immutable`. Whichever
distribution's yarn only starts the release file that `yarnPath` in yarn the distribution packages hands the work to the release file that
`.yarnrc.yml` names, and that file does the work. `yarnPath` in `.yarnrc.yml` names.
- A non-server repo's development environment stage runs it, with only - A non-server repo's development environment stage runs it, with only
system packages installed inline above it. system packages installed inline above it.
- A gate phase or the build stage installs what its base image lacks, either - A gate phase or the build stage installs what its base image lacks, either
@@ -235,11 +232,9 @@ are thin shims calling them. Model scripts:
which writes no image. No lint verdict may come from a host invocation of which writes no image. No lint verdict may come from a host invocation of
the linter. the linter.
- [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a - [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a
container). Go code gets `gofmt`, then `golines -m 77`. container)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only; - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
native). Fails when `gofmt` or `golines -m 77` would change a Go file. native)
- [ ] Go: `script/bootstrap` installs `golines` with `go install` pinned to a
commit hash
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not - [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
modify files modify files
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
@@ -266,9 +261,6 @@ are thin shims calling them. Model scripts:
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit` `script/precommit`
- [ ] `make hooks` — shims to `script/install-precommit` - [ ] `make hooks` — shims to `script/install-precommit`
- [ ] `script/update-policy` / `make update-policy` — fetches the vendored
policy files from the `prompts` repo, commits them and pushes; identical
across repos
- [ ] README **Entrypoints** section documents the scripts and links the - [ ] README **Entrypoints** section documents the scripts and links the
standard standard
@@ -292,20 +284,19 @@ are thin shims calling them. Model scripts:
final stage is missing its `COPY --from=` edge to the gate phases. final stage is missing its `COPY --from=` edge to the gate phases.
- [ ] No secrets in repo, and none in the build context: enumerate a probe image - [ ] No secrets in repo, and none in the build context: enumerate a probe image
rather than reading `.dockerignore` rather than reading `.dockerignore`
- [ ] No mutable references to what the repo downloads and installs itself in - [ ] No mutable image/package references. The one exception is a package from
scripts and Dockerfiles. Distribution packages are outside this rule. They the distribution's own package index, which needs no version and no hash.
need no version and no hash, and updating and upgrading them is fine too. Pulling that index is also allowed. In a `Dockerfile`, the distribution is
In a `Dockerfile`, the distribution is the pinned base image's. On a host, the pinned base image's. On a host, it is the host's, and the host's
it is the host's, and the host's package manager stands for it, nix and package manager stands for it, nix and brew included. A package source
brew included. A package source that a `Dockerfile` adds itself is not the that a `Dockerfile` adds itself does not get this exception
distribution's own, so its packages are pinned
- [ ] node, yarn, npm, python, pip and venv come from the distribution wherever - [ ] node, yarn, npm, python, pip and venv come from the distribution wherever
possible, never from nvm, pyenv, corepack or `npm install -g yarn`. If the possible, never from nvm, pyenv, corepack or `npm install -g yarn`. If the
distribution's version is too old, use a newer distribution. node means distribution's version is too old, use a newer distribution. node means
the distribution's newest Node package, such as `nodejs-current` on Alpine the distribution's newest Node package, such as `nodejs-current` on Alpine
3.24 3.24
- [ ] yarn's release file is in git under `.yarn/releases/`, written once by - [ ] yarn's release file is in git under `.yarn/releases/`, written once by
`yarn set version` with an exact version (repositories with `yarn.lock`) `yarn set version` with an exact version
- [ ] No unnecessary files in repo root - [ ] No unnecessary files in repo root
- [ ] All dates written as YYYY-MM-DD - [ ] All dates written as YYYY-MM-DD
+33 -78
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-11-09 last_modified: 2026-11-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -21,28 +21,13 @@ style conventions are in separate documents:
with the authoritative source as policies evolve. with the authoritative source as policies evolve.
- **External references must be pinned by cryptographic hash.** This is the - **External references must be pinned by cryptographic hash.** This is the
single most important rule in this document. It covers what we download and single most important rule in this document. It covers everything fetched from
install ourselves in scripts and Dockerfiles: a remote source, including:
- Docker base images - Docker base images
- Go modules - Go modules
- Python packages from PyPI - Python packages
- npm packages - npm packages
- GitHub Actions - GitHub Actions
- release archives fetched with `curl` or `wget`
Packages from the distribution's own package manager are outside this rule.
They need no version and no hash, because the distribution is trusted. In a
`Dockerfile`, it is the pinned base image's distribution. On a host, it is
the host's. The host's package manager stands for its distribution, and that
includes nix and brew. Updating the package index and upgrading installed
packages are fine too. This command, for example, is allowed:
```sh
apt update && apt upgrade -y && apt install -y build-essential
```
A package source that a `Dockerfile` adds itself is not the distribution's
own, so its packages are pinned like any other download.
A version tag such as `@v4`, `@latest` or `:3.21` is server-mutable, and A version tag such as `@v4`, `@latest` or `:3.21` is server-mutable, and
therefore a remote code execution vulnerability. Reference an external therefore a remote code execution vulnerability. Reference an external
@@ -59,6 +44,17 @@ style conventions are in separate documents:
Unverified install scripts are arbitrary remote code execution. Double-check Unverified install scripts are arbitrary remote code execution. Double-check
every external reference in every file before committing. 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
distribution is trusted. In a `Dockerfile`, it is the pinned base image's
distribution. On a host, it is the host's. The host's package manager stands
for its distribution, and that includes nix and brew. These commands are
examples:
- `apt-get update`
- `apt-get install`
- `apk update`
- `apk add`
These come from the distribution wherever possible: These come from the distribution wherever possible:
- node, meaning the distribution's newest Node package, such as - node, meaning the distribution's newest Node package, such as
`nodejs-current` on Alpine 3.24 `nodejs-current` on Alpine 3.24
@@ -83,6 +79,9 @@ style conventions are in separate documents:
- the npm registry - the npm registry
- `go get` - `go get`
A package source that a `Dockerfile` adds itself is not the distribution's
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 bootstrap`
- `make setup` - `make setup`
@@ -93,8 +92,6 @@ style conventions are in separate documents:
- `make check` (runs `test`, `lint`, `fmt-check`) - `make check` (runs `test`, `lint`, `fmt-check`)
- `make docker` - `make docker`
- `make hooks` (installs pre-commit hook) - `make hooks` (installs pre-commit hook)
- `make update-policy` (fetches the vendored policy files, commits and
pushes)
A model Makefile is at A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
@@ -111,7 +108,6 @@ style conventions are in separate documents:
- `script/fmt-check` - `script/fmt-check`
- `script/check` - `script/check`
- `script/docker` - `script/docker`
- `script/update-policy`
The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they
run in minimal containers; alpine images, for example, have no bash. Locate run in minimal containers; alpine images, for example, have no bash. Locate
@@ -136,8 +132,8 @@ style conventions are in separate documents:
is out of step with `yarn.lock`. is out of step with `yarn.lock`.
Each repository commits the yarn that runs. `yarnPath` in `.yarnrc.yml` Each repository commits the yarn that runs. `yarnPath` in `.yarnrc.yml`
points at the release file in `.yarn/releases/`. The distribution's yarn points at the release file in `.yarn/releases/`. Whichever yarn the
only starts that file, and that file does the work. Alpine packages yarn 1, distribution packages hands the work to that file. Alpine packages yarn 1,
and Debian 13 packages yarn 4.1. The same committed yarn does the work on and Debian 13 packages yarn 4.1. The same committed yarn does the work on
both. Corepack never runs, and no yarn is downloaded. both. Corepack never runs, and no yarn is downloaded.
@@ -147,8 +143,8 @@ style conventions are in separate documents:
- `enableTelemetry: false`, because yarn otherwise sends usage reports from - `enableTelemetry: false`, because yarn otherwise sends usage reports from
any host that is not CI any host that is not CI
By default, yarn runs no dependency's install script. A dependency that must Under these settings yarn runs no package's install script. A package that
run one is named in `package.json`, as must run one is named in `package.json`, as
`"dependenciesMeta": { "<name>": { "built": true } }`. `"dependenciesMeta": { "<name>": { "built": true } }`.
`.gitattributes` carries `.yarn/releases/** binary`, so diffs and merges `.gitattributes` carries `.yarn/releases/** binary`, so diffs and merges
@@ -185,15 +181,12 @@ style conventions are in separate documents:
host. On a pristine checkout with nothing installed, the run dies there, host. On a pristine checkout with nothing installed, the run dies there,
after the containerised gates have passed. after the containerised gates have passed.
Five further scripts are our own extensions to the standard: Four further scripts are our own extensions to the standard:
- `script/check` runs `script/test`, `script/lint` and `script/fmt-check`. - `script/check` runs `script/test`, `script/lint` and `script/fmt-check`.
- `script/precommit` is what the git pre-commit hook runs, and it calls - `script/precommit` is what the git pre-commit hook runs, and it calls
`script/check`. `script/check`.
- `script/install-precommit` installs the git pre-commit hook. The - `script/install-precommit` installs the git pre-commit hook. The
`make hooks` target shims to it. `make hooks` target shims to it.
- `script/update-policy` fetches the vendored policy files from the
`prompts` repo, commits them and pushes. The `make update-policy` target
shims to it.
- `script/projectname` (literally that filename) simply outputs the - `script/projectname` (literally that filename) simply outputs the
project's name. project's name.
@@ -206,16 +199,9 @@ style conventions are in separate documents:
README must document the provided scripts in an **Entrypoints** section (see README must document the provided scripts in an **Entrypoints** section (see
the README requirements below). the README requirements below).
- Run the repo's own entrypoints, never the underlying tools directly. The - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
`script/` scripts are the single source of truth for how each operation runs. instead of invoking the underlying tools directly. The Makefile is the single
Calling them directly is fine, and so is calling the Makefile targets. The source of truth for how these operations are run.
Makefile targets must still exist, as the thin shims described above.
An agent whose work is confined to one package may run `go test` directly on
that package's suite, or on individual tests in it, while iterating. This is
the only exception to this rule. `go build`, `go vet` and the linter still
go through the entrypoints. A run while iterating is not a gate, so the full
`make check` is still required before every push.
- The Makefile is authoritative documentation for how the repo is used. Beyond - The 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:
@@ -249,9 +235,9 @@ style conventions are in separate documents:
- `package.json`, `yarn.lock`, `.yarnrc.yml` and `.yarn/releases/` - `package.json`, `yarn.lock`, `.yarnrc.yml` and `.yarn/releases/`
- `go.mod` and `go.sum` - `go.mod` and `go.sum`
- **The lint and test gates run in Docker, as phases of the `Dockerfile`.** - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
There is no separate lint file. `script/lint` and `script/test` each build one no separate lint file. `script/lint` and `script/test` each build one phase
phase and nothing else: and nothing else:
```sh ```sh
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --build-arg CACHEBUST="$(date +%s)-$$" \
@@ -681,31 +667,11 @@ style conventions are in separate documents:
- Use platform-standard formatters: - Use platform-standard formatters:
- `black` for Python - `black` for Python
- `prettier` for JS/CSS/Markdown/HTML - `prettier` for JS/CSS/Markdown/HTML
- `gofmt`, then `golines -m 77`, for Go - `go fmt` for Go
Always use default configuration, with three exceptions: Always use default configuration, with two exceptions:
- four-space indents (except Go) - four-space indents (except Go)
- `proseWrap: always` for Markdown (hard-wrap at 80 columns) - `proseWrap: always` for Markdown (hard-wrap at 80 columns)
- a 77-column limit for Go, set by `golines -m 77`
`script/fmt` runs `gofmt` and then `golines -m 77` on Go code.
`script/fmt-check` fails when either of them would change a file. `golines`
comes from `github.com/segmentio/golines`. `script/bootstrap` installs it
with `go install`, pinned to a commit hash as below:
```sh
# golines v0.13.0, 2025-08-21
go install github.com/segmentio/golines@8f32f0f7e89c30f572c7f2cd3b2a48016b9d8bbf
```
`script/bootstrap` reads the installed version from the `mod` line of
`go version -m "$(command -v golines)"`, which reports `v0.13.0`. It does
not use `golines --version`, which prints `golines vdev` for a binary that
`go install` built.
`golines` cannot shorten every line: long string literals and comments stay
as they are. golangci-lint's `lll` limit therefore stays at 88 columns, so
it fails only lines far past the formatter's limit.
Documentation and writing repos (Markdown, HTML, CSS) should also have Documentation and writing repos (Markdown, HTML, CSS) should also have
`.prettierrc` and `.prettierignore`. `.prettierrc` and `.prettierignore`.
@@ -933,17 +899,6 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- A repo updates its policy files with one command: `make update-policy`. It
runs `script/update-policy`, which fetches each vendored file the repo carries
from the `prompts` repo, commits them and pushes. It uses the head of `main`,
or the commit given by a full hash: `script/update-policy <hash>`. The list of
vendored files is at the top of that script. The script replaces each file and
keeps only the repo's own entries:
- in `.gitignore`, `.editorconfig` and `.dockerignore`: the lines below the
comment that ends the canonical file
- in `.golangci.yml`: the entries the repo added to the `deny` list of the
`test-support` depguard rule
- `.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
@@ -1169,12 +1124,12 @@ style conventions are in separate documents:
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`, `update-policy`) `install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`, - JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`,
`.gitattributes`, `.prettierrc`, `.prettierignore` `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It - Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
+5 -5
View File
@@ -4,10 +4,10 @@
# installed tools are skipped. Base tools, node and yarn come from nix, # installed tools are skipped. Base tools, node and yarn come from nix,
# apt, brew, or apk (detected in that order), with no version and no # apt, brew, or apk (detected in that order), with no version and no
# hash, because the distribution is trusted. It installs node, yarn and # hash, because the distribution is trusted. It installs node, yarn and
# the node packages only when yarn.lock is in the tree. The # the node packages only when yarn.lock is in the tree. Whichever yarn
# distribution's yarn only starts the release file that yarnPath in # the distribution packages hands the work to the release file that
# .yarnrc.yml names, and that file does the work. So git pins the yarn # yarnPath in .yarnrc.yml names, so git pins the yarn that runs. Assumes
# that runs. Assumes nothing is present. # nothing is present.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -80,7 +80,7 @@ main() {
# A Dockerfile stage that needs no yarn copies no yarn.lock. # A Dockerfile stage that needs no yarn copies no yarn.lock.
if [ -f yarn.lock ]; then if [ -f yarn.lock ]; then
if missing node; then pkg_install nodejs_latest nodejs node nodejs-current; fi if missing node; then pkg_install nodejs nodejs node nodejs-current; fi
if missing yarn && missing yarnpkg; then if missing yarn && missing yarnpkg; then
pkg_install yarn yarnpkg yarn yarn pkg_install yarn yarnpkg yarn yarn
fi fi
-215
View File
@@ -1,215 +0,0 @@
#!/bin/sh
# script/update-policy: replace this repo's vendored policy files with
# the canonical ones from the prompts repo, commit them and push.
# Identical in all repos. Our own extension to
# scripts-to-rule-them-all.
#
# Usage: script/update-policy [full commit hash of prompts]
# Without a hash it uses the head of the prompts main branch.
#
# The script writes nothing until every file has been fetched and
# merged. A repo's own entries are kept:
# - .gitignore, .editorconfig, .dockerignore: the lines below the
# canonical file's last line. An entry above that line which the
# canonical file lacks stops the script.
# - .golangci.yml: the entries the repo added to the deny list of the
# test-support rule. Any other line the canonical file lacks stops
# the script, unless it is blank or a comment.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PROMPTS_URL="https://git.eeqj.de/sneak/prompts"
# Paths in the prompts repo. A file this repo does not carry is skipped.
VENDORED_FILES="
.dockerignore
.editorconfig
.gitea/workflows/check.yml
.gitignore
.golangci.yml
.prettierrc
prompts/API_POLICIES.md
prompts/CODE_STYLEGUIDE.md
prompts/CODE_STYLEGUIDE_GO.md
prompts/CODE_STYLEGUIDE_JS.md
prompts/CODE_STYLEGUIDE_PYTHON.md
prompts/EXISTING_REPO_CHECKLIST.md
prompts/GO_HTTP_SERVER_CONVENTIONS.md
prompts/LLM_PROSE_TELLS.md
prompts/NEW_REPO_CHECKLIST.md
prompts/REPO_POLICIES.md
"
die() {
echo "update-policy: $*" >&2
exit 1
}
# local_path <path in prompts>: print where this repo keeps the file, or
# nothing if git does not track it here. A prompts/NAME.md lands at
# docs/NAME.md or NAME.md.
local_path() {
name="${1#prompts/}"
if [ "$name" != "$1" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then
echo "docs/$name"
elif [ -n "$(git ls-files -- "$name")" ]; then
echo "$name"
fi
}
# local_only_lines <fetched> <local> [end]: print each line of the local
# file, above line end if given, that the fetched file lacks. Blank
# lines and comments do not count.
local_only_lines() {
awk -v end="${3:-}" '
NR == FNR { fetched[$0]; next }
(end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in fetched)
' "$1" "$2"
}
# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file. Stop if a line above
# that one is missing from the fetched file, unless it is blank or a
# comment: replacing the file would delete it.
keep_lines_below_last() {
last="$(tail -n 1 "$1")"
number="$(grep -n -F -x -- "$last" "$2" | head -n 1 | cut -d: -f1)"
if [ -z "$last" ] || [ -z "$number" ]; then
die "$2 lacks this line; add it and put the repo's own entries below it: $last"
fi
own="$(local_only_lines "$1" "$2" "$number")"
if [ -n "$own" ]; then
die "$2 has entries above its last canonical line that the canonical file lacks; move each below that line to keep it, or delete it:
$own"
fi
tail -n "+$((number + 1))" "$2" >> "$1"
}
# keep_deny_entries <fetched> <local>: add to the deny list of the
# test-support rule in the fetched file the entries only the local file
# has. An entry is a "- pkg:" line, the lines under it and any comment
# above it. Stop if the local file still has a line the result lacks,
# unless it is blank or a comment: replacing the file would delete it.
keep_deny_entries() {
awk '
function indent(line) { return match(line, /[^ ]/) - 1 }
function print_local_entries(i) {
for (i = 1; i <= count; i++)
if (!(pkg[i] in fetched_pkg))
printf "%s", entry[i]
}
FNR == 1 { in_rule = 0; in_list = 0 }
in_list && /[^ ]/ && indent($0) <= list_indent {
in_list = 0
if (FILENAME == ARGV[2]) print_local_entries()
}
in_list {
is_entry = ($1 == "-" && $2 == "pkg:")
if (FILENAME == ARGV[2]) {
if (is_entry) fetched_pkg[$3]
print
} else if (is_entry) {
count++
pkg[count] = $3
entry[count] = comment $0 "\n"
comment = ""
} else if (/^ *#/) {
comment = comment $0 "\n"
} else if (count) {
entry[count] = entry[count] $0 "\n"
}
next
}
in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 }
/^ *test-support:/ { in_rule = 1; rule_indent = indent($0) }
in_rule && /^ *deny:/ {
in_rule = 0
in_list = 1
list_indent = indent($0)
if (FILENAME == ARGV[2]) found = 1
}
FILENAME == ARGV[2] { print }
END {
if (in_list) print_local_entries()
exit !found
}
' "$2" "$1" > "$1.merged" ||
die "the fetched $2 has no deny list in its test-support rule"
mv "$1.merged" "$1"
own="$(local_only_lines "$1" "$2")"
if [ -n "$own" ]; then
die "$2 has lines the canonical file lacks; add each to the canonical file to keep it, or delete it:
$own"
fi
}
main() {
cd "$ROOT"
[ "$#" -le 1 ] || die "usage: script/update-policy [full commit hash]"
project="$(script/projectname)"
if [ "$project" = "prompts" ]; then
die "this repo is the source of the policy files"
fi
commit="${1:-}"
if [ -z "$commit" ]; then
head_line="$(git ls-remote "$PROMPTS_URL.git" refs/heads/main)"
commit="${head_line%%[[:space:]]*}"
fi
printf '%s\n' "$commit" | grep -Eqx '[0-9a-f]{40}' ||
die "not a full commit hash: '$commit'"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
carried=""
for file in $VENDORED_FILES; do
path="$(local_path "$file")"
[ -n "$path" ] || continue
if [ -n "$(git status --porcelain -- "$path")" ]; then
die "$path has uncommitted changes"
fi
mkdir -p "$tmp/$(dirname "$path")"
curl --fail --silent --show-error --max-time 60 \
--output "$tmp/$path" \
"$PROMPTS_URL/raw/commit/$commit/$file" ||
die "cannot fetch $file at $commit"
carried="$carried $path"
done
[ -n "$carried" ] || die "this repo carries none of the vendored files"
for path in $carried; do
case "$path" in
.gitignore | .editorconfig | .dockerignore)
keep_lines_below_last "$tmp/$path" "$path"
;;
.golangci.yml)
keep_deny_entries "$tmp/$path" "$path"
;;
esac
done
for path in $carried; do
cp "$tmp/$path" "$path"
git add -- "$path"
done
if git diff --cached --quiet -- $carried; then
echo "update-policy: already at prompts $commit, nothing to commit"
else
git commit -m "Update policy files from prompts $commit" -- $carried
fi
# Also pushes when there was nothing to commit, so a run after a
# failed push delivers the commit that push left behind.
git push origin HEAD || die "push failed; fix that and run this again"
}
main "$@"
-519
View File
@@ -1,519 +0,0 @@
#!/bin/sh
# test/update-policy.sh: test script/update-policy on throwaway repos.
# Each scenario gets one temp directory holding a repo, its local bare
# origin, the canonical files and a stub curl that serves them, so
# nothing touches the network.
set -eu
UPDATE_POLICY="$(cd "$(dirname "$0")/.." && pwd -P)/script/update-policy"
dir=""
trap 'rm -rf "$dir"' EXIT
fail() {
echo "FAIL: $scenario: $*" >&2
cat "$dir/output" >&2
exit 1
}
write_stub_curl() {
mkdir "$dir/bin"
cat > "$dir/bin/curl" << 'EOF'
#!/bin/sh
previous=""
fail_flag=""
for argument in "$@"; do
[ "$previous" = "--output" ] && output="$argument"
[ "$argument" = "--fail" ] && fail_flag=yes
previous="$argument"
done
url="$previous"
base="https://git.eeqj.de/sneak/prompts/raw/commit/$HASH/"
path="${url#"$base"}"
source="$(dirname "$0")/../canonical/$path"
if [ "$path" = "$url" ] || [ ! -f "$source" ]; then
[ -n "$fail_flag" ] && exit 22
echo "404 page not found" > "$output"
exit 0
fi
cp "$source" "$output"
EOF
chmod +x "$dir/bin/curl"
}
write_canonical_files() {
mkdir -p "$dir/canonical/prompts"
cat > "$dir/canonical/.gitignore" << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.dockerignore" << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.editorconfig" << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
EOF
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc"
echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md"
echo "new api policy" > "$dir/canonical/prompts/API_POLICIES.md"
}
# write_outdated_files: this repo's files, one canonical version behind,
# with the repo's own entries in the places the script keeps them. The
# comment above the closing comment in .gitignore is replaced, not kept.
write_outdated_files() {
cat > "$dir/repo/.gitignore" << 'EOF'
*.log
# An outdated comment.
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.dockerignore" << 'EOF'
**/*.log
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.editorconfig" << 'EOF'
root = true
[*]
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
cat > "$dir/repo/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 2 }' > "$dir/repo/.prettierrc"
echo "old policy" > "$dir/repo/REPO_POLICIES.md"
}
# start_scenario <name>: make a new temp directory holding a repo with
# the outdated files, not yet committed, and an empty origin.
start_scenario() {
scenario="$1"
# The one commit the stub curl serves; every other commit gets a 404.
export HASH=1111111111111111111111111111111111111111
rm -rf "$dir"
dir="$(mktemp -d)"
: > "$dir/output"
write_stub_curl
write_canonical_files
git init -q --bare -b main "$dir/origin.git"
git init -q -b main "$dir/repo"
git -C "$dir/repo" config user.name "Test"
git -C "$dir/repo" config user.email "test@example.com"
git -C "$dir/repo" remote add origin "$dir/origin.git"
mkdir "$dir/repo/script"
cp "$UPDATE_POLICY" "$dir/repo/script/update-policy"
printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname"
chmod +x "$dir/repo/script/projectname"
write_outdated_files
}
# commit_and_push_fixture <paths...>: commit those paths, remove the
# rest of the outdated files, and record the commit as $before.
commit_and_push_fixture() {
(
cd "$dir/repo"
git add script "$@"
git commit -q -m "Fixture"
git push -q origin HEAD
git clean -q -f -x
)
before="$(git -C "$dir/repo" rev-parse HEAD)"
}
run_update_policy() {
status=0
(cd "$dir/repo" && PATH="$dir/bin:$PATH" script/update-policy "$@") \
> "$dir/output" 2>&1 || status=$?
}
reject_pushes() {
printf '#!/bin/sh\nexit 1\n' > "$dir/origin.git/hooks/pre-receive"
chmod +x "$dir/origin.git/hooks/pre-receive"
}
accept_pushes() {
rm "$dir/origin.git/hooks/pre-receive"
}
head_of_repo() {
git -C "$dir/repo" rev-parse HEAD
}
head_of_origin() {
git -C "$dir/origin.git" rev-parse refs/heads/main
}
expect_success() {
[ "$status" -eq 0 ] || fail "exited $status"
}
expect_failure() {
[ "$status" -ne 0 ] || fail "exited 0"
}
expect_output() {
grep -q -F -e "$1" "$dir/output" || fail "output lacks: $1"
}
expect_output_without() {
if grep -q -F -e "$1" "$dir/output"; then
fail "output has: $1"
fi
}
# expect_file <path>: the file must equal standard input.
expect_file() {
cat > "$dir/expected"
diff "$dir/expected" "$dir/repo/$1" >&2 || fail "$1 is wrong"
}
expect_absent() {
[ ! -e "$dir/repo/$1" ] || fail "$1 exists"
}
expect_nothing_written() {
[ -z "$(git -C "$dir/repo" status --porcelain)" ] ||
fail "the working tree changed"
[ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
test_own_entries_kept() {
start_scenario "own entries kept in all four files"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.golangci.yml .prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_success
expect_file .gitignore << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .dockerignore << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .editorconfig << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
expect_file .golangci.yml << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
expect_file REPO_POLICIES.md << 'EOF'
new policy
EOF
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
[ "$(head_of_repo)" != "$before" ] || fail "no commit was made"
git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" ||
fail "the commit message does not name the hash"
}
test_files_not_carried_are_skipped() {
start_scenario "files the repo does not carry skipped"
mkdir "$dir/repo/docs"
mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md"
commit_and_push_fixture docs/REPO_POLICIES.md
run_update_policy "$HASH"
expect_success
expect_file docs/REPO_POLICIES.md << 'EOF'
new policy
EOF
expect_absent REPO_POLICIES.md
expect_absent API_POLICIES.md
expect_absent docs/API_POLICIES.md
expect_absent .gitignore
expect_absent .dockerignore
expect_absent .editorconfig
expect_absent .golangci.yml
expect_absent .prettierrc
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
}
test_nothing_changed() {
start_scenario "nothing changed"
cp "$dir/canonical/.prettierrc" "$dir/repo/.prettierrc"
commit_and_push_fixture .prettierrc
run_update_policy "$HASH"
expect_success
expect_output "nothing to commit"
expect_nothing_written
}
test_bad_commit_hash() {
for hash in not-a-hash 0000000000000000000000000000000000000000; do
start_scenario "bad commit hash $hash stops with nothing written"
commit_and_push_fixture .gitignore .prettierrc REPO_POLICIES.md
run_update_policy "$hash"
expect_failure
expect_nothing_written
done
# The last hash has the right form, so it is the one that reaches curl.
expect_output "cannot fetch"
}
test_push_failure() {
start_scenario "push failure exits non-zero"
commit_and_push_fixture .prettierrc
reject_pushes
run_update_policy "$HASH"
expect_failure
expect_output "push failed"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
# Each of these files, with the repo's own entries above the closing
# comment. The comment added with them must not be named.
test_own_entry_above_closing_comment() {
for file in .gitignore .dockerignore .editorconfig; do
start_scenario "own entries above the closing comment of $file stop"
{
printf '/myapp\n/.tool/\n\n# A comment of our own.\n'
cat "$dir/repo/$file"
} > "$dir/merged"
mv "$dir/merged" "$dir/repo/$file"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_failure
expect_output "$file has entries"
expect_output "/myapp"
expect_output "/.tool/"
expect_output_without "A comment of our own."
expect_nothing_written
done
}
test_retry_after_failed_push() {
start_scenario "a retry after a failed push delivers the commit"
commit_and_push_fixture .prettierrc
reject_pushes
run_update_policy "$HASH"
expect_failure
committed="$(head_of_repo)"
[ "$committed" != "$before" ] || fail "no commit was made"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
accept_pushes
run_update_policy "$HASH"
expect_success
[ "$(head_of_repo)" = "$committed" ] || fail "the retry made a new commit"
[ "$(head_of_origin)" = "$committed" ] ||
fail "the retry did not deliver the commit"
}
test_uncommitted_change_stops() {
start_scenario "a carried file with uncommitted changes stops"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
echo "uncommitted" >> "$dir/repo/REPO_POLICIES.md"
run_update_policy "$HASH"
expect_failure
expect_output "REPO_POLICIES.md has uncommitted changes"
expect_file REPO_POLICIES.md << 'EOF'
old policy
uncommitted
EOF
[ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
test_gitignore_lacks_canonical_last_line() {
start_scenario ".gitignore lacking the canonical last line stops"
printf '*.log\n/myapp\n' > "$dir/repo/.gitignore"
commit_and_push_fixture .gitignore .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output ".gitignore lacks this line"
expect_nothing_written
}
test_fetched_golangci_without_deny_list() {
start_scenario "a fetched .golangci.yml with no deny list stops"
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
files:
- "**/*.go"
other:
deny:
- pkg: example.com/other
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output "no deny list in its test-support rule"
expect_nothing_written
}
test_own_golangci_setting_stops() {
start_scenario "a .golangci.yml setting the canonical file lacks stops"
cat >> "$dir/repo/.golangci.yml" << 'EOF'
# A comment of our own.
forbidigo:
forbid:
- pattern: model.Operator
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output ".golangci.yml has lines"
expect_output "forbidigo:"
expect_output "- pattern: model.Operator"
expect_output_without "A comment of our own."
expect_nothing_written
}
test_source_repo_stops() {
start_scenario "the repo the policy files come from stops"
printf '#!/bin/sh\necho prompts\n' > "$dir/repo/script/projectname"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_failure
expect_output "this repo is the source of the policy files"
expect_nothing_written
}
test_staged_file_stays_out_of_commit() {
start_scenario "a file staged before the script starts stays out of its commit"
commit_and_push_fixture .prettierrc
echo "notes" > "$dir/repo/notes.txt"
git -C "$dir/repo" add notes.txt
run_update_policy "$HASH"
expect_success
[ "$(git -C "$dir/repo" show --name-only --format= HEAD)" = ".prettierrc" ] ||
fail "the commit holds more than the policy file"
[ "$(git -C "$dir/repo" diff --cached --name-only)" = "notes.txt" ] ||
fail "notes.txt is no longer staged"
}
test_short_commit_hash_refused() {
start_scenario "a hexadecimal hash shorter than 40 characters is refused"
commit_and_push_fixture .prettierrc
run_update_policy "${HASH%?}"
expect_failure
expect_output "not a full commit hash"
expect_nothing_written
}
# git is pointed at a local repository in place of the prompts remote,
# and the stub curl serves the head of that repository's main.
test_no_hash_uses_head_of_main() {
start_scenario "with no hash the script uses the head of main"
commit_and_push_fixture .prettierrc
git init -q --bare -b main "$dir/prompts.git"
git -C "$dir/repo" push -q "$dir/prompts.git" HEAD:refs/heads/main
git -C "$dir/repo" config "url.$dir/prompts.git.insteadOf" \
"https://git.eeqj.de/sneak/prompts.git"
HASH="$before"
run_update_policy
expect_success
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" ||
fail "the commit message does not name the head of main"
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
}
main() {
test_own_entries_kept
test_files_not_carried_are_skipped
test_nothing_changed
test_bad_commit_hash
test_push_failure
test_own_entry_above_closing_comment
test_retry_after_failed_push
test_uncommitted_change_stops
test_gitignore_lacks_canonical_last_line
test_fetched_golangci_without_deny_list
test_own_golangci_setting_stops
test_source_repo_stops
test_staged_file_stays_out_of_commit
test_short_commit_hash_refused
test_no_hash_uses_head_of_main
echo "update-policy tests passed"
}
main "$@"