diff --git a/TODO.md b/TODO.md index 1960ac5..1a6fce0 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,17 @@ 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. `script/fmt` and `script/fmt-check` now run yarn + under nvm's pinned node when nvm has it installed, and otherwise the `yarn` on + `PATH`. `REPO_POLICIES.md` and both checklists say so. 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..40d9865 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,11 @@ 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` run `yarn` under nvm's pinned node + when nvm has that version installed, and otherwise the `yarn` on `PATH`. + `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 diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 2f3dd49..c1627b1 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,11 @@ 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` run `yarn` under nvm's pinned node + when nvm has that version installed, and otherwise the `yarn` on `PATH`. + `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 diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 62f417f..d56210b 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,38 @@ 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 run it under nvm's pinned node when nvm has + that version installed, and otherwise run the `yarn` on `PATH`. 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). diff --git a/script/bootstrap b/script/bootstrap index 799f256..bbad5df 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,45 @@ 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. +node_is_pinned_major() { + if missing node; 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..2c76a5d 100755 --- a/script/fmt +++ b/script/fmt @@ -7,20 +7,21 @@ 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. +# When the installed node is not the pinned major version, +# script/bootstrap installs the pinned node and yarn under nvm and +# leaves neither on the PATH of the shell that called it. So yarn runs +# under nvm's pinned node when nvm has it installed, and otherwise is +# the yarn on PATH. nvm is a bash script, hence the subshell. run_yarn() { - if command -v yarn >/dev/null 2>&1; then - exec yarn "$@" + if [ -d "$HOME/.nvm/versions/node/v$NODE_VERSION" ]; then + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" fi - if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + if ! command -v yarn >/dev/null 2>&1; then echo "fmt: no yarn; run script/bootstrap first" >&2 exit 1 fi - exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && - shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" + exec yarn "$@" } main() { diff --git a/script/fmt-check b/script/fmt-check index c7ff266..9e196ed 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -7,20 +7,21 @@ 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. +# When the installed node is not the pinned major version, +# script/bootstrap installs the pinned node and yarn under nvm and +# leaves neither on the PATH of the shell that called it. So yarn runs +# under nvm's pinned node when nvm has it installed, and otherwise is +# the yarn on PATH. nvm is a bash script, hence the subshell. run_yarn() { - if command -v yarn >/dev/null 2>&1; then - exec yarn "$@" + if [ -d "$HOME/.nvm/versions/node/v$NODE_VERSION" ]; then + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" fi - if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + if ! command -v yarn >/dev/null 2>&1; then echo "fmt-check: no yarn; run script/bootstrap first" >&2 exit 1 fi - exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && - shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" + exec yarn "$@" } main() {