diff --git a/TODO.md b/TODO.md index 1960ac5..3ef2366 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,21 @@ fmt-check, and commit. # Completed Steps +- 2026-10-08: The canonical `script/bootstrap` now uses the installed node only + when its major version is the pinned one (issue 118). Otherwise, as when node + is missing, it installs the pinned node under nvm and installs yarn and the + packages under it. The CI runner image ships node 24, under which the pinned + yarn 1.22.22 printed the deprecation warning DEP0169 on every bootstrap. Only + the major is compared because 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; the version-comparison rule in `REPO_POLICIES.md` + names node as its exception. `script/fmt` and `script/fmt-check` pick their + yarn with the same test: the `yarn` on `PATH` when the installed node is the + pinned major, and otherwise yarn under the pinned node through nvm. + `REPO_POLICIES.md` and both checklists say so, and the checklists' final + `script/cibuild` check names what the CI runner image has: node 24 and no + yarn. Not yet tried on the shared runner. Repositories pick this up on their + next re-vendor. - 2026-10-07: The `script/cibuild` item in `NEW_REPO_CHECKLIST.md` now matches the canonical `script/cibuild` (issue 125). Its image build carries the tag, `-t "$tag"`, and the item says that `$version` comes from diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 3027bac..f984a75 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-10-07 +last_modified: 2026-10-08 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -149,11 +149,13 @@ with your task. the image with `--no-cache`. Without the bootstrap the CI run dies in `script/fmt-check`, which runs the formatter on the host and finds nothing installed. -- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version - before invoking `yarn`, as `script/bootstrap`'s own install step does. - `script/bootstrap` leaves the node and yarn it installs off the `PATH` of - the shell that called it, so a bare `yarn` exits 127 on a runner carrying - nothing but docker and git. +- [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test + as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has + the pinned major version, and otherwise yarn under the pinned node, with + nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node + 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. - [ ] `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 brings a linter along with every other dependency, and no verdict is taken @@ -206,10 +208,11 @@ with your task. # Final - [ ] `make check` passes -- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but - docker and git, with no node or yarn on `PATH`, which is what CI has, and - demonstrably executed the checks — a sub-second build, or `CACHED` on a - gate layer, means nothing ran +- [ ] `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, + git and node 24, with no yarn on `PATH`), and demonstrably executed the + checks — a sub-second build, or `CACHED` on a gate layer, means nothing + ran - [ ] A planted lint violation fails both `make lint` and a plain `docker build .`; revert it afterwards - [ ] Commit and merge fixes before starting your actual task diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 2f3dd49..6561aef 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-10-07 +last_modified: 2026-10-08 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -136,8 +136,9 @@ are thin shims calling them. Model scripts: alpine images without bash - [ ] `script/bootstrap` / `make bootstrap` — installs all dependencies, idempotently, assuming nothing (pkg manager detection nix/apt/brew/apk; - node used if present, else pinned version via nvm from a hash-verified - archive; pinned yarn via corepack); a non-server repo's development + node used if its major version is the pinned one, else pinned version via + nvm from a hash-verified archive, with yarn and the packages installed + under it; pinned yarn via corepack); a non-server repo's development environment stage runs it instead of inline installs; a gate phase or the build stage installs what its base image lacks either inline or by running it @@ -170,11 +171,13 @@ are thin shims calling them. Model scripts: on its own line before the build. The bootstrap is required: CI checks out and runs this alone, and `script/fmt-check` runs the formatter on the host. -- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version - before invoking `yarn`, as `script/bootstrap`'s own install step does. - `script/bootstrap` leaves the node and yarn it installs off the `PATH` of - the shell that called it, so a bare `yarn` exits 127 on a runner carrying - nothing but docker and git. +- [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test + as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has + the pinned major version, and otherwise yarn under the pinned node, with + nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node + 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: `script/lint` and `script/test` write no image, and `script/docker` and `script/cibuild` tag theirs @@ -189,10 +192,11 @@ are thin shims calling them. Model scripts: - [ ] `make check` passes - [ ] `make docker` succeeds -- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but - docker and git, with no node or yarn on `PATH`, which is what CI has, and - demonstrably executed the checks — a sub-second build, or `CACHED` on a - gate layer, means nothing ran +- [ ] `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, + git and node 24, with no yarn on `PATH`), and demonstrably executed the + checks — a sub-second build, or `CACHED` on a gate layer, means nothing + ran - [ ] Plant a lint violation and confirm both `make lint` and a plain `docker build .` fail on it; revert. A plain build that passes proves the final stage is missing its `COPY --from=` edge to the gate phases. diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 62f417f..17eaf34 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-10-07 +last_modified: 2026-10-08 --- This document covers repository structure, tooling, and workflow standards. Code @@ -54,34 +54,39 @@ style conventions are in separate documents: `cibuild`. `script/bootstrap` installs all dependencies idempotently and assumes nothing is present: base tools come from nix, apt, brew, or apk (detected in that order; apt runs noninteractive). For node it uses the - installed node if present; otherwise it installs a PINNED node version via - nvm, first installing nvm itself if missing — from a hash-verified GitHub - release archive (never `curl | sh`), with bash installed as an explicit - prerequisite since nvm requires bash. yarn is then pinned via - `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; - always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root, runs `script/bootstrap`, runs `script/check`, and builds the image - with the version; the Gitea workflow calls it. **`script/cibuild` runs + installed node only when its major version is the pinned one; otherwise (node + missing, or another major, such as the node 24 the CI runner image ships) it + installs the PINNED node version via nvm and installs yarn and the packages + under it. nvm itself is installed first if missing, from a hash-verified + GitHub release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. Only the major version is compared + because the `Dockerfile` stages start from a node image whose exact version is + not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned + via `corepack prepare yarn@ --activate`. Never install "latest" or + "lts"; always exact versions. `script/cibuild` runs the CI build: it changes + to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the + image with the version; the Gitea workflow calls it. **`script/cibuild` runs `script/bootstrap` first**, because the workflow checks out the repo and runs nothing else, while `script/fmt-check` runs the formatter on the host: on a pristine checkout with nothing installed the run dies there, after the - containerised gates have passed. **The bootstrap alone is not enough**: - `script/bootstrap` installs node and yarn under nvm and leaves neither on the - `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host - entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore - source nvm for the pinned node version before invoking it, exactly as - `script/bootstrap`'s own install step does. A runner carrying nothing but - docker and git then gets through `script/check`. Four further scripts are our - own extensions to the standard: `script/check` runs `script/test`, - `script/lint` and `script/fmt-check`; `script/precommit` is what the git - pre-commit hook runs, and it calls `script/check`; `script/install-precommit` - installs the git pre-commit hook (the `make hooks` target shims to it); and - `script/projectname` (literally that filename) simply outputs the project's - name. Scripts that need the name call `script/projectname` — e.g. - `script/docker` assembles its image tag from it — so those scripts stay - byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. - `go mod tidy` verification in Go repos) belong in `script/precommit`, not in - the hook itself. Model scripts are at + containerised gates have passed. **The bootstrap alone is not enough**: when + `script/bootstrap` installs node and yarn under nvm it leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs + under another node. The host entrypoints that need yarn — `script/fmt` and + `script/fmt-check` — therefore use the same test as `script/bootstrap`: when + the node on `PATH` has the pinned major version they run the `yarn` on `PATH`, + and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the + pinned node. A runner carrying nothing but docker and git then gets through + `script/check`. Four further scripts are our own extensions to the standard: + `script/check` runs `script/test`, `script/lint` and `script/fmt-check`; + `script/precommit` is what the git pre-commit hook runs, and it calls + `script/check`; `script/install-precommit` installs the git pre-commit hook + (the `make hooks` target shims to it); and `script/projectname` (literally + that filename) simply outputs the project's name. Scripts that need the name + call `script/projectname` — e.g. `script/docker` assembles its image tag from + it — so those scripts stay byte-identical across all repos. Repo-type-specific + pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in + `script/precommit`, not in the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -549,6 +554,11 @@ style conventions are in separate documents: function defined and never invoked has the same exit status and the same empty output as one that worked. + Node is the exception to the whole-token comparison: the canonical + `script/bootstrap` compares only its major version, because the node 22 + alpine image the `Dockerfile` stages start from is not the exact pin, and + nvm cannot install a prebuilt node on alpine. + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. A Go tool a repo needs on the host is installed with `go install` pinned to diff --git a/script/bootstrap b/script/bootstrap index 799f256..f00b1fd 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -2,10 +2,12 @@ # script/bootstrap: install all dependencies needed to build and develop # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, -# or apk (detected in that order); assumes nothing is present. Node is -# used directly if installed; otherwise it is installed at a pinned -# version via nvm (installing nvm itself first, from a hash-verified -# release archive, never curl | sh). +# or apk (detected in that order); assumes nothing is present. The +# installed node is used only when its major version is the pinned one; +# otherwise (node missing, or another major) the pinned version is +# installed via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh), and yarn and the packages are +# installed under it. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -100,31 +102,47 @@ ensure_nvm() { 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 ! missing node; then return 0; fi + if node_is_pinned_major; then return 0; fi ensure_nvm nvm_sh "nvm install $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 - elif [ -s "$HOME/.nvm/nvm.sh" ]; then - nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ - corepack prepare yarn@$YARN_VERSION --activate" else npm install -g "yarn@$YARN_VERSION" fi } install_js_deps() { - if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + 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" - else - yarn install --frozen-lockfile fi } diff --git a/script/fmt b/script/fmt index 68cb9af..bbc6915 100755 --- a/script/fmt +++ b/script/fmt @@ -7,12 +7,22 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Must match the pin in script/bootstrap. NODE_VERSION="22.17.0" -# script/bootstrap installs node and yarn under nvm and leaves neither -# on the PATH of the shell that called it, so resolve the pinned -# toolchain here the way bootstrap's own install step does. nvm is a -# bash script, hence the subshell. +# 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() { - if command -v yarn >/dev/null 2>&1; then + if node_is_pinned_major; then exec yarn "$@" fi if [ ! -s "$HOME/.nvm/nvm.sh" ]; then diff --git a/script/fmt-check b/script/fmt-check index c7ff266..3e5a2a9 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -7,12 +7,22 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Must match the pin in script/bootstrap. NODE_VERSION="22.17.0" -# script/bootstrap installs node and yarn under nvm and leaves neither -# on the PATH of the shell that called it, so resolve the pinned -# toolchain here the way bootstrap's own install step does. nvm is a -# bash script, hence the subshell. +# 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() { - if command -v yarn >/dev/null 2>&1; then + if node_is_pinned_major; then exec yarn "$@" fi if [ ! -s "$HOME/.nvm/nvm.sh" ]; then