6 Commits
Author SHA1 Message Date
clawbot 8c889fa622 REPO_POLICIES: script/ entrypoints are the single source of truth (#157)
check / check (push) Successful in 1m16s
2026-11-06 01:18:52 +01:00
clawbot 6e5fa4c607 REPO_POLICIES: pinning covers what we download ourselves; distribution packages are outside it (#156)
check / check (push) Canceled after 3s
2026-11-06 01:18:44 +01:00
clawbot 9389bf0172 Commit yarn 4 in the tree and install with --immutable (closes #149)
check / check (push) Successful in 16s
Follows sneak's ruling at
#149 (comment). This
repository now carries yarn 4.18.1 under .yarn/releases. The
distribution's yarn only starts that file, and that file does the work.
yarn.lock is converted once, and script/bootstrap installs with
--immutable instead of stopping on yarn's major version. Node is the
distribution's newest Node package: nodejs-current on Alpine and
nodejs_latest on nix. 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 21:32:55 +01:00
clawbot 3066aae835 Allow go test on one package while iterating (closes #51)
check / check (push) Canceled after 0s
Follows sneak's ruling at
#51 (comment). 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. `go build`,
`go vet` and the linter still go through make targets. The full `make check`
is still required before every push. The `Makefile` gains nothing.

The rule on Docker now says the lint and test gates run there, because a run
while iterating is not a gate.

Model: opus-5-5
2026-11-04 19:32:37 +01:00
clawbot 86c7617f86 Install node, yarn and python from the distribution (closes #146)
check / check (push) Canceled after 0s
Follows sneak's ruling at
#146 (comment). Runtimes
and the installer tools bundled with python come from the distribution,
with no version and no hash. Third-party packages stay pinned by hash.

script/bootstrap drops nvm and corepack. It installs node, yarn and the
node packages only when yarn.lock is in the tree, and stops there when
yarn's major version is not 1. This repository's Dockerfile starts from
Alpine 3.24. The Python example starts from debian:trixie-slim, keeps
pipenv at $HOME/.pipenv on a host and in an image, and sets
PIP_ONLY_BINARY=:all:, so pip downloads no build tools without a hash
check.

Model: opus-5-5
2026-11-04 09:55:26 +01:00
clawbotandsneak b36ddacee1 Rewrite REPO_POLICIES.md sentence by sentence (closes #136) (#147)
check / check (push) Canceled after 0s
Rewrites every sentence of `prompts/REPO_POLICIES.md` against the sentence rules, for #136. No rule's meaning, `must`/`should` or order changed. Code blocks, digests, URLs and commands are byte-identical. Both checklists carry the same rewrite of the paragraph they quote.

## For sneak

Kept as they are: each looks false or contradicts another rule. Line numbers are this branch's.

- Lines 196-197: the final stage carries the `COPY --from=` lines; in the `Dockerfile` (345-346) the build stage does, the runtime stage none.
- Lines 629-641 and 670-675: `make test` runs `go test` and `pytest` on the host; 80-81 make targets shims, 183-185 make `script/test` a Docker build.
- Lines 23-37 pin by hash; 103-112 install node (nvm) and yarn (corepack) by version. Tracked at #146.
- Line 131 counts four extensions; `script/lint`, `script/fmt`, `script/fmt-check`, `script/docker` (85-89) are not in the standard either.
- Lines 149-151: the Makefile is the single source of truth; 80-81 (implementation in `script/`), 211 (tool invoked in a phase), 616 (test command in the `Dockerfile`).
- Lines 64-68 require `make test` and `make lint`, which 183-185 make Docker builds; line 167 only says a repo should have a `Dockerfile`.
- Line 759 says byte-identical; the block at 767 has `tag="$(script/projectname)"`, both scripts have `tag="$("$SCRIPT_DIR/projectname")"`.
- Lines 834-839: `script/bootstrap` never tests presence, 860 excepts only node; `ensure_yarn` in the canonical `script/bootstrap` skips the install when yarn is present, without a version check.

## Disclosures

- Judgement call: short name lists stay inline under about 15 words.

Model: fable-5-1
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #147
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-11-04 05:17:16 +01:00
18 changed files with 1678 additions and 464 deletions
+6
View File
@@ -35,6 +35,12 @@
# `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`.
+3
View File
@@ -2,3 +2,6 @@
# both sides instead of conflicting. Git never reports a conflict here: read # both sides instead of conflicting. Git never reports a conflict here: read
# the merged entries after every merge or rebase. # the merged entries after every merge or rebase.
TODO.md merge=union TODO.md merge=union
# yarn's release file is one opaque file in diffs and merges.
.yarn/releases/** binary
+5 -3
View File
@@ -19,10 +19,12 @@ Thumbs.db
# Node # Node
node_modules/ node_modules/
.yarn/*
!.yarn/releases
# Secrets. Unanchored like every entry above, so each matches at every # Secrets. Unanchored, so each matches at every depth. Matching is
# depth. Matching is case-sensitive on Linux, so names use character # case-sensitive on Linux, so names use character ranges rather than a
# ranges rather than a lowercase form that misses `Server.Key`. # 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
+1
View File
@@ -1,2 +1,3 @@
node_modules/ node_modules/
.yarn/
yarn.lock yarn.lock
+1000
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
enableTelemetry: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.18.1.cjs
+24 -13
View File
@@ -2,17 +2,24 @@
# lint` or `script/lint`, which are themselves a docker build and would # lint` or `script/lint`, which are themselves a docker build and would
# recurse into a daemon that does not exist in a build step. # recurse into a daemon that does not exist in a build step.
# #
# Every stage declares ARG CACHEBUST first: script/ passes a new value on # In every stage, ARG CACHEBUST comes right after the step that installs
# every build, which reruns every RUN step after it. # 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
# 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
# file in .yarn/releases/, so every stage copies that file and
# .yarnrc.yml before it runs script/bootstrap.
# #
# node 22-alpine, 2026-02-22 # alpine 3.24.3, 2026-11-04
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS lint
RUN apk add --no-cache git make nodejs-current yarn
ARG CACHEBUST ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/releases/ .yarn/releases/
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
@@ -21,14 +28,16 @@ RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Test phase, same shape and for the same reason. # Test phase, same shape and for the same reason.
# #
# node 22-alpine, 2026-02-22 # alpine 3.24.3, 2026-11-04
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS test
RUN apk add --no-cache git make nodejs-current yarn
ARG CACHEBUST ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/releases/ .yarn/releases/
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
@@ -41,8 +50,9 @@ RUN echo "No tests defined."
# this image cannot be produced unless lint and test passed. A stage # this image cannot be produced unless lint and test passed. A stage
# appended after this one would drop all three out of a plain build. # appended after this one would drop all three out of a plain build.
# #
# node 22-alpine, 2026-02-22 # alpine 3.24.3, 2026-11-04
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache git make nodejs-current yarn
ARG CACHEBUST ARG CACHEBUST
WORKDIR /app WORKDIR /app
@@ -50,10 +60,11 @@ WORKDIR /app
COPY --from=lint /app/package.json /dev/null COPY --from=lint /app/package.json /dev/null
COPY --from=test /app/package.json /dev/null COPY --from=test /app/package.json /dev/null
# script/bootstrap installs all prerequisites. Manifests are copied # script/bootstrap finds the system packages above and installs the node
# first so that layer stays cached until dependencies change. # packages.
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/releases/ .yarn/releases/
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
+4 -4
View File
@@ -111,7 +111,7 @@ development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide: alpine. We provide:
- `script/bootstrap` — install all dependencies (yarn install) - `script/bootstrap` — install all dependencies (`yarn install --immutable`)
- `script/setup` — set up the repo for development after a fresh clone: runs - `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
@@ -156,9 +156,9 @@ A stage with none of these steps declares `ARG CACHEBUST` right after `FROM`.
The `script/` builds pass a new `CACHEBUST` value on every build, which reruns The `script/` builds pass a new `CACHEBUST` value on every build, which reruns
every `RUN` step after that line and leaves the layers above it and the cache every `RUN` step after that line and leaves the layers above it and the cache
mounts in place; `--no-cache` would build those layers again and empty the mounts in place; `--no-cache` would build those layers again and empty the
mounts. This repo's own `Dockerfile` declares `ARG CACHEBUST` right after `FROM` mounts. This repo's own `Dockerfile` installs git, make, node and yarn right
in every stage: each stage installs system packages and node packages together after `FROM` in every stage, above `ARG CACHEBUST`. `script/bootstrap` installs
in one `script/bootstrap` step, and check builds may keep system packages but the node packages below that line, because check builds keep system packages but
not node packages. not node packages.
## Rationale ## Rationale
+45 -1
View File
@@ -21,7 +21,51 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-11-03: Rewrote `prompts/REPO_POLICIES.md` sentence by sentence - 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
`yarn install --immutable` (https://git.eeqj.de/sneak/prompts/issues/149), on
sneak's ruling at
https://git.eeqj.de/sneak/prompts/issues/149#issuecomment-167558. Product
repositories convert when they next have work. These changed:
- This repository carries `.yarn/releases/yarn-4.18.1.cjs`, and its
`yarn.lock` is in yarn 4's format.
- `script/bootstrap` no longer stops on yarn's major version.
- Node comes from the distribution's newest Node package, which on Alpine
3.24 is `nodejs-current`.
- A `Dockerfile` stage that needs yarn also copies `.yarnrc.yml` and
`.yarn/releases/`, and may start from any distribution that packages node
and yarn.
- Both checklists give the steps, and the ignore files keep yarn's install
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
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#issuecomment-166033. Third-party
packages stay pinned by hash. These changed:
- `script/bootstrap` no longer uses nvm or corepack, and stops when yarn's
major version is not 1.
- `script/bootstrap` installs node, yarn and the node packages only when
`yarn.lock` is in the tree.
- `script/bootstrap`, `script/fmt` and `script/fmt-check` run `yarn` or
`yarnpkg`, whichever is present.
- This repository's `Dockerfile` starts from Alpine 3.24.
- The Python `test` phase example starts from `debian:trixie-slim`.
- A Python repository's `script/bootstrap` creates its virtual environment
at `$HOME/.pipenv`, so it works on a host and in a `Dockerfile`.
- `PIP_ONLY_BINARY=:all:` stops a package with no wheel, so pip downloads no
build tools without a hash check.
- 2026-11-04: Rewrote `prompts/REPO_POLICIES.md` sentence by sentence
(https://git.eeqj.de/sneak/prompts/issues/136), on sneak's yes at (https://git.eeqj.de/sneak/prompts/issues/136), on sneak's yes at
https://git.eeqj.de/sneak/prompts/issues/136#issuecomment-165193. Every rule https://git.eeqj.de/sneak/prompts/issues/136#issuecomment-165193. Every rule
keeps its meaning and its place, and `must` and `should` stay as they were. keeps its meaning and its place, and `must` and `should` stay as they were.
+2 -1
View File
@@ -2,5 +2,6 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"prettier": "3.8.1" "prettier": "3.8.1"
} },
"packageManager": "yarn@4.18.1"
} }
+3 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: Code Styleguide — JavaScript title: Code Styleguide — JavaScript
last_modified: 2026-02-22 last_modified: 2026-11-04
--- ---
1. Use `const` for everything. If you need to reassign, use `let`. Never use 1. Use `const` for everything. If you need to reassign, use `let`. Never use
@@ -8,7 +8,8 @@ last_modified: 2026-02-22
1. Use yarn for package management, avoid using npm. 1. Use yarn for package management, avoid using npm.
1. Use LTS node versions. 1. Install node from the distribution. If its version is too old, use a newer
distribution.
1. Use `prettier` for code formatting, with four spaces for indentation. 1. Use `prettier` for code formatting, with four spaces for indentation.
+104 -48
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist title: Existing Repo Checklist
last_modified: 2026-11-03 last_modified: 2026-11-06
--- ---
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
@@ -56,14 +56,14 @@ with your task.
- 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
packages from `Pipfile.lock` packages from `Pipfile.lock`
- [ ] A stage with none of these steps declares `ARG CACHEBUST` right after - [ ] A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that installs anything else as well, such as a `FROM`. A step that also installs anything else comes after
`script/bootstrap` that also installs node packages, comes after it too. `ARG CACHEBUST` too. A `script/bootstrap` that also installs node packages
The `script/` builds pass a new `CACHEBUST` value on every build, which is one. The `script/` builds pass a new `CACHEBUST` value on every build,
reruns every `RUN` step after that line and leaves the layers above it and which reruns every `RUN` step after that line and leaves the layers above
the cache mounts in place; `--no-cache` would build those layers again and it and the cache mounts in place; `--no-cache` would build those layers
empty the mounts. A stage without the `ARG` line is served from cache on again and empty the mounts. A stage without the `ARG` line is served from
an unchanged tree, so the scripts and the `ARG` lines are re-vendored in cache on an unchanged tree, so the scripts and the `ARG` lines are
one commit. re-vendored in one commit.
- [ ] Go: each Go stage downloads the modules (`COPY go.mod go.sum ./`, - [ ] Go: each Go stage downloads the modules (`COPY go.mod go.sum ./`,
`RUN go mod download`) above `ARG CACHEBUST`; the steps that compile `RUN go mod download`) above `ARG CACHEBUST`; the steps that compile
(`golangci-lint run`, `go test`, `go build`) mount Go's build cache (`golangci-lint run`, `go test`, `go build`) mount Go's build cache
@@ -76,15 +76,25 @@ with your task.
- [ ] Python: a `test` phase that installs its packages from `Pipfile.lock` does - [ ] Python: a `test` phase that installs its packages from `Pipfile.lock` does
it above `ARG CACHEBUST`, with `COPY script/ script/`, it above `ARG CACHEBUST`, with `COPY script/ script/`,
`COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When `COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When
`script/bootstrap` runs in that step, it installs nothing beyond system `script/bootstrap` runs in that step, it installs nothing beyond these:
packages, pipenv and its dependencies, and the packages from - system packages
`Pipfile.lock`. It installs pipenv and its dependencies by hash, with - pipenv and its dependencies
`pip install --require-hashes`. It installs the packages from - the packages from `Pipfile.lock`
`Pipfile.lock` with `pipenv install --deploy`, plus `--dev` when the tests
need the development packages. It installs no Python there, because the It creates a virtual environment at `$HOME/.pipenv` with `python3 -m venv`
tests use the base image's. The tests run after `ARG CACHEBUST`, and every when it is missing, then uses that environment's `pip` and `pipenv`. On a
other stage installs its Python packages after it, as in the Python `test` host, it first installs the distribution's python and venv packages when
phase example in `REPO_POLICIES.md` they are missing. It installs pipenv and its dependencies by hash, with
`pip install --require-hashes`. It installs the packages from `Pipfile.lock`
with `pipenv install --deploy`, plus `--dev` when the tests need the
development packages. It sets `PIP_ONLY_BINARY=:all:` for both installs, so
a package with no wheel for the image stops the install. In that step it
installs no Python, because a step above it already did. `Pipfile` names no
`python_version`, because `--deploy` stops when the distribution's Python
differs from it. The tests run after `ARG CACHEBUST`, and every other stage
installs its Python packages after it, as in the Python `test` phase example
in `REPO_POLICIES.md`
- [ ] The gate phases invoke their tools directly, never through `make lint` or - [ ] The gate phases invoke their tools directly, never through `make lint` or
`script/test` — those are themselves a `docker build` and would recurse `script/test` — those are themselves a `docker build` and would recurse
inside a build step inside a build step
@@ -111,13 +121,14 @@ with your task.
- that of a submodule keeping its own `.git` directory - that of a submodule keeping its own `.git` directory
`git describe` does not need them, and each can hold a credential: a `git describe` does not need them, and each can hold a credential: a
password in a remote URL, or the token the CI checkout step stores there. A password in a remote URL, or the token the CI checkout step stores there.
submodule whose name has a `config` segment (`config`, `deploy/config`, Some submodule names have a `config` segment, as in `config`,
`config/lib`) loses its whole git directory to `**/.git/modules/**/config`, `deploy/config` or `config/lib`. Such a submodule loses its whole git
and Go's version stamping then fails the build. Give it a name without that directory to `**/.git/modules/**/config`, and Go's version stamping then
segment (`git submodule add --name`). The stage that compiles has `git`: the fails the build. Give it a name without that segment
Debian Go image has it, and an alpine one needs `apk add --no-cache git`. It (`git submodule add --name`). The stage that compiles has `git`: the Debian
takes the version from the `VERSION` build argument when one is given, Go image has it, and an alpine one needs `apk add --no-cache git`. That
stage takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives: otherwise from `git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
- the tag, the number of commits since it and the short commit - the tag, the number of commits since it and the short commit
@@ -153,10 +164,39 @@ with your task.
`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: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - [ ] JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`,
(fetch from `.gitattributes`, `.prettierrc`, `.prettierignore` (fetch the last two
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc`
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierignore`) and
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierignore`).
- [ ] Any repository with `yarn.lock`: that file is in yarn 4's format. If
its second line says `yarn lockfile v1`, convert it with the steps
below. Do them in one commit with the re-vendored scripts, because
yarn 1 ignores `--immutable` and exits 0 on a tree with no
`.yarnrc.yml`. Run them with any distribution's node and yarn, and use
`yarnpkg` on Debian and Ubuntu.
1. Read the version from `yarnPath` in
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`.
2. Run `yarn set version <version>`. It writes
`.yarn/releases/yarn-<version>.cjs`, `yarnPath` in `.yarnrc.yml`, and
`packageManager` in `package.json`.
3. Add `nodeLinker: node-modules` and `enableTelemetry: false` to
`.yarnrc.yml`.
4. Run `yarn install` once. It converts `yarn.lock`, and each package
keeps its version and gains a `checksum`.
5. Delete `approvedGitRepositories`, `enableScripts` and
`npmMinimalAgeGate` from `.yarnrc.yml`. The conversion wrote them.
They allow install scripts, any git repository and packages of any
age, which yarn otherwise refuses.
6. In `package.json`, name any package that must run its install script,
as `"dependenciesMeta": { "<name>": { "built": true } }`, and run
`yarn install` again.
7. Add `.yarn/*` and `!.yarn/releases` above the secret patterns in
`.gitignore` and `.dockerignore`. Docker obeys the last matching
line, and the secret patterns must win. Add `.yarn/` to
`.prettierignore`, and `.yarn/releases/** binary` to
`.gitattributes`.
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)
@@ -191,13 +231,20 @@ with your task.
`docker build` in `script/`. Without the bootstrap the CI run dies in `docker build` in `script/`. Without the bootstrap the CI run dies in
`script/fmt-check`, which runs the formatter on the host and finds nothing `script/fmt-check`, which runs the formatter on the host and finds nothing
installed. installed.
- [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test - [ ] `script/bootstrap` installs node, yarn and the node packages only when
as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has `yarn.lock` is in the tree. node and yarn come from the distribution when
the pinned major version, and otherwise yarn under the pinned node, with they are missing. It installs the node packages with
nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node `yarn install --immutable`. The distribution's yarn only starts the
and yarn it installs under nvm off the `PATH` of the shell that called it, release file that `yarnPath` in `.yarnrc.yml` names, and that file does
so a bare `yarn` exits 127 on a runner carrying nothing but docker and the work.
git, or runs under another node. - [ ] A `Dockerfile` stage that needs yarn may start from any distribution that
packages node and yarn. It copies `package.json`, `yarn.lock`,
`.yarnrc.yml` and `.yarn/releases/` before it runs `script/bootstrap`. A
stage that needs no yarn copies none of them.
- [ ] `script/bootstrap`, `script/fmt` and `script/fmt-check` run yarn by
whichever name is present: `yarn`, or `yarnpkg` on Debian and Ubuntu. With
neither name on `PATH`, `script/fmt` and `script/fmt-check` print one line
saying to run `script/bootstrap`, and exit 1.
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its - [ ] `script/bootstrap` installs no linter of its own — delete the block, its
version variables and its call site. A JS repo's `yarn install` stays; it version variables and its call site. A JS repo's `yarn install` stays; it
brings a linter along with every other dependency, and no verdict is taken brings a linter along with every other dependency, and no verdict is taken
@@ -220,13 +267,21 @@ with your task.
- [ ] 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 in Dockerfiles or scripts (tags, `@latest`) — all - [ ] No mutable references (tags, `@latest`) to what the repo downloads and
pinned by cryptographic hash with version/date comment. The one exception installs itself in scripts and Dockerfiles — all pinned by cryptographic
is a package from the distribution's own package index, which needs no hash with version/date comment. Distribution packages are outside this
version and no hash. Pulling that index is also allowed. In a rule. They need no version and no hash, and updating and upgrading them is
`Dockerfile`, the distribution is the pinned base image's. On a host, it fine too. In a `Dockerfile`, the distribution is the pinned base image's.
is the host's. A package source that a `Dockerfile` adds itself does not On a host, it is the host's, and the host's package manager stands for it,
get this exception nix and brew included. A package source that a `Dockerfile` adds itself is
not the distribution's own, so its packages are pinned
- [ ] 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
distribution's version is too old, use a newer distribution. node means
the distribution's newest Node package, such as `nodejs-current` on Alpine
3.24
- [ ] yarn's release file is in git under `.yarn/releases/`, written once by
`yarn set version` with an exact version (repositories with `yarn.lock`)
- [ ] Using `yarn`, not `npm` (JS projects) - [ ] Using `yarn`, not `npm` (JS projects)
# Directory Structure # Directory Structure
@@ -255,11 +310,12 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has.
(the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker, That host is the runner image
git and node 24, with no yarn on `PATH`), and demonstrably executed the `docker.gitea.com/runner-images:ubuntu-latest`, which has docker, git and
checks — a sub-second build, or `CACHED` on a step that lints or tests, node 24. Take any yarn off `PATH` first. The run must show that the checks
one of the `RUN` steps below `ARG CACHEBUST`, means nothing ran. Above executed. A sub-second build means nothing ran. So does `CACHED` on a step
that lints or tests, one of the `RUN` steps below `ARG CACHEBUST`. Above
that line, these steps are expected to show `CACHED`: that line, these steps are expected to show `CACHED`:
- 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
+93 -50
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist title: New Repo Checklist
last_modified: 2026-11-03 last_modified: 2026-11-06
--- ---
Use this checklist when creating a new repository from scratch. Follow the steps Use this checklist when creating a new repository from scratch. Follow the steps
@@ -82,12 +82,13 @@ Template files can be fetched from:
`git describe` does not need them, and each can hold a credential: a `git describe` does not need them, and each can hold a credential: a
password in a remote URL, or the token the CI checkout step stores password in a remote URL, or the token the CI checkout step stores
there. A submodule whose name has a `config` segment (`config`, there. Some submodule names have a `config` segment, as in `config`,
`deploy/config`, `config/lib`) loses its whole git directory to `deploy/config` or `config/lib`. Such a submodule loses its whole git
`**/.git/modules/**/config`, and Go's version stamping then fails the directory to `**/.git/modules/**/config`, and Go's version stamping then
build. Give it a name without that segment (`git submodule add --name`). fails the build. Give it a name without that segment
The stage that compiles has `git`: the Debian Go image has it, and an (`git submodule add --name`). The stage that compiles has `git`: the
alpine one needs `apk add --no-cache git`. It takes the version from the Debian Go image has it, and an alpine one needs
`apk add --no-cache git`. That stage takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
`git describe --tags --always`. That gives: `git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
@@ -122,14 +123,14 @@ Template files can be fetched from:
- the step that runs `script/bootstrap` to install a Python `test` - the step that runs `script/bootstrap` to install a Python `test`
phase's packages from `Pipfile.lock` phase's packages from `Pipfile.lock`
- A stage with none of these steps declares `ARG CACHEBUST` right after - A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that installs anything else as well, such as a `FROM`. A step that also installs anything else comes after
`script/bootstrap` that also installs node packages, comes after it too. `ARG CACHEBUST` too. A `script/bootstrap` that also installs node packages
The `script/` builds pass a new `CACHEBUST` value on every build, which is one. The `script/` builds pass a new `CACHEBUST` value on every build,
reruns every `RUN` step after that line and leaves the layers above it and which reruns every `RUN` step after that line and leaves the layers above
the cache mounts in place; `--no-cache` would build those layers again and it and the cache mounts in place; `--no-cache` would build those layers
empty the mounts. A stage without the `ARG` line is served from cache on again and empty the mounts. A stage without the `ARG` line is served from
an unchanged tree, so the scripts and every stage's `ARG CACHEBUST` line cache on an unchanged tree, so the scripts and every stage's
are added in the same commit. `ARG CACHEBUST` line are added in the same commit.
- Go: each Go stage downloads the modules (`COPY go.mod go.sum ./`, - Go: each Go stage downloads the modules (`COPY go.mod go.sum ./`,
`RUN go mod download`) above `ARG CACHEBUST`; the steps that compile `RUN go mod download`) above `ARG CACHEBUST`; the steps that compile
(`golangci-lint run`, `go test`, `go build`) mount Go's build cache (`golangci-lint run`, `go test`, `go build`) mount Go's build cache
@@ -142,15 +143,26 @@ Template files can be fetched from:
- Python: a `test` phase that installs its packages from `Pipfile.lock` does - Python: a `test` phase that installs its packages from `Pipfile.lock` does
it above `ARG CACHEBUST`, with `COPY script/ script/`, it above `ARG CACHEBUST`, with `COPY script/ script/`,
`COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When `COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When
`script/bootstrap` runs in that step, it installs nothing beyond system `script/bootstrap` runs in that step, it installs nothing beyond these:
packages, pipenv and its dependencies, and the packages from - system packages
`Pipfile.lock`. It installs pipenv and its dependencies by hash, with - pipenv and its dependencies
`pip install --require-hashes`. It installs the packages from - the packages from `Pipfile.lock`
`Pipfile.lock` with `pipenv install --deploy`, plus `--dev` when the tests
need the development packages. It installs no Python there, because the It creates a virtual environment at `$HOME/.pipenv` with
tests use the base image's. The tests run after `ARG CACHEBUST`, and every `python3 -m venv` when it is missing, then uses that environment's `pip`
other stage installs its Python packages after it, as in the Python `test` and `pipenv`. On a host, it first installs the distribution's python and
phase example in `REPO_POLICIES.md` venv packages when they are missing. It installs pipenv and its
dependencies by hash, with `pip install --require-hashes`. It installs
the packages from `Pipfile.lock` with `pipenv install --deploy`, plus
`--dev` when the tests need the development packages. It sets
`PIP_ONLY_BINARY=:all:` for both installs, so a package with no wheel
for the image stops the install. In that step it installs no Python,
because a step above it already did. `Pipfile` names no
`python_version`, because `--deploy` stops when the distribution's
Python differs from it. The tests run after `ARG CACHEBUST`, and every
other stage installs its Python packages after it, as in the Python
`test` phase example in `REPO_POLICIES.md`
- 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
@@ -167,7 +179,22 @@ 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: `yarn init`, `yarn add --dev prettier` - [ ] JS, or any repository that runs prettier through yarn: make the first
`yarn.lock` with the steps below. `script/bootstrap` installs no yarn
until `yarn.lock` exists, so run them with any distribution's node and
yarn, such as Alpine's (`apk add nodejs-current yarn`). Use `yarnpkg`
on Debian and Ubuntu.
1. Read the version from `yarnPath` in
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.yarnrc.yml`.
2. Run `yarn set version <version>`. It writes
`.yarn/releases/yarn-<version>.cjs`, `.yarnrc.yml` and
`package.json`.
3. Add `nodeLinker: node-modules` and `enableTelemetry: false` to
`.yarnrc.yml`.
4. Add `"private": true` to `package.json`.
5. Run `yarn add --dev prettier`. It writes `yarn.lock`.
6. Add `.yarn/releases/** binary` to `.gitattributes`.
7. Run `yarn install --immutable`. It must pass and change nothing.
- [ ] Python: `pyproject.toml` - [ ] Python: `pyproject.toml`
## Configure script/ Entrypoints and Makefile ## Configure script/ Entrypoints and Makefile
@@ -178,14 +205,23 @@ are thin shims calling them. Model scripts:
- [ ] scripts are POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run on - [ ] scripts are POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run on
alpine images without bash alpine images without bash
- [ ] `script/bootstrap` / `make bootstrap` — installs all dependencies, - [ ] `script/bootstrap` / `make bootstrap` installs all dependencies,
idempotently, assuming nothing (pkg manager detection nix/apt/brew/apk; idempotently, and assumes nothing is present:
node used if its major version is the pinned one, else pinned version via - It detects the package manager: nix, apt, brew or apk.
nvm from a hash-verified archive, with yarn and the packages installed - It installs node, yarn and the node packages only when `yarn.lock` is in
under it; pinned yarn via corepack); a non-server repo's development the tree. node and yarn come from that package manager when they are
environment stage runs it instead of inline installs; a gate phase or the missing.
build stage installs what its base image lacks either inline or by running - It installs the node packages with `yarn install --immutable`. The
it distribution's yarn only starts the release file that `yarnPath` in
`.yarnrc.yml` names, and that file does the work.
- A non-server repo's development environment stage runs it, with only
system packages installed inline above it.
- A gate phase or the build stage installs what its base image lacks, either
inline or by running it.
- [ ] A `Dockerfile` stage that needs yarn may start from any distribution that
packages node and yarn. It copies `package.json`, `yarn.lock`,
`.yarnrc.yml` and `.yarn/releases/` before it runs `script/bootstrap`. A
stage that needs no yarn copies none of them.
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`, - [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
then `install-precommit`, plus repo-specific init then `install-precommit`, plus repo-specific init
- [ ] `script/test` / `make test` — - [ ] `script/test` / `make test` —
@@ -215,13 +251,10 @@ are thin shims calling them. Model scripts:
from `script/projectname`, each assigned on its own line before the build. from `script/projectname`, each assigned on its own line before the build.
The bootstrap is required: CI checks out and runs this alone, and The bootstrap is required: CI checks out and runs this alone, and
`script/fmt-check` runs the formatter on the host. `script/fmt-check` runs the formatter on the host.
- [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test - [ ] `script/bootstrap`, `script/fmt` and `script/fmt-check` run yarn by
as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has whichever name is present: `yarn`, or `yarnpkg` on Debian and Ubuntu. With
the pinned major version, and otherwise yarn under the pinned node, with neither name on `PATH`, `script/fmt` and `script/fmt-check` print one line
nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node saying to run `script/bootstrap`, and exit 1.
and yarn it installs under nvm off the `PATH` of the shell that called it,
so a bare `yarn` exits 127 on a runner carrying nothing but docker and
git, or runs under another node.
- [ ] No `docker build` in `script/` leaves a dangling image behind: - [ ] No `docker build` in `script/` leaves a dangling image behind:
`script/lint` and `script/test` write no image, and `script/docker` and `script/lint` and `script/test` write no image, and `script/docker` and
`script/cibuild` tag theirs `script/cibuild` tag theirs
@@ -236,11 +269,12 @@ are thin shims calling them. Model scripts:
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `make docker` succeeds - [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has.
(the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker, That host is the runner image
git and node 24, with no yarn on `PATH`), and demonstrably executed the `docker.gitea.com/runner-images:ubuntu-latest`, which has docker, git and
checks — a sub-second build, or `CACHED` on a step that lints or tests, node 24. Take any yarn off `PATH` first. The run must show that the checks
one of the `RUN` steps below `ARG CACHEBUST`, means nothing ran. Above executed. A sub-second build means nothing ran. So does `CACHED` on a step
that lints or tests, one of the `RUN` steps below `ARG CACHEBUST`. Above
that line, these steps are expected to show `CACHED`: that line, these steps are expected to show `CACHED`:
- 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
@@ -251,11 +285,20 @@ 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 image/package references. The one exception is a package from - [ ] No mutable references to what the repo downloads and installs itself in
the distribution's own package index, which needs no version and no hash. scripts and Dockerfiles. Distribution packages are outside this rule. They
Pulling that index is also allowed. In a `Dockerfile`, the distribution is need no version and no hash, and updating and upgrading them is fine too.
the pinned base image's. On a host, it is the host's. A package source In a `Dockerfile`, the distribution is the pinned base image's. On a host,
that a `Dockerfile` adds itself does not get this exception it is the host's, and the host's package manager stands for it, nix and
brew included. A package source that a `Dockerfile` adds itself is not the
distribution's own, so its packages are pinned
- [ ] 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
distribution's version is too old, use a newer distribution. node means
the distribution's newest Node package, such as `nodejs-current` on Alpine
3.24
- [ ] yarn's release file is in git under `.yarn/releases/`, written once by
`yarn set version` with an exact version (repositories with `yarn.lock`)
- [ ] 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
+325 -192
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-11-03 last_modified: 2026-11-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -16,51 +16,73 @@ 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 its YAML front matter. The date is how a copy `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
is kept in sync 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 everything fetched from single most important rule in this document. It covers what we download and
a remote source, including: install ourselves in scripts and Dockerfiles:
- Docker base images - Docker base images
- Go modules - Go modules
- Python packages from PyPI
- 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
dependency by its content hash: dependency by its content hash:
- Docker: `@sha256:...` - Docker: `@sha256:...`
- Go: the module hash in `go.sum` - Go: the module hash in `go.sum`
- npm: the integrity hash in the lockfile - Python: the package hash in `Pipfile.lock`
- npm: the `checksum` in `yarn.lock`
- GitHub Actions: `@<commit-sha>` - GitHub Actions: `@<commit-sha>`
This also means never `curl | bash` to install tools like pyenv, nvm, This also means never `curl | bash` to install a tool such as rustup.
rustup, etc. Instead, download a specific release archive from GitHub, Instead, download a specific release archive from GitHub, verify its hash,
verify its hash, and only then install. The hash is hardcoded in the and only then install. The hash is hardcoded in the Dockerfile or script.
Dockerfile or script. Unverified install scripts are arbitrary remote code Unverified install scripts are arbitrary remote code execution. Double-check
execution. Double-check every external reference in every file before every external reference in every file before committing.
committing.
There is one exception: a package from the distribution's own package index These come from the distribution wherever possible:
needs no version and no hash. Pulling that index is also allowed. The - node, meaning the distribution's newest Node package, such as
distribution is trusted. In a `Dockerfile`, it is the pinned base image's `nodejs-current` on Alpine 3.24
distribution. On a host, it is the host's. These commands are examples: - yarn
- `apt-get update` - npm
- `apt-get install` - python
- `apk update` - pip and venv, the installer tools bundled with python
- `apk add`
If the distribution's version is too old, use a newer distribution, never a
download. That rules out all of these:
- nvm
- pyenv
- corepack
- `npm install -g yarn`
yarn's release file is third-party code, and git pins it. `yarn set version`
writes it to `.yarn/releases/` once, given an exact version.
These are not trusted, so everything from them stays pinned by hash: These are not trusted, so everything from them stays pinned by hash:
- Docker Hub - Docker Hub
- PyPI - PyPI
- npm - 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`
@@ -88,10 +110,10 @@ style conventions are in separate documents:
- `script/check` - `script/check`
- `script/docker` - `script/docker`
The scripts must be POSIX sh: `#!/bin/sh`, `set -eu`, no bashisms. That is The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they
so they run in minimal containers; alpine images, for example, have no bash. run in minimal containers; alpine images, for example, have no bash. Locate
Locate the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd` the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd` there
there before acting. before acting.
From the standard's canonical set we use `bootstrap`, `setup`, `test`, and From the standard's canonical set we use `bootstrap`, `setup`, `test`, and
`cibuild`. `setup` makes the repo ready for development after a fresh clone: `cibuild`. `setup` makes the repo ready for development after a fresh clone:
@@ -99,35 +121,66 @@ style conventions are in separate documents:
initialization. initialization.
`script/bootstrap` installs all dependencies idempotently and assumes `script/bootstrap` installs all dependencies idempotently and assumes
nothing is present. Base tools come from nix, apt, brew, or apk, detected in nothing is present. Base tools, node and yarn come from nix, apt, brew, or
that order; apt runs noninteractive. For node it uses the installed node apk, detected in that order; apt runs noninteractive. Each one is installed
only when its major version is the pinned one. When node is missing or has only when it is missing. A tool from outside the distribution is never
another major, it installs the PINNED node version via nvm, and installs installed as "latest" or "lts"; it is always an exact version.
yarn and the packages under it. The node 24 the CI runner image ships is one
such other major. nvm itself is installed first if missing, from a
hash-verified GitHub release archive, never `curl | sh`. bash is installed
as an explicit prerequisite, since nvm requires bash. Only the major version
is compared, for two reasons. The `Dockerfile` stages start from a node
image whose exact version is not the pin, and nvm cannot install a prebuilt
node on alpine. yarn is pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions.
`script/cibuild` runs the CI build, and the Gitea workflow calls it. It `script/bootstrap` installs node, yarn and the node packages only when
changes to the repo root, runs `script/bootstrap`, runs `script/check`, and `yarn.lock` is in the tree. With no `yarn.lock`, it installs no node and no
builds the image with the version. **`script/cibuild` runs yarn. It installs the node packages with `yarn install --immutable`. That
`script/bootstrap` first.** The workflow checks out the repo and runs install fails when a package's `checksum` is wrong, or when `package.json`
nothing else, while `script/fmt-check` runs the formatter on the host. is out of step with `yarn.lock`.
Without the bootstrap, on a pristine checkout with nothing installed, the
run dies there, after the containerised gates have passed. **The bootstrap Each repository commits the yarn that runs. `yarnPath` in `.yarnrc.yml`
alone is not enough.** When `script/bootstrap` installs node and yarn under points at the release file in `.yarn/releases/`. The distribution's yarn
nvm, it leaves neither on the `PATH` of the shell that called it. A bare only starts that file, and that file does the work. Alpine packages yarn 1,
`yarn` then either exits 127 or runs under another node. The host and Debian 13 packages yarn 4.1. The same committed yarn does the work on
entrypoints that need yarn, `script/fmt` and `script/fmt-check`, therefore both. Corepack never runs, and no yarn is downloaded.
use the same test as `script/bootstrap`. When the node on `PATH` has the
pinned major version, they run the `yarn` on `PATH`. Otherwise they load nvm `.yarnrc.yml` holds these settings:
through `$HOME/.nvm/nvm.sh` and run yarn under the pinned node. A runner - `yarnPath`
carrying nothing but docker and git then gets through `script/check`. - `nodeLinker: node-modules`
- `enableTelemetry: false`, because yarn otherwise sends usage reports from
any host that is not CI
By default, yarn runs no dependency's install script. A dependency that must
run one is named in `package.json`, as
`"dependenciesMeta": { "<name>": { "built": true } }`.
`.gitattributes` carries `.yarn/releases/** binary`, so diffs and merges
treat the release file as one opaque file.
yarn 1 ignores `--immutable` and exits 0, so on a tree with no `.yarnrc.yml`
the install is not frozen. A repository therefore re-vendors
`script/bootstrap` and converts its `yarn.lock` in one commit.
Debian and Ubuntu name the yarn command `yarnpkg`. `script/bootstrap`,
`script/fmt` and `script/fmt-check` each carry the same small function that
runs whichever name is present. With neither name on `PATH`, `script/fmt`
and `script/fmt-check` print one line saying to run `script/bootstrap`, and
exit 1.
A `Dockerfile` stage that needs yarn may start from any distribution that
packages node and yarn. It copies all of these before it runs
`script/bootstrap`:
- `package.json`
- `yarn.lock`
- `.yarnrc.yml`
- `.yarn/releases/`
With only `package.json` and `yarn.lock`, the install fails. Alpine's yarn 1
then says to enable corepack; never do that, and copy the missing files
instead. The Python and Go stages below need no yarn and copy no
`yarn.lock`, so `script/bootstrap` installs no node and no yarn there.
`script/cibuild` runs the CI build, and the Gitea workflow calls it. The
script changes to the repo root, runs `script/bootstrap`, runs
`script/check`, and builds the image with the version. **`script/cibuild`
runs `script/bootstrap` first**, because the workflow checks out the repo
and runs nothing else, while `script/fmt-check` runs the formatter on the
host. On a pristine checkout with nothing installed, the run dies there,
after the containerised gates have passed.
Four further scripts are our own extensions to the standard: Four further scripts are our own extensions to the standard:
- `script/check` runs `script/test`, `script/lint` and `script/fmt-check`. - `script/check` runs `script/test`, `script/lint` and `script/fmt-check`.
@@ -147,9 +200,16 @@ 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).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Run the repo's own entrypoints, never the underlying tools directly. The
instead of invoking the underlying tools directly. The Makefile is the single `script/` scripts are the single source of truth for how each operation runs.
source of truth for how these operations are run. Calling them directly is fine, and so is calling the Makefile targets. The
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:
@@ -170,20 +230,22 @@ style conventions are in separate documents:
cannot be built unless they pass. For non-server repos the final stage brings cannot be built unless they pass. For non-server repos the final stage brings
up a development environment; for server repos it is the runtime image. The up a development environment; for server repos it is the runtime image. The
gate phases and the build stage start from their pinned base images and gate phases and the build stage start from their pinned base images and
install what those images lack. They do that in one of two ways: install what those images lack in either of these ways:
- inline, as the canonical Go `Dockerfile` below does for `git` - inline, as the canonical Go `Dockerfile` below does for `git`
- by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile` - by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile`
does for its yarn packages does for its yarn packages
The development environment stage installs development prerequisites by The development environment stage installs development prerequisites by
running `script/bootstrap` rather than duplicating its installs inline. A running `script/bootstrap`. Only system packages may be installed inline
stage that runs `script/bootstrap` COPYs `script/` and the dependency above it, so that check builds keep them; the `prompts` repo's own
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before `Dockerfile` does this. A stage that runs `script/bootstrap` COPYs `script/`
running it. and the dependency manifests it needs before running it. These are examples:
- `package.json`, `yarn.lock`, `.yarnrc.yml` and `.yarn/releases/`
- `go.mod` and `go.sum`
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **The lint and test gates run in Docker, as phases of the `Dockerfile`.**
no separate lint file. `script/lint` and `script/test` each build one phase There is no separate lint file. `script/lint` and `script/test` each build one
and nothing else: phase and nothing else:
```sh ```sh
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --build-arg CACHEBUST="$(date +%s)-$$" \
@@ -194,9 +256,9 @@ style conventions are in separate documents:
**A stage that is not the last one in the file is built only when the final **A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here. It is also why the final stage two gates are always invoked by name here, and why the final stage carries a
carries a `COPY --from=` of a harmless file from each of them. Without that `COPY --from=` of a harmless file from each of them. Without that
edge, a plain `docker build .` builds the last stage alone and exits 0 dependency, a plain `docker build .` builds the last stage alone and exits 0
having linted and tested nothing. having linted and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase **The gate builds write no image.** With `--output type=cacheonly` the phase
@@ -205,12 +267,12 @@ style conventions are in separate documents:
image holds the toolchain. A build given neither `--output` nor `-t` writes image holds the toolchain. A build given neither `--output` nor `-t` writes
an untagged image and leaves it dangling, on every developer host and every an untagged image and leaves it dangling, on every developer host and every
CI runner. `script/cibuild` and `script/docker` build the image that ships CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image. Each assigns the tag and tag it, so each build replaces the previous image. Each script assigns
on its own line before the build, so `set -e` stops it where the tag 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`. It is never invoked through `make lint` or `eslint`, `prettier`. The tool is never invoked through `make lint` or
`script/test`. Those are themselves a `docker build`, and would recurse into `script/test`. Those are themselves a `docker build`, and would recurse into
a daemon that does not exist in a build step. Formatting is the exception a daemon that does not exist in a build step. Formatting is the exception
and stays on the host: `script/fmt` writes the working tree, and and stays on the host: `script/fmt` writes the working tree, and
@@ -224,9 +286,11 @@ style conventions are in separate documents:
caller cannot tell from real findings. Both have produced wrong verdicts in caller cannot tell from real findings. Both have produced wrong verdicts in
this org, in both directions. A container has its own `TMPDIR`, so the lock this org, in both directions. A container has its own `TMPDIR`, so the lock
is out of reach. The lint phase does keep golangci-lint's cache between is out of reach. The lint phase does keep golangci-lint's cache between
check builds (below). But it lints every checkout at `/src`, and a finding check builds (below), but it lints every checkout at `/src`. A finding is
is taken from that cache only for the same files, settings and golangci-lint taken from that cache only when all of these are the same:
version. - the files
- the settings
- the golangci-lint version
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker - **Any build that runs checks is given a new `CACHEBUST` value.** Docker
invalidates a `COPY` layer only when the copied content changes. On an invalidates a `COPY` layer only when the copied content changes. On an
@@ -234,8 +298,7 @@ style conventions are in separate documents:
executes, and the build still exits 0. Every `docker build` in `script/` executes, and the build still exits 0. Every `docker build` in `script/`
therefore passes `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the therefore passes `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the
script's process id. So no two builds share a value, not even the three in one script's process id. So no two builds share a value, not even the three in one
`script/cibuild`. The scripts that run `docker build` are these four, and `script/cibuild`. Four scripts do this, and there is no fifth:
there is no fifth:
- `script/lint` - `script/lint`
- `script/test` - `script/test`
- `script/cibuild` - `script/cibuild`
@@ -251,13 +314,17 @@ style conventions are in separate documents:
packages from `Pipfile.lock` packages from `Pipfile.lock`
A stage with none of these steps declares `ARG CACHEBUST` right after A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that installs anything else as well, such as a `FROM`. A step that also installs anything else comes after `ARG CACHEBUST`
`script/bootstrap` that also installs node packages, comes after it too. A too. A `script/bootstrap` that also installs node packages is one. A new
new value reruns every `RUN` step after that line; `COPY` steps are compared value reruns every `RUN` step after that line; `COPY` steps are compared by
by content and need nothing. Docker keeps the layers above the line until content and need nothing. Docker keeps the layers above the line until one
the base image, their own lines or the files they copy change. A stage of these changes:
without the `ARG` line is served from cache on an unchanged tree, so a - the base image
repository re-vendors the scripts and adds the `ARG` lines in one commit. - their own lines
- the files they copy
A stage without the `ARG` line is served from cache on an unchanged tree, so
a repository re-vendors the scripts and adds the `ARG` lines in one commit.
The scripts do not pass `--no-cache`. That flag reruns every step too, but The scripts do not pass `--no-cache`. That flag reruns every step too, but
it also builds the layers above the line again and empties every cache it also builds the layers above the line again and empties every cache
mount. Go's build cache and golangci-lint's would then start empty every mount. Go's build cache and golangci-lint's would then start empty every
@@ -266,14 +333,19 @@ style conventions are in separate documents:
ran: a sub-second build reporting success is a cache hit, not a result. ran: a sub-second build reporting success is a cache hit, not a result.
Never invalidate by pruning: `docker builder prune` and friends destroy a Never invalidate by pruning: `docker builder prune` and friends destroy a
build cache shared with every other build on the host. When a check is added build cache shared with every other build on the host. When a check is added
or changed, prove it works: plant a defect it must catch, watch the run fail or changed, prove it works:
on it, then revert the defect. A green run alone shows neither that the - plant a defect it must catch
check ran nor that it covers what it should. - watch the run fail on it
- revert the defect
A green run alone shows neither that the check ran nor that it covers what
it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image, pinned by hash, The lint phase is based on the `golangci/golangci-lint` image (pinned by
so lint failures surface in seconds rather than after a full compile. The test hash), so lint failures surface in seconds rather than after a full compile.
phase is based on the Debian Go image. The canonical Go repo `Dockerfile`: The test phase is based on the Debian Go image. The canonical Go repo
`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
@@ -367,22 +439,22 @@ style conventions are in separate documents:
- The lint phase uses the `golangci/golangci-lint` image directly, so - The lint phase uses the `golangci/golangci-lint` image directly, so
nothing needs installing: it has both Go and the linter. nothing needs installing: it has both Go and the linter.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only - `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default, purpose is the dependency on that phase. BuildKit runs stages in parallel
and a stage nothing depends on is not built at all. Without these two by default, and a stage nothing depends on is not built at all. Without
lines a red gate would not fail the build. these two lines a red gate would not fail the build.
- Keep the runtime stage last. If you add a stage after it, give it the same - Keep the runtime stage last. If you add a stage after it, give it the same
two copies: a plain `docker build .` builds the last stage's chain and two copies. A plain `docker build .` builds the last stage's chain and
nothing else. nothing else.
- Check builds keep these, and none of them can hand back a stale result: - Check builds keep these, and none of them can hand back a stale result:
- The layers above `ARG CACHEBUST`: the system packages and Go's - The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the modules. Docker installs the packages again when the base image or the
install line changes. It downloads the modules again when `go.mod` or install line changes. Docker downloads the modules again when `go.mod`
`go.sum` changes. Go checks every module against `go.sum` as it or `go.sum` changes. Go checks every module against `go.sum` as it
downloads it, and no later build can change a kept layer. The modules downloads it, and no later build can change a kept layer. The modules
are kept in a layer rather than a cache mount, for two reasons. A are kept in a layer rather than a cache mount. A cache mount is shared
cache mount is shared by every build on the host. And Go builds from by every build on the host, and Go builds from whatever files the
whatever files it holds without checking them again (only mount holds without checking them again (only `go mod verify` does). A
`go mod verify` does), so a file changed there would go unnoticed. 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
@@ -431,12 +503,13 @@ style conventions are in separate documents:
`git describe` does not need them, and each can hold a credential: a `git describe` does not need them, and each can hold a credential: a
password in a remote URL, or the token the CI checkout step stores password in a remote URL, or the token the CI checkout step stores
there. A submodule whose name has a `config` segment (`config`, there. Some submodule names have a `config` segment, as in `config`,
`deploy/config`, `config/lib`) loses its whole git directory to `deploy/config` or `config/lib`. Such a submodule loses its whole git
`**/.git/modules/**/config`, and Go's version stamping then fails the directory to `**/.git/modules/**/config`, and Go's version stamping then
build. Give it a name without that segment (`git submodule add --name`). fails the build. Give it a name without that segment
The stage that compiles has `git`: the Debian Go image has it, and an (`git submodule add --name`). The stage that compiles has `git`: the
alpine one needs `apk add --no-cache git`. It takes the version from the Debian Go image has it, and an alpine one needs
`apk add --no-cache git`. That stage takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
`git describe --tags --always`. That gives: `git describe --tags --always`. That gives:
- the tag, on a tagged commit - the tag, on a tagged commit
@@ -476,48 +549,91 @@ style conventions are in separate documents:
The tests run after `ARG CACHEBUST`, on every check build. In every other The tests run after `ARG CACHEBUST`, on every check build. In every other
stage, a step that installs Python packages comes after `ARG CACHEBUST`. stage, a step that installs Python packages comes after `ARG CACHEBUST`.
- When `script/bootstrap` runs in that step, it installs nothing beyond - When `script/bootstrap` runs in that step, it installs nothing beyond
system packages, pipenv and its dependencies, and the packages from these:
`Pipfile.lock`. It installs no Python there: the tests use the base - system packages
image's Python, which the image's hash pins. If it installs anything else - pipenv and its dependencies
there, the step goes after `ARG CACHEBUST`. - the packages from `Pipfile.lock`
`script/bootstrap` installs no Python there: the tests use the
distribution's Python, which a step above it installs. If it installs
anything else there, the step goes after `ARG CACHEBUST`.
- Debian's Python refuses a system-wide `pip install`, so pipenv goes in a
virtual environment at `$HOME/.pipenv`. `script/bootstrap` creates it with
`python3 -m venv` when it is missing, then uses that environment's `pip`
and `pipenv`. On a host, it first installs the distribution's python and
venv packages when they are missing. One script then works on a host and
in a `Dockerfile`. `Pipfile` names no `python_version`, because
`pipenv install --deploy` stops when the distribution's Python differs
from it.
- PyPI is not trusted, so `script/bootstrap` installs pipenv and its - PyPI is not trusted, so `script/bootstrap` installs pipenv and its
dependencies by hash. It does so with `pip install --require-hashes -r` dependencies by hash. It does so with `pip install --require-hashes -r`
and a requirements file in `script/` that lists each of them with `==` and and a requirements file in `script/` that lists each of them with `==` and
its `--hash`. pip refuses to install a dependency the file does not list. its `--hash`. pip refuses to install a dependency the file does not list.
- It then runs `pipenv install --deploy`, with `--dev` when the tests need - `script/bootstrap` then runs `pipenv install --deploy`, with `--dev` when
the development packages. `--deploy` installs only what `Pipfile.lock` the tests need the development packages. `--deploy` does all of these:
names, checks every package against the lockfile's hashes, and fails when - installs only what `Pipfile.lock` names
the lockfile is out of date with `Pipfile`. - checks every package against the lockfile's hashes
- A kept layer holds nothing a new install would refuse: pip checked pipenv - fails when the lockfile is out of date with `Pipfile`
and its dependencies, and pipenv checked the lockfile's packages, against - `script/bootstrap` sets `PIP_ONLY_BINARY=:all:` for both installs, and
their hashes. pipenv creates its virtual environment with the `virtualenv` pipenv passes it on to pip. A package with no ready-built wheel for the
package. The packages that `virtualenv` adds come from wheels inside that image then stops the install. Without the setting, pip builds that package
package, so the hash pip checked for it covers them. Two things in the from source and downloads the build tools without a hash check.
layer are not fixed by a hash: - A kept layer holds nothing a new install would refuse: pip checked the
- A package with no ready-built wheel for the image is built from hashes of pipenv and its dependencies, and pipenv checked the hashes of
source. pip downloads the build tools without a hash check, so a kept the lockfile's packages. pipenv creates its virtual environment with the
layer's build of such a package may differ from a new one. `virtualenv` package. The packages that `virtualenv` adds come from wheels
- System packages keep the versions the package manager gave when the inside that package, so the hash pip checked for it covers them. System
layer was built; a new install may get newer ones. packages, Python among them, are not fixed by a hash. They 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.
In this example, `script/bootstrap` finds `make` and `git` already A Python repository's `script/bootstrap` adds these lines in `main`, above
installed, installs pipenv and its dependencies by hash, and runs the last `echo`. The `ensurepip` test fails when python is missing, and also
`pipenv install --dev --deploy`: when Debian's venv package is missing:
```sh
if [ ! -x "$HOME/.pipenv/bin/pip" ]; then
if ! python3 -m ensurepip --version >/dev/null 2>&1; then
pkg_install python3 python3-venv python3 python3
fi
python3 -m venv "$HOME/.pipenv"
fi
export PIP_ONLY_BINARY=:all:
"$HOME/.pipenv/bin/pip" install --require-hashes -r script/requirements.txt
"$HOME/.pipenv/bin/pipenv" install --dev --deploy
```
Make `script/requirements.txt` once, with
`echo pipenv | pip-compile --generate-hashes --allow-unsafe -o script/requirements.txt -`.
`pip-compile` comes from Alpine's `py3-pip-tools` package; Debian and Ubuntu
do not package `pip-tools`. Make the first `Pipfile.lock` with
`$HOME/.pipenv/bin/pipenv lock`, after the `pip install` line has installed
pipenv.
In the example below, `script/bootstrap` finds `make`, `git`, python and
venv already installed. The example copies no `yarn.lock`, so
`script/bootstrap` installs no node and no yarn.
```dockerfile ```dockerfile
# Test phase. Both pytest runs share one 90-second timeout, so a hang # Test phase. Both pytest runs share one 90-second timeout, so a hang
# uses it once and gets no rerun. # uses it once and gets no rerun.
# python:3.x-slim, YYYY-MM-DD # debian:trixie-slim, YYYY-MM-DD
FROM python@sha256:... AS test FROM debian@sha256:... AS test
# make and git are base tools that script/bootstrap installs when they # Python, pip and venv come from the distribution, which is trusted, so
# are missing. Installed here, they stay in their own layer when # they need no version and no hash. make and git are base tools that
# script/ changes. # script/bootstrap installs when they are missing. Installed here, they
# stay in their own layer when script/ changes.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends make git \ && apt-get install -y --no-install-recommends ca-certificates make git \
python3 python3-venv \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# script/bootstrap installs pipenv in a virtual environment at
# $HOME/.pipenv, and the tests below run that pipenv.
ENV PATH=/root/.pipenv/bin:$PATH
WORKDIR /app WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY Pipfile Pipfile.lock ./ COPY Pipfile Pipfile.lock ./
@@ -530,12 +646,12 @@ style conventions are in separate documents:
``` ```
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push. Its only other step checks out the repo, with runs `script/cibuild` on push. The workflow's only other step checks out the
`persist-credentials: false`. `script/cibuild` needs no token, and without repo, with `persist-credentials: false`. `script/cibuild` needs no token, and
that setting the checkout leaves the job's token in `.git/config` for every without that setting the checkout leaves the job's token in `.git/config` for
later step. The checkout step also sets `fetch-depth: 0`, which fetches the every later step. The checkout step also sets `fetch-depth: 0`, which fetches
tags `git describe` needs. By default it clones shallow with no tags, and a the tags `git describe` needs. By default it clones shallow with no tags, and
tagged repository's CI build would stamp a bare short commit id. The a tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`. (`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`.
A new push then cancels the older run on the same branch, queued or running, A new push then cancels the older run on the same branch, queued or running,
@@ -549,7 +665,7 @@ style conventions are in separate documents:
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
shared runner after 20 minutes. That allows for the three Docker builds shared runner after 20 minutes. That allows for the three Docker builds
described above, each held to the 5-minute Docker build limit below, plus the described above, each held to the 5-minute Docker build limit below, plus the
bootstrap. The three are the test phase, the lint phase, then the image. A bootstrap. Those builds are the test phase, the lint phase, then the image. A
separate workflow limited to `main` by a `branches` list under `on: push` separate workflow limited to `main` by a `branches` list under `on: push`
cannot be checked by review. To try a change to it, add the feature branch to cannot be checked by review. To try a change to it, add the feature branch to
that list and push, then remove the branch from the list again before merging. that list and push, then remove the branch from the list again before merging.
@@ -643,25 +759,25 @@ style conventions are in separate documents:
passing result in its cache directory (`GOCACHE`). When the same tests run passing result in its cache directory (`GOCACHE`). When the same tests run
again on unchanged code, it prints that result, marked `(cached)`, without again on unchanged code, it prints that result, marked `(cached)`, without
running them. The directory lasts from one run to the next on a developer's running them. The directory lasts from one run to the next on a developer's
machine, where this target runs. It also lasts in the `test` phase of the machine, where this target runs. The directory also lasts in the `test`
`Dockerfile`, which mounts it to keep compiled packages between check phase of the `Dockerfile`, which mounts it to keep compiled packages between
builds, so the phase's `go test` runs carry `-count=1` too. A new check builds, so the phase's `go test` runs carry `-count=1` too. A new
`CACHEBUST` value (above) is what makes that step run on an unchanged tree. `CACHEBUST` value (above) is what makes that step run on an unchanged tree.
Python example: Python example:
```makefile ```makefile
test: test:
@timeout 90 sh -c 'python -m pytest || { \ @timeout 90 sh -c 'pipenv run pytest || { \
echo "--- Rerunning with -v for details ---"; \ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }' pipenv run pytest -v; exit 1; }'
``` ```
Both pytest runs share that one 90-second timeout, as in the `Dockerfile` Both pytest runs share that one 90-second timeout, as in the `Dockerfile`
example above, so a hang uses it once and gets no rerun. Ctrl-C does not example above, so a hang uses it once and gets no rerun. Ctrl-C does not
stop a running `make test`, which goes on until the tests end or the 90 stop a running `make test`, which goes on until the tests end or the 90
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. On a host, the target needs `$HOME/.pipenv/bin` on `PATH`.
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
@@ -685,6 +801,7 @@ style conventions are in separate documents:
- editor files (`.swp`, `*~`) - editor files (`.swp`, `*~`)
- in-repo agent scratch directories (`.claude/`) - in-repo agent scratch directories (`.claude/`)
- `node_modules/` - `node_modules/`
- `.yarn/*`, with `!.yarn/releases` so that git keeps yarn's release file
- the repo's own build outputs - the repo's own build outputs
Fetch the standard `.gitignore` from Fetch the standard `.gitignore` from
@@ -703,19 +820,19 @@ style conventions are in separate documents:
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root. `config/.env` and therefore excludes only the copies at the repository root. `config/.env` and
`certs/server.key` still reach the context and can land in an image layer. `certs/server.key` still reach the context and can land in an image layer.
That is more dangerous than a short file with no secret patterns at all, Such a `.dockerignore` is more dangerous than a short file with no secret
because it reads as solved and stops anyone looking. Give every patterns at all, because it reads as solved and stops anyone looking. Give
depth-independent pattern the `**/` prefix, and leave only genuinely every depth-independent pattern the `**/` prefix, and leave only genuinely
root-anchored entries unprefixed. Those are `.claude`, and the repo's own root-anchored entries unprefixed. Those are `.claude`, `.yarn/*`,
host-built binary. The binary is written `/myapp` and never `**/myapp`, which `!.yarn/releases`, and the repo's own host-built binary. The binary is written
would also match `cmd/myapp/` and delete the package directory from the `/myapp` and never `**/myapp`, which would also match `cmd/myapp/` and delete
context. Matching is case-sensitive, and an ALL-CAPS twin per pattern still the package directory from the context. Matching is case-sensitive, and an
misses `Server.Key`. So secret names use character ranges: all-caps copy of each pattern still misses `Server.Key`. So secret names use
`**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for `.envrc` and the character ranges: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for
extensionless SSH keys. Where such a pattern also catches something the build `.envrc` and the extensionless SSH keys. Where such a pattern also catches
needs, re-include it with a negation (`!docs/example.env`). Deleting the something the build needs, re-include it with a negation
pattern reopens the exposure for every other file it covers. Fetch the (`!docs/example.env`). Deleting the pattern reopens the exposure for every
standard `.dockerignore` from 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.
@@ -726,11 +843,12 @@ style conventions are in separate documents:
be copied into an image layer. In `.gitignore` the entry is `.claude/`, be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap, which the canonical that name from the build. Anchoring carries a known gap. The canonical
`.dockerignore` states in its own comment, since consuming repos receive the `.dockerignore` states the gap in its own comment, since consuming repos
file and not the tracker. The directory is created in the agent's working receive the file and not the tracker. The gap is that the directory is created
directory, so a repo running agents in subdirectories still ships in the agent's working directory. A repo running agents in subdirectories
`services/api/.claude/` and must add its own anchored entry there. therefore still ships `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.** The version is derived from the `.git` `git describe --tags --always` gives.** The version is derived from the `.git`
@@ -754,10 +872,10 @@ style conventions are in separate documents:
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
than failing. The `[ -n "$version" ]` line is the single place the fallback than failing. The `[ -n "$version" ]` line is the single place the fallback
is applied. It is a live check that fires on a build from an export with no is applied. That line is a live check that fires on a build from an export
`.git`, and on a repository with no commits yet. Do not fold it into the with no `.git`, and on a repository with no commits yet. Do not fold it into
substitution as `|| echo unknown`, which makes the guard unreachable. The the substitution as `|| echo unknown`, which makes the guard unreachable.
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared The Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped. Passing `VERSION` to a repo whose there because `ARG` is stage-scoped. Passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why 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
@@ -772,11 +890,15 @@ style conventions are in separate documents:
transfers only the delta from the previous build. transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data must never be - **No build artifacts in version control.** Code-derived data must never be
committed to the repository if it can be avoided: compiled bundles, minified committed to the repository if it can be avoided. Code-derived data includes:
output, generated assets. The build process (e.g. Dockerfile, Makefile) should - compiled bundles
generate these at build time. Notable exception: Go protobuf generated files - minified output
(`.pb.go`) ARE committed. Repos need to work with `go get`, which downloads - generated assets
code but does not execute code generation.
The build process (e.g. Dockerfile, Makefile) should generate these at build
time. Notable exception: Go protobuf generated files (`.pb.go`) ARE
committed because repos need to work with `go get`, which downloads code but
does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
@@ -796,8 +918,8 @@ style conventions are in separate documents:
`test-support` depguard rule. There a repo names its own test-support packages `test-support` depguard rule. There a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.15.0 (released 2026-10-16). It is pinned as the digest of the lint phase's v2.15.0 (released 2026-10-16). The version is pinned as the digest of the lint
base image, phase's 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
@@ -817,22 +939,26 @@ style conventions are in separate documents:
disagree about what the tool even is. The canonical form: disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version - compares the installed version against the pin over the **whole** version
token. A parser that stops at the first `-` reports `2.12.2` for a host token. A parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install; running `2.12.2-rc1` and skips the install.
- treats absent, non-zero, empty or unrecognised `--version` output as a - treats `--version` output as a mismatch when it is any of these:
mismatch, so the failure direction is a redundant install and never a - absent
skipped one; - non-zero
- empty
- unrecognised
So a failure causes a redundant install, never a skipped one.
- after installing, re-resolves the binary the way callers do, with - after installing, re-resolves the binary the way callers do, with
`hash -r` and then through `PATH`, not through the directory the installer `hash -r` and then through `PATH`, not through the directory the installer
wrote to. It fails naming the resolved path, since an install that a wrote to. A shadowing binary can hide an install, which then succeeds
shadowing binary hides succeeds while changing nothing any caller sees; while no caller sees any change. In that case it fails and names the
resolved path.
- 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 A tool from the distribution has no pin, so `script/bootstrap` installs it
`script/bootstrap` compares only its major version, for two reasons. The when it is missing. make, git, node and yarn are such tools.
node 22 alpine image the `Dockerfile` stages start from is not the exact
pin, and nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
@@ -900,9 +1026,15 @@ style conventions are in separate documents:
an explicit allowlist of known origins. Wildcard (`*`) is acceptable an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs. only for public, unauthenticated read-only APIs.
- **Error handling:** - **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths, - Internal errors must never leak implementation details to the client.
or other implementation details to the client. Return generic error Those details include:
messages in production; detailed errors only when `DEBUG` is enabled. - stack traces
- SQL queries
- file paths
Return generic error messages in production; detailed errors only
when `DEBUG` is enabled.
- **TLS:** - **TLS:**
- Services never terminate TLS directly. They are always deployed behind - Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain a TLS-terminating reverse proxy. The service itself listens on plain
@@ -1004,7 +1136,8 @@ style conventions are in separate documents:
- `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`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`,
`.gitattributes`, `.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
+23 -96
View File
@@ -1,24 +1,17 @@
#!/bin/sh #!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop # script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tools, node and yarn come from nix,
# or apk (detected in that order); assumes nothing is present. The # apt, brew, or apk (detected in that order), with no version and no
# installed node is used only when its major version is the pinned one; # hash, because the distribution is trusted. It installs node, yarn and
# otherwise (node missing, or another major) the pinned version is # the node packages only when yarn.lock is in the tree. The
# installed via nvm (installing nvm itself first, from a hash-verified # distribution's yarn only starts the release file that yarnPath in
# release archive, never curl | sh), and yarn and the packages are # .yarnrc.yml names, and that file does the work. So git pins the yarn
# installed under it. # that runs. Assumes nothing is present.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
APT_UPDATED="" APT_UPDATED=""
@@ -67,99 +60,33 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# verify_sha256 <file> <expected-hash> # Debian and Ubuntu name the yarn command yarnpkg.
verify_sha256() { run_yarn() {
if command -v sha256sum >/dev/null 2>&1; then if command -v yarn >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)" yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" echo "no yarn; run script/bootstrap first" >&2
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1 exit 1
fi fi
} }
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
# True when the node on PATH has the pinned major version. Only the
# major is compared: the Dockerfile stages start from a node 22 alpine
# image whose exact version is not the pin, and nvm cannot install a
# prebuilt node on alpine. Another major is not used: the pinned yarn 1
# prints a deprecation warning under node 24. script/fmt and
# script/fmt-check carry this function unchanged, to run the yarn
# installed here.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
ensure_node() {
if node_is_pinned_major; then
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
echo \"bootstrap: using node \$(node --version)\""
}
ensure_yarn() {
if ! node_is_pinned_major; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
return 0
fi
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if node_is_pinned_major; then
yarn install --frozen-lockfile
else
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
ensure_node # A Dockerfile stage that needs no yarn copies no yarn.lock.
ensure_yarn if [ -f yarn.lock ]; then
install_js_deps if missing node; then pkg_install nodejs_latest nodejs node nodejs-current; fi
if missing yarn && missing yarnpkg; then
pkg_install yarn yarnpkg yarn yarn
fi
run_yarn install --immutable
fi
echo "bootstrap complete" echo "bootstrap complete"
} }
+7 -24
View File
@@ -4,33 +4,16 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Debian and Ubuntu name the yarn command yarnpkg.
NODE_VERSION="22.17.0"
# True when the node on PATH has the pinned major version: the same
# function as in script/bootstrap, which uses it to decide where it
# installs yarn.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() { run_yarn() {
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then if command -v yarn >/dev/null 2>&1; then
exec yarn "$@" yarn "$@"
fi elif command -v yarnpkg >/dev/null 2>&1; then
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then yarnpkg "$@"
echo "fmt: no yarn; run script/bootstrap first" >&2 else
echo "no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
} }
main() { main() {
+7 -24
View File
@@ -4,33 +4,16 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Debian and Ubuntu name the yarn command yarnpkg.
NODE_VERSION="22.17.0"
# True when the node on PATH has the pinned major version: the same
# function as in script/bootstrap, which uses it to decide where it
# installs yarn.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() { run_yarn() {
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then if command -v yarn >/dev/null 2>&1; then
exec yarn "$@" yarn "$@"
fi elif command -v yarnpkg >/dev/null 2>&1; then
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then yarnpkg "$@"
echo "fmt-check: no yarn; run script/bootstrap first" >&2 else
echo "no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
} }
main() { main() {
+21 -6
View File
@@ -1,8 +1,23 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # This file is generated by running "yarn install" inside your project.
# yarn lockfile v1 # Manual changes might be lost - proceed with caution!
__metadata:
version: 10
cacheKey: 10c0
prettier@3.8.1: "prettier@npm:3.8.1":
version "3.8.1" version: 3.8.1
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" resolution: "prettier@npm:3.8.1"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== bin:
prettier: bin/prettier.cjs
checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
prettier: "npm:3.8.1"
languageName: unknown
linkType: soft