Install the pinned node when the installed one is another major version (closes #118)
check / check (push) Waiting to run

script/bootstrap used whatever node was installed, so on the CI runner
image, which ships node 24, the pinned yarn 1.22.22 printed the
deprecation warning DEP0169 on every bootstrap. It now uses the
installed node only when its major version is the pinned one, and
otherwise installs the pinned node with nvm, as it already did when node
was missing, with yarn and the packages under it. It prints the node
version it uses. Only the major is compared because the Dockerfile
stages use a node 22 alpine image that nvm cannot replace. script/fmt
and script/fmt-check carry the same test to pick their yarn: the yarn on
PATH, or yarn under the pinned node loaded through nvm.

Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
2026-10-08 04:14:21 +02:00
parent c55a0cb2f0
commit f9e23ccae3
7 changed files with 146 additions and 71 deletions
+15
View File
@@ -21,6 +21,21 @@ fmt-check, and commit.
# Completed Steps # 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. It prints the version of the node it uses either way. 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 - 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, the canonical `script/cibuild` (issue 125). Its image build carries the tag,
`-t "$tag"`, and the item says that `$version` comes from `-t "$tag"`, and the item says that `$version` comes from
+13 -10
View File
@@ -1,6 +1,6 @@
--- ---
title: Existing Repo Checklist 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 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 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 `script/fmt-check`, which runs the formatter on the host and finds nothing
installed. installed.
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version - [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test
before invoking `yarn`, as `script/bootstrap`'s own install step does. as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of the pinned major version, and otherwise yarn under the pinned node, with
the shell that called it, so a bare `yarn` exits 127 on a runner carrying nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node
nothing but docker and git. 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 - [ ] `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
@@ -206,10 +208,11 @@ with your task.
# Final # Final
- [ ] `make check` passes - [ ] `make check` passes
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has
docker and git, with no node or yarn on `PATH`, which is what CI has, and (the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker,
demonstrably executed the checks — a sub-second build, or `CACHED` on a git and node 24, with no yarn on `PATH`), and demonstrably executed the
gate layer, means nothing ran 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 - [ ] A planted lint violation fails both `make lint` and a plain
`docker build .`; revert it afterwards `docker build .`; revert it afterwards
- [ ] Commit and merge fixes before starting your actual task - [ ] Commit and merge fixes before starting your actual task
+16 -12
View File
@@ -1,6 +1,6 @@
--- ---
title: New Repo Checklist 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 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 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, assuming nothing (pkg manager detection nix/apt/brew/apk;
node used if present, else pinned version via nvm from a hash-verified node used if its major version is the pinned one, else pinned version via
archive; pinned yarn via corepack); a non-server repo's development 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 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 build stage installs what its base image lacks either inline or by running
it 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 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 and runs this alone, and `script/fmt-check` runs the formatter on the
host. host.
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version - [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test
before invoking `yarn`, as `script/bootstrap`'s own install step does. as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of the pinned major version, and otherwise yarn under the pinned node, with
the shell that called it, so a bare `yarn` exits 127 on a runner carrying nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node
nothing but docker and git. 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
@@ -189,10 +192,11 @@ 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 nothing but - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has
docker and git, with no node or yarn on `PATH`, which is what CI has, and (the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker,
demonstrably executed the checks — a sub-second build, or `CACHED` on a git and node 24, with no yarn on `PATH`), and demonstrably executed the
gate layer, means nothing ran 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 - [ ] 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 `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. final stage is missing its `COPY --from=` edge to the gate phases.
+36 -26
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-07 last_modified: 2026-10-08
--- ---
This document covers repository structure, tooling, and workflow standards. Code 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 `cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk 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 (detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via installed node only when its major version is the pinned one; otherwise (node
nvm, first installing nvm itself if missing — from a hash-verified GitHub missing, or another major, such as the node 24 the CI runner image ships) it
release archive (never `curl | sh`), with bash installed as an explicit installs the PINNED node version via nvm and installs yarn and the packages
prerequisite since nvm requires bash. yarn is then pinned via under it. nvm itself is installed first if missing, from a hash-verified
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; GitHub release archive (never `curl | sh`), with bash installed as an explicit
always exact versions. `script/cibuild` runs the CI build: it changes to the prerequisite since nvm requires bash. Only the major version is compared
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image because the `Dockerfile` stages start from a node image whose exact version is
with the version; the Gitea workflow calls it. **`script/cibuild` runs 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: 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 `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 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 pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**: containerised gates have passed. **The bootstrap alone is not enough**: when
`script/bootstrap` installs node and yarn under nvm and leaves neither on the `script/bootstrap` installs node and yarn under nvm it leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host `PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore under another node. The host entrypoints that need yarn — `script/fmt` and
source nvm for the pinned node version before invoking it, exactly as `script/fmt-check` — therefore use the same test as `script/bootstrap`: when
`script/bootstrap`'s own install step does. A runner carrying nothing but the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
docker and git then gets through `script/check`. Four further scripts are our and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
own extensions to the standard: `script/check` runs `script/test`, pinned node. A runner carrying nothing but docker and git then gets through
`script/lint` and `script/fmt-check`; `script/precommit` is what the git `script/check`. Four further scripts are our own extensions to the standard:
pre-commit hook runs, and it calls `script/check`; `script/install-precommit` `script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
installs the git pre-commit hook (the `make hooks` target shims to it); and `script/precommit` is what the git pre-commit hook runs, and it calls
`script/projectname` (literally that filename) simply outputs the project's `script/check`; `script/install-precommit` installs the git pre-commit hook
name. Scripts that need the name call `script/projectname` — e.g. (the `make hooks` target shims to it); and `script/projectname` (literally
`script/docker` assembles its image tag from it — so those scripts stay that filename) simply outputs the project's name. Scripts that need the name
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. call `script/projectname` — e.g. `script/docker` assembles its image tag from
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in it — so those scripts stay byte-identical across all repos. Repo-type-specific
the hook itself. Model scripts are at 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/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). 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 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
`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`. 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 A Go tool a repo needs on the host is installed with `go install` pinned to
+34 -11
View File
@@ -2,10 +2,12 @@
# 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 tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is # or apk (detected in that order); assumes nothing is present. The
# used directly if installed; otherwise it is installed at a pinned # installed node is used only when its major version is the pinned one;
# version via nvm (installing nvm itself first, from a hash-verified # otherwise (node missing, or another major) the pinned version is
# release archive, never curl | sh). # 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -100,31 +102,52 @@ ensure_nvm() {
rm -rf "$tmp" 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() { ensure_node() {
if ! missing node; then return 0; fi if node_is_pinned_major; then
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm ensure_nvm
nvm_sh "nvm install $NODE_VERSION" nvm_sh "nvm install $NODE_VERSION"
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
echo \"bootstrap: using node \$(node --version)\""
} }
ensure_yarn() { 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 yarn; then return 0; fi
if ! missing corepack; then if ! missing corepack; then
corepack enable corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate 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 else
npm install -g "yarn@$YARN_VERSION" npm install -g "yarn@$YARN_VERSION"
fi fi
} }
install_js_deps() { 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\" && \ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile" yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi fi
} }
+16 -6
View File
@@ -7,15 +7,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither # True when the node on PATH has the pinned major version: the same
# on the PATH of the shell that called it, so resolve the pinned # function as in script/bootstrap, which uses it to decide where it
# toolchain here the way bootstrap's own install step does. nvm is a # installs yarn.
# bash script, hence the subshell. 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 command -v yarn >/dev/null 2>&1; then if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
exec yarn "$@" exec yarn "$@"
fi fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2 echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi
+16 -6
View File
@@ -7,15 +7,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither # True when the node on PATH has the pinned major version: the same
# on the PATH of the shell that called it, so resolve the pinned # function as in script/bootstrap, which uses it to decide where it
# toolchain here the way bootstrap's own install step does. nvm is a # installs yarn.
# bash script, hence the subshell. 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 command -v yarn >/dev/null 2>&1; then if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
exec yarn "$@" exec yarn "$@"
fi fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2 echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi