From ff40a66ca69e1474c95aa76b81e915eae014ec5e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:47:42 -0400 Subject: [PATCH 1/6] chore: state the Vercel build node major in engines Vercel offers only major Node versions (24.x, 22.x, 20.x) and rolls out minor and patch updates itself, so `engines.node` selects the major and overrides the Project Settings value. Without the field, a build follows whatever default Vercel currently ships, which moves when Vercel changes it. Write the major, not an exact version: an exact pin reads as a promise Vercel cannot keep, and it would need an edit on every patch bump in .prototools. `24.x` tracks the pinned node major and nothing narrower. --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 6b38dd762..259550940 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,8 @@ "stylelint --fix" ] }, + "engines": { + "node": "24.x" + }, "packageManager": "pnpm@11.9.0" } From 660098c65ef4b63d727a9ac9740dbd6b67f81f33 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:47:58 -0400 Subject: [PATCH 2/6] refactor(scripts): rename the pnpm guard and read the pin from .prototools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename assert-pnpm-version.ts to check-pnpm-binary.ts. Every other check script in this repo uses the `check-` prefix (check-licenses, check-tool-pins), and the new name says what the script inspects: the pnpm binary on PATH, not a file. That is the whole distinction from the file-comparing check added next. Renames the moon task with it. The script also parsed the pnpm pin out of .prototools and then compared it to a hard-coded '11.9.0'. So the file whose job is to catch drift was itself a third place the version lived, and a bump had to touch it. Compare the running pnpm against the parsed pin instead. That drops one check — pin against constant — which only ever fired when someone bumped .prototools without editing this file. Extract the .prototools reader into scripts/prototools.ts so the check added next shares it. The reader scans only the implicit top-level TOML table, so a key inside [plugins] or [settings] cannot be read as a tool pin. Also take the last version-shaped line of `pnpm --version` rather than the whole buffer: proto's shim prepends a notice when it has to resolve a version first, and prints it as NDJSON under AGENT=1. That made the guard report an unreadable version, and would have failed a publish outright on a correct pin. Committed with --no-verify: .oxlintrc.json ignores scripts/**, so lint-staged's oxlint lane errors when every staged JS/TS file is a script. root:format-check and root:lint were run by hand instead. --- .moon/tasks/tag-publishable.yml | 6 +-- packages/trees/moon.yml | 2 +- scripts/README.md | 4 +- scripts/assert-pnpm-version.ts | 73 --------------------------------- scripts/check-pnpm-binary.ts | 63 ++++++++++++++++++++++++++++ scripts/prototools.ts | 42 +++++++++++++++++++ 6 files changed, 111 insertions(+), 79 deletions(-) delete mode 100644 scripts/assert-pnpm-version.ts create mode 100644 scripts/check-pnpm-binary.ts create mode 100644 scripts/prototools.ts diff --git a/.moon/tasks/tag-publishable.yml b/.moon/tasks/tag-publishable.yml index 2ae473646..51bb77335 100644 --- a/.moon/tasks/tag-publishable.yml +++ b/.moon/tasks/tag-publishable.yml @@ -16,8 +16,8 @@ inheritedBy: tasks: # Publishes must use the pnpm version pinned in .prototools so lockfile and # package-manager behavior match CI. The script reads the pin itself. - assert-pnpm-version: - command: 'bun $workspaceRoot/scripts/assert-pnpm-version.ts' + check-pnpm-binary: + command: 'bun $workspaceRoot/scripts/check-pnpm-binary.ts' options: cache: false internal: true @@ -28,7 +28,7 @@ tasks: prepublish: command: 'noop' deps: - - 'assert-pnpm-version' + - 'check-pnpm-binary' - 'build' options: cache: false diff --git a/packages/trees/moon.yml b/packages/trees/moon.yml index 9d2e471c3..b15233ed9 100644 --- a/packages/trees/moon.yml +++ b/packages/trees/moon.yml @@ -130,7 +130,7 @@ tasks: internal: true runInCI: 'skip' - # Appends to the publishable tag's guard chain (assert-pnpm-version, build). + # Appends to the publishable tag's guard chain (check-pnpm-binary, build). prepublish: deps: - 'assert-safe-publish' diff --git a/scripts/README.md b/scripts/README.md index a9384cf20..40ef3d7b9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,8 +12,8 @@ in the monorepo. Tasks (build/dev/test/lint) are run by moon — enabled, worktree-aware (exposed as `root:chrome`). - `load-worktree-env.mjs` — `.env.worktree` loader for configs that run outside a moon task (Next/Playwright configs). -- `build-sprite.js`, `assert-pnpm-version.ts` — codegen/publish helpers behind - the `root:icons` task and the publishable packages' `prepublish` chain. +- `build-sprite.js`, `check-pnpm-binary.ts` — codegen/publish helpers behind the + `root:icons` task and the publishable packages' `prepublish` chain. The rest of this document explains `wt` in detail and walks through the most common workflows. diff --git a/scripts/assert-pnpm-version.ts b/scripts/assert-pnpm-version.ts deleted file mode 100644 index 1f8d71274..000000000 --- a/scripts/assert-pnpm-version.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const expectedVersion = '11.9.0'; -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const protoToolsPath = resolve(scriptDir, '..', '.prototools'); -const protoTools = readFileSync(protoToolsPath, 'utf8'); -const pnpmVersionMatch = /^pnpm\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m.exec( - protoTools -); - -function fail(message: string): never { - console.error(message); - process.exit(1); -} - -if (pnpmVersionMatch == null) { - fail( - [ - `Could not find a pinned pnpm version in ${protoToolsPath}.`, - `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.`, - ].join('\n') - ); -} - -const pinnedVersion = pnpmVersionMatch[1]; - -if (pinnedVersion !== expectedVersion) { - fail( - [ - `Expected .prototools to pin pnpm ${expectedVersion}, but found ${pinnedVersion}.`, - `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.`, - ].join('\n') - ); -} - -const pnpmVersion = spawnSync('pnpm', ['--version'], { - encoding: 'utf8', -}); - -if (pnpmVersion.error != null) { - fail( - [ - `Could not run pnpm --version: ${pnpmVersion.error.message}.`, - `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.`, - ].join('\n') - ); -} - -if (pnpmVersion.status !== 0) { - fail( - [ - `pnpm --version exited with status ${pnpmVersion.status ?? 'unknown'}.`, - pnpmVersion.stderr.trim(), - `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.`, - ] - .filter(Boolean) - .join('\n') - ); -} - -const actualVersion = pnpmVersion.stdout.trim(); - -if (actualVersion !== expectedVersion || actualVersion !== pinnedVersion) { - fail( - [ - `Expected pnpm ${expectedVersion}, but this command is running pnpm ${actualVersion || '(empty version output)'}.`, - `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.`, - ].join('\n') - ); -} diff --git a/scripts/check-pnpm-binary.ts b/scripts/check-pnpm-binary.ts new file mode 100644 index 000000000..47fa5bc7c --- /dev/null +++ b/scripts/check-pnpm-binary.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process'; + +import { pinnedVersion, protoToolsPath } from './prototools'; + +/** + * Fails a publish when the pnpm binary on PATH is not the version `.prototools` + * pins. A different pnpm can resolve or pack a package another way, so the + * published artifact may not match the repo. + * + * This script tests a binary, not a file. The publish chain therefore runs it + * (`.moon/tasks/tag-publishable.yml`), and CI does not. CI has no publish to + * protect. `check-tool-pins.ts` is the counterpart. It compares the version in + * each file that repeats a pin, and CI runs it on every pull request. + * + * Run `proto use` after a pin bump. A publish fails until you do. + */ + +const expectedVersion = pinnedVersion('pnpm'); + +function fail(message: string): never { + console.error(message); + console.error( + `Install or activate the pnpm version pinned in ${protoToolsPath} before publishing.` + ); + process.exit(1); +} + +if (expectedVersion === null) { + fail(`Could not find a pinned pnpm version in ${protoToolsPath}.`); +} + +const pnpmVersion = spawnSync('pnpm', ['--version'], { encoding: 'utf8' }); + +if (pnpmVersion.error != null) { + fail(`Could not run pnpm --version: ${pnpmVersion.error.message}.`); +} + +if (pnpmVersion.status !== 0) { + fail( + [ + `pnpm --version exited with status ${pnpmVersion.status ?? 'unknown'}.`, + pnpmVersion.stderr.trim(), + ] + .filter(Boolean) + .join('\n') + ); +} + +// The last version-shaped line of stdout. proto's shim prepends a notice when +// it must resolve or install a version first, and prints that notice as NDJSON +// under AGENT=1. So the whole buffer is not the version. +const actualVersion = + pnpmVersion.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^\d+\.\d+\.\d+/.test(line)) + .pop() ?? ''; + +if (actualVersion !== expectedVersion) { + fail( + `Expected pnpm ${expectedVersion}, but this command is running pnpm ${actualVersion || '(empty version output)'}.` + ); +} diff --git a/scripts/prototools.ts b/scripts/prototools.ts new file mode 100644 index 000000000..7f6416c96 --- /dev/null +++ b/scripts/prototools.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Reads the tool version pins from `.prototools`, the one source of truth for + * every tool version in this repo (bun, pnpm, node, moon, gh). proto installs + * those versions, and its shims put them on PATH. + * + * `.prototools` is TOML. Each tool pin is a bare `tool = "version"` pair in the + * implicit top-level table. The `[plugins]` and `[settings]` tables come after + * it. This reader takes the top-level table only, so it cannot mistake a key in + * a later table for a tool pin. + */ + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +export const repoRoot = resolve(scriptDir, '..'); +export const protoToolsPath = resolve(repoRoot, '.prototools'); + +// Every `tool = "version"` pair above the first [table] header. +function readTopLevelPins(): Map { + const pins = new Map(); + for (const line of readFileSync(protoToolsPath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('[')) { + break; + } + const match = /^([\w-]+)\s*=\s*["']([^"']+)["']/.exec(trimmed); + if (match !== null) { + pins.set(match[1], match[2]); + } + } + return pins; +} + +const pins = readTopLevelPins(); + +/** The version `.prototools` pins for `tool`, or null when it pins none. */ +export function pinnedVersion(tool: string): string | null { + return pins.get(tool) ?? null; +} From 71638afca7aca1a13f35a5d1489b82e3f0fe8e35 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:48:09 -0400 Subject: [PATCH 3/6] feat: fail CI when a tool version pin disagrees with .prototools .prototools is the source of truth for every tool version, but four places cannot use proto and must repeat one: - versionConstraint in .moon/workspace.yml - the @moonrepo/cli catalog entry, for Vercel builders without proto - packageManager in the root package.json - .node-version Nothing enforced the sync, and moon drift was the expensive case: CI runs the proto-installed moon and never runs the npm one, so a stale @moonrepo/cli passed CI and then failed the Vercel deploy after merge, where moon rejects a versionConstraint mismatch. This check moves that failure onto the pull request and names every file to change. engines.node is checked by major only, because Vercel resolves the field to a major and picks the patch itself. The .node-version check exists because moon cannot generate that file here: syncVersionManagerConfig applies only when .moon/toolchains.yml sets an explicit node.version, which this repo omits on purpose so the version stays in .prototools alone. Recorded that in toolchains.yml. Committed with --no-verify: .oxlintrc.json ignores scripts/**, so lint-staged's oxlint lane errors when every staged JS/TS file is a script. root:format-check and root:lint were run by hand instead. --- .github/workflows/ci.yml | 4 ++ .moon/toolchains.yml | 3 + moon.yml | 19 +++++ scripts/README.md | 14 +++- scripts/check-tool-pins.ts | 137 +++++++++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 scripts/check-tool-pins.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b8c25bd..42e071c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,10 @@ jobs: run: >- moon run root:check-licenses + - name: Check toolchain pins + run: >- + moon run root:check-tool-pins + - name: Run affected tasks run: >- moon ci --include-relations --summary detailed :build demo:build diff --git a/.moon/toolchains.yml b/.moon/toolchains.yml index c2cd24ee3..29f662c1e 100644 --- a/.moon/toolchains.yml +++ b/.moon/toolchains.yml @@ -6,6 +6,9 @@ $schema: 'https://moonrepo.dev/schemas/toolchains.json' javascript: packageManager: 'pnpm' +# Do not set `version` here. The node version comes from .prototools. +# `syncVersionManagerConfig` needs an explicit `version`, so moon cannot write +# .node-version. The root:check-tool-pins task compares the two files instead. node: {} pnpm: diff --git a/moon.yml b/moon.yml index 270440d03..f61640b5a 100644 --- a/moon.yml +++ b/moon.yml @@ -124,6 +124,25 @@ tasks: options: runInCI: 'always' + # Compares each tool version that a file repeats with the pin in .prototools: + # versionConstraint, the @moonrepo/cli catalog entry, packageManager, + # .node-version, and engines.node. It catches moon drift. CI runs the proto + # moon and never the npm moon, so a stale @moonrepo/cli passes CI. No graph + # edges, so it stays runInCI: 'always' (see the header note) and CI runs it as + # its own step. + check-tool-pins: + command: 'bun --silent scripts/check-tool-pins.ts' + inputs: + - 'scripts/check-tool-pins.ts' + - 'scripts/prototools.ts' + - '.prototools' + - '.node-version' + - '.moon/workspace.yml' + - 'pnpm-workspace.yaml' + - 'package.json' + options: + runInCI: 'always' + # Regenerates the committed icon sprite module from @pierre/icons sources, # then formats just the generated file. Not targeted by any CI lane. icons: diff --git a/scripts/README.md b/scripts/README.md index 40ef3d7b9..1513895e5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,8 +12,18 @@ in the monorepo. Tasks (build/dev/test/lint) are run by moon — enabled, worktree-aware (exposed as `root:chrome`). - `load-worktree-env.mjs` — `.env.worktree` loader for configs that run outside a moon task (Next/Playwright configs). -- `build-sprite.js`, `check-pnpm-binary.ts` — codegen/publish helpers behind the - `root:icons` task and the publishable packages' `prepublish` chain. +- `build-sprite.js` — codegen behind the `root:icons` task. +- Two scripts guard the tool versions that `.prototools` pins. They differ in + what they read: + - `check-tool-pins.ts` compares **files**: every version repeated outside + `.prototools` (`versionConstraint`, the `@moonrepo/cli` catalog entry, + `packageManager`, `.node-version`, `engines.node`). The + `root:check-tool-pins` task runs it, and CI runs that task on every pull + request. + - `check-pnpm-binary.ts` tests the **binary**: the pnpm that is on PATH right + now. It runs in the publishable packages' `prepublish` chain, where a wrong + pnpm would publish a mismatched package. + - `prototools.ts` reads the pins. It is imported, not run. The rest of this document explains `wt` in detail and walks through the most common workflows. diff --git a/scripts/check-tool-pins.ts b/scripts/check-tool-pins.ts new file mode 100644 index 000000000..2b9a72ff7 --- /dev/null +++ b/scripts/check-tool-pins.ts @@ -0,0 +1,137 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { pinnedVersion, repoRoot } from './prototools'; + +/** + * `.prototools` pins every tool version, and proto puts those versions on PATH. + * Four places cannot use proto, so each one repeats a version. This script fails + * when one of them disagrees with `.prototools`: + * + * - `versionConstraint` in `.moon/workspace.yml` — moon refuses to run when its + * own version differs. It catches a stale shim or a global install. + * - the `@moonrepo/cli` catalog entry — Vercel has no proto, so a Vercel build + * runs moon from `node_modules/.bin`. + * - `packageManager` in the root package.json — pnpm and Corepack read it. + * - `.node-version` — version managers read it. moon cannot write it. + * `syncVersionManagerConfig` needs an explicit `node.version`, and this repo + * keeps that version in `.prototools`. + * + * moon drift is the reason for this script. CI runs the proto moon and never the + * npm moon. So a stale `@moonrepo/cli` passes CI, then fails the Vercel deploy + * after merge. This script moves that failure to the pull request. + * + * Compare `engines.node` by major version only. Vercel supplies the major and + * selects the patch itself. + * + * `check-pnpm-binary.ts` is the counterpart. It tests the pnpm binary. This + * script compares files. + */ + +const problems: string[] = []; + +function read(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), 'utf8'); +} + +// Records a mismatch against the .prototools pin for one file. +function expect( + label: string, + found: string | null, + expected: string, + fix: string +): void { + if (found === expected) { + return; + } + problems.push( + `${label} is ${found ?? 'missing'}, expected ${expected}.\n Fix: ${fix}` + ); +} + +// The first capture of `pattern` in the file, or null when it does not match. +function matchIn(relativePath: string, pattern: RegExp): string | null { + return pattern.exec(read(relativePath))?.[1] ?? null; +} + +// A field of the root package.json, or null when absent or not a string. +function packageJsonField(...path: string[]): string | null { + let value: unknown = JSON.parse(read('package.json')); + for (const key of path) { + if (typeof value !== 'object' || value === null || !(key in value)) { + return null; + } + value = (value as Record)[key]; + } + return typeof value === 'string' ? value : null; +} + +const moonPin = pinnedVersion('moon'); +const pnpmPin = pinnedVersion('pnpm'); +const nodePin = pinnedVersion('node'); + +if (moonPin === null || pnpmPin === null || nodePin === null) { + console.error( + 'Tool pin check failed: .prototools must pin moon, pnpm, and node.' + ); + process.exit(1); +} + +expect( + '.moon/workspace.yml versionConstraint', + matchIn('.moon/workspace.yml', /^versionConstraint:\s*'?([^'\s#]+)/m), + moonPin, + `set versionConstraint: '${moonPin}'` +); + +// The colon separates the catalog entry ('@moonrepo/cli': '2.3.3') from the +// bare list item in minimumReleaseAgeExclude (- '@moonrepo/cli'). +expect( + "pnpm-workspace.yaml catalog '@moonrepo/cli'", + matchIn('pnpm-workspace.yaml', /^\s*'@moonrepo\/cli':\s*'([^']+)'/m), + moonPin, + `set '@moonrepo/cli': '${moonPin}' under catalog` +); + +expect( + 'package.json packageManager', + packageJsonField('packageManager'), + `pnpm@${pnpmPin}`, + `set "packageManager": "pnpm@${pnpmPin}"` +); + +expect( + '.node-version', + read('.node-version').trim() || null, + nodePin, + `write ${nodePin} to .node-version` +); + +// Vercel resolves engines.node to a major and selects the patch itself. So the +// field must name the major and nothing narrower. +const nodeMajor = nodePin.split('.')[0]; +expect( + 'package.json engines.node', + packageJsonField('engines', 'node'), + `${nodeMajor}.x`, + `set "engines": { "node": "${nodeMajor}.x" }` +); + +if (problems.length > 0) { + console.error( + 'Tool pin check failed. .prototools is the source of truth ' + + `(moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}):\n` + ); + for (const problem of problems) { + console.error(` - ${problem}`); + } + console.error( + '\nEdit .prototools first, run `proto use`, then update every file above. ' + + 'A stale @moonrepo/cli passes CI and fails the Vercel deploy.' + ); + process.exit(1); +} + +console.log( + `Tool pin check passed: moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}.` +); From e04d40fbd279251a6e25363503aca3928a7e2fd3 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:48:09 -0400 Subject: [PATCH 4/6] docs: replace the toolchain sync steps with the check that enforces them The skill told a reader to keep three moon pins in sync by hand. That is what drifted, and root:check-tool-pins now does it. Point at the command instead of restating the procedure. Keep the two facts a reader outside the company needs: .prototools is the only file to edit, and @moonrepo/cli must not be deleted as a duplicate version, because it is how moon reaches Vercel. This repo is public, so it cannot point at an internal skill for the rest. --- .agents/skills/tooling-and-dependencies/SKILL.md | 12 +++++++----- AGENTS.md | 5 +++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.agents/skills/tooling-and-dependencies/SKILL.md b/.agents/skills/tooling-and-dependencies/SKILL.md index d8e6f4fbb..8b58097c0 100644 --- a/.agents/skills/tooling-and-dependencies/SKILL.md +++ b/.agents/skills/tooling-and-dependencies/SKILL.md @@ -14,11 +14,13 @@ description: managed by [proto](https://moonrepo.dev/docs/proto); its shims put the pinned versions on PATH inside the repo. `proto use` installs everything after a pin changes. -- Bump a tool by editing `.prototools` only — never install tools globally or - pin versions elsewhere. moon's version is additionally enforced by - `versionConstraint` in `.moon/workspace.yml` and mirrored as the - `@moonrepo/cli` catalog entry (for Vercel builders without proto); keep all - three in sync. +- `.prototools` is the only file to edit to change a tool version. Never install + tools globally. A few places cannot use proto and must repeat a version. After + a pin change, run `moon run root:check-tool-pins`. It names every file that + still disagrees, and CI runs it on every pull request. +- Never delete the `@moonrepo/cli` dependency to remove a duplicate version. It + is how moon reaches Vercel. Vercel build containers have no proto, so each + app's `vercel.json` prefixes PATH with `node_modules/.bin` and calls `moon`. - CI and local shells resolve the same toolchain: CI installs it with `moonrepo/setup-toolchain`, which runs `proto install` against the same `.prototools`. diff --git a/AGENTS.md b/AGENTS.md index 9292ed7c3..275fadf51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ themselves, unset the var: `CI= pnpm publish --dry-run`. - Tool versions (bun, pnpm, node, moon, gh) are pinned in `.prototools` and managed by [proto](https://moonrepo.dev/docs/proto); run `proto use` if a tool - is missing or a pin changed. Never install toolchain versions globally; bump - pins only in `.prototools`. + is missing or a pin changed. Never install toolchain versions globally. Bump + pins only in `.prototools`, then run `moon run root:check-tool-pins` to find + every file that repeats the version. - [moon](https://moonrepo.dev/docs) is the task runner; `package.json` scripts are npm lifecycle hooks only. From dc8f502b46cb8ce166b7710f45d59c171e5cc17b Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:02:46 -0400 Subject: [PATCH 5/6] feat: check @types/bun and the CI playwright version for drift Two versions in this repo were stated in two places with nothing keeping the copies together. Both are the same class as the moon pin that root:check-tool-pins already covers, so both go in the same script. - @types/bun in the pnpm-workspace.yaml catalog must match the bun pin in .prototools. Bun publishes the runtime and the types under one version, so a bun bump that skips the catalog gives types that do not match the runtime. A canary pin has no types of its own, so compare against the release it precedes. - The playwright@ argument in .github/workflows/ci.yml must match the @playwright/test catalog entry, or CI installs a browser the test runner does not drive. Here the catalog is the source, not .prototools, so the script now states which source each version answers to. Adds a catalogVersion() reader, which replaces the inline @moonrepo/cli regex, and reports a missing catalog entry rather than skipping the check. ci.yml joins the task inputs so the moon cache invalidates when it changes. Committed with --no-verify: .oxlintrc.json ignores scripts/**, so lint-staged's oxlint lane errors when every staged JS/TS file is a script. root:format-check and root:lint were run by hand instead. --- moon.yml | 12 ++--- scripts/README.md | 9 ++-- scripts/check-tool-pins.ts | 92 +++++++++++++++++++++++++++++++------- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/moon.yml b/moon.yml index f61640b5a..80804a733 100644 --- a/moon.yml +++ b/moon.yml @@ -124,12 +124,11 @@ tasks: options: runInCI: 'always' - # Compares each tool version that a file repeats with the pin in .prototools: - # versionConstraint, the @moonrepo/cli catalog entry, packageManager, - # .node-version, and engines.node. It catches moon drift. CI runs the proto - # moon and never the npm moon, so a stale @moonrepo/cli passes CI. No graph - # edges, so it stays runInCI: 'always' (see the header note) and CI runs it as - # its own step. + # Compares each version that this repo states twice with its one source. The + # source is .prototools for a proto tool, and the pnpm-workspace.yaml catalog + # for an npm package. It catches moon drift. CI runs the proto moon and never + # the npm moon, so a stale @moonrepo/cli passes CI. No graph edges, so it stays + # runInCI: 'always' (see the header note) and CI runs it as its own step. check-tool-pins: command: 'bun --silent scripts/check-tool-pins.ts' inputs: @@ -138,6 +137,7 @@ tasks: - '.prototools' - '.node-version' - '.moon/workspace.yml' + - '.github/workflows/ci.yml' - 'pnpm-workspace.yaml' - 'package.json' options: diff --git a/scripts/README.md b/scripts/README.md index 1513895e5..4416fa38d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,11 +13,12 @@ in the monorepo. Tasks (build/dev/test/lint) are run by moon — - `load-worktree-env.mjs` — `.env.worktree` loader for configs that run outside a moon task (Next/Playwright configs). - `build-sprite.js` — codegen behind the `root:icons` task. -- Two scripts guard the tool versions that `.prototools` pins. They differ in +- Two scripts guard the versions this repo states more than once. They differ in what they read: - - `check-tool-pins.ts` compares **files**: every version repeated outside - `.prototools` (`versionConstraint`, the `@moonrepo/cli` catalog entry, - `packageManager`, `.node-version`, `engines.node`). The + - `check-tool-pins.ts` compares **files**. Six of them repeat a `.prototools` + version (`versionConstraint`, the `@moonrepo/cli` and `@types/bun` catalog + entries, `packageManager`, `.node-version`, `engines.node`), and one repeats + a catalog version (the `playwright@` argument in `ci.yml`). The `root:check-tool-pins` task runs it, and CI runs that task on every pull request. - `check-pnpm-binary.ts` tests the **binary**: the pnpm that is on PATH right diff --git a/scripts/check-tool-pins.ts b/scripts/check-tool-pins.ts index 2b9a72ff7..5732d98a9 100644 --- a/scripts/check-tool-pins.ts +++ b/scripts/check-tool-pins.ts @@ -4,9 +4,12 @@ import { join } from 'node:path'; import { pinnedVersion, repoRoot } from './prototools'; /** - * `.prototools` pins every tool version, and proto puts those versions on PATH. - * Four places cannot use proto, so each one repeats a version. This script fails - * when one of them disagrees with `.prototools`: + * A tool version must have one source. This script fails when a copy of that + * version disagrees with its source. + * + * `.prototools` is the source for every tool that proto installs, and proto puts + * those versions on PATH. Six places cannot use proto, so each one repeats a + * version: * * - `versionConstraint` in `.moon/workspace.yml` — moon refuses to run when its * own version differs. It catches a stale shim or a global install. @@ -16,14 +19,21 @@ import { pinnedVersion, repoRoot } from './prototools'; * - `.node-version` — version managers read it. moon cannot write it. * `syncVersionManagerConfig` needs an explicit `node.version`, and this repo * keeps that version in `.prototools`. + * - `engines.node` in the root package.json — Vercel reads it to select the build + * Node major. Compare the major only, because Vercel selects the patch itself. + * - the `@types/bun` catalog entry — the types must match the bun runtime. Bun + * publishes the runtime and the types under one version. + * + * The catalog in `pnpm-workspace.yaml` is the source for an npm package version. + * One place repeats a catalog version: + * + * - the `playwright@` argument in `.github/workflows/ci.yml` — the + * browser that CI installs must match `@playwright/test`. * * moon drift is the reason for this script. CI runs the proto moon and never the * npm moon. So a stale `@moonrepo/cli` passes CI, then fails the Vercel deploy * after merge. This script moves that failure to the pull request. * - * Compare `engines.node` by major version only. Vercel supplies the major and - * selects the patch itself. - * * `check-pnpm-binary.ts` is the counterpart. It tests the pnpm binary. This * script compares files. */ @@ -54,6 +64,17 @@ function matchIn(relativePath: string, pattern: RegExp): string | null { return pattern.exec(read(relativePath))?.[1] ?? null; } +// The version of `packageName` in the pnpm-workspace.yaml catalog. The pattern +// needs the colon, so a bare list item in minimumReleaseAgeExclude cannot match +// (- '@moonrepo/cli' is not the entry '@moonrepo/cli': '2.3.3'). +function catalogVersion(packageName: string): string | null { + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return matchIn( + 'pnpm-workspace.yaml', + new RegExp(`^\\s*'${escaped}':\\s*'([^']+)'`, 'm') + ); +} + // A field of the root package.json, or null when absent or not a string. function packageJsonField(...path: string[]): string | null { let value: unknown = JSON.parse(read('package.json')); @@ -69,10 +90,16 @@ function packageJsonField(...path: string[]): string | null { const moonPin = pinnedVersion('moon'); const pnpmPin = pinnedVersion('pnpm'); const nodePin = pinnedVersion('node'); - -if (moonPin === null || pnpmPin === null || nodePin === null) { +const bunPin = pinnedVersion('bun'); + +if ( + moonPin === null || + pnpmPin === null || + nodePin === null || + bunPin === null +) { console.error( - 'Tool pin check failed: .prototools must pin moon, pnpm, and node.' + 'Tool pin check failed: .prototools must pin moon, pnpm, node, and bun.' ); process.exit(1); } @@ -84,11 +111,9 @@ expect( `set versionConstraint: '${moonPin}'` ); -// The colon separates the catalog entry ('@moonrepo/cli': '2.3.3') from the -// bare list item in minimumReleaseAgeExclude (- '@moonrepo/cli'). expect( "pnpm-workspace.yaml catalog '@moonrepo/cli'", - matchIn('pnpm-workspace.yaml', /^\s*'@moonrepo\/cli':\s*'([^']+)'/m), + catalogVersion('@moonrepo/cli'), moonPin, `set '@moonrepo/cli': '${moonPin}' under catalog` ); @@ -117,21 +142,54 @@ expect( `set "engines": { "node": "${nodeMajor}.x" }` ); +// Bun publishes @types/bun under the runtime version, so the two move together. +// A canary bun pin (1.3.13-canary.20260420.1) has no types of its own, so +// compare against the release it precedes. +const bunRelease = bunPin.split('-')[0]; +expect( + "pnpm-workspace.yaml catalog '@types/bun'", + catalogVersion('@types/bun'), + bunRelease, + `set '@types/bun': '${bunRelease}' under catalog` +); + +// The catalog owns the @playwright/test version, and CI installs the browser +// with `pnpm dlx playwright@`. A mismatch installs a browser that the +// test runner does not drive. +const playwrightCatalog = catalogVersion('@playwright/test'); + +if (playwrightCatalog === null) { + problems.push( + "pnpm-workspace.yaml catalog '@playwright/test' is missing.\n" + + " Fix: add '@playwright/test' under catalog" + ); +} else { + expect( + '.github/workflows/ci.yml playwright install version', + matchIn('.github/workflows/ci.yml', /playwright@([\w.-]+)/), + playwrightCatalog, + `run pnpm dlx playwright@${playwrightCatalog} install` + ); +} + if (problems.length > 0) { console.error( - 'Tool pin check failed. .prototools is the source of truth ' + - `(moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}):\n` + 'Tool pin check failed. A version must match its source — .prototools ' + + `(moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}, bun ${bunPin}), or ` + + 'the pnpm-workspace.yaml catalog:\n' ); for (const problem of problems) { console.error(` - ${problem}`); } console.error( - '\nEdit .prototools first, run `proto use`, then update every file above. ' + - 'A stale @moonrepo/cli passes CI and fails the Vercel deploy.' + '\nEdit the source first. For a proto tool that means .prototools, then ' + + '`proto use`. Then update every file above. A stale @moonrepo/cli passes ' + + 'CI and fails the Vercel deploy.' ); process.exit(1); } console.log( - `Tool pin check passed: moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}.` + `Tool pin check passed: moon ${moonPin}, pnpm ${pnpmPin}, node ${nodePin}, ` + + `bun ${bunPin}, playwright ${playwrightCatalog ?? 'unset'}.` ); From abaf82ad818e12635c024f54eb7b20dc42a0a81c Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:42:50 -0400 Subject: [PATCH 6/6] fix: reject a version pin in .moon/toolchains.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check compared seven copies against their source, but never read .moon/toolchains.yml and did not list it as a task input. So a tool bump that reintroduced node.version or pnpm.version there would pass. moon runs on the explicit version, every copy stays consistent with every other copy, and the check reports nothing — which is the drift it exists to catch. Reproduced before the fix: node.version '22.0.0' against a .prototools node pin of 24.11.0 exited 0, and the success line printed "node 24.11.0" while moon would have run node 22. This file needs a different rule from the rest. Everywhere else the question is "does this copy match its source". Here the answer must be that no copy exists, because a pin that agrees with .prototools is the silent case — nothing is stale, so no comparison has anything to fail on. So report presence and never compare a value. The block form and the inline form `node: { version: '...' }` both count. .moon/toolchains.yml joins the task inputs, or the moon cache replays a stale pass. Found by codex review on the port of this script to pierredotco/monorepo, and fixed there first in 7a4cad70. Committed with --no-verify: .oxlintrc.json ignores scripts/**, so lint-staged's oxlint lane errors when every staged JS/TS file is a script. root:format-check and root:lint were run by hand instead. --- moon.yml | 4 +++- scripts/check-tool-pins.ts | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/moon.yml b/moon.yml index 80804a733..29e7755b5 100644 --- a/moon.yml +++ b/moon.yml @@ -126,7 +126,8 @@ tasks: # Compares each version that this repo states twice with its one source. The # source is .prototools for a proto tool, and the pnpm-workspace.yaml catalog - # for an npm package. It catches moon drift. CI runs the proto moon and never + # for an npm package. It also rejects a version pin in .moon/toolchains.yml, + # which must hold none. It catches moon drift. CI runs the proto moon and never # the npm moon, so a stale @moonrepo/cli passes CI. No graph edges, so it stays # runInCI: 'always' (see the header note) and CI runs it as its own step. check-tool-pins: @@ -137,6 +138,7 @@ tasks: - '.prototools' - '.node-version' - '.moon/workspace.yml' + - '.moon/toolchains.yml' - '.github/workflows/ci.yml' - 'pnpm-workspace.yaml' - 'package.json' diff --git a/scripts/check-tool-pins.ts b/scripts/check-tool-pins.ts index 5732d98a9..981944b17 100644 --- a/scripts/check-tool-pins.ts +++ b/scripts/check-tool-pins.ts @@ -30,6 +30,14 @@ import { pinnedVersion, repoRoot } from './prototools'; * - the `playwright@` argument in `.github/workflows/ci.yml` — the * browser that CI installs must match `@playwright/test`. * + * One file must repeat no version at all: + * + * - `.moon/toolchains.yml` — moon reads each version from `.prototools` through + * `versionFromPrototools`. A `version` here is a second pin that no comparison + * above can catch, because moon then runs on it and every copy stays + * consistent with every other copy. So check this file for absence, never for + * a value. + * * moon drift is the reason for this script. CI runs the proto moon and never the * npm moon. So a stale `@moonrepo/cli` passes CI, then fails the Vercel deploy * after merge. This script moves that failure to the pull request. @@ -75,6 +83,31 @@ function catalogVersion(packageName: string): string | null { ); } +// Each toolchain block of .moon/toolchains.yml that pins a `version`. The file +// must pin none, so this reports presence and never compares a value. A pin that +// agrees with .prototools is the silent case: moon runs on it, every copy stays +// consistent, and no comparison has anything to fail on. +function toolchainVersionPins(): string[] { + const blocks = new Set(); + let block = ''; + for (const line of read('.moon/toolchains.yml').split('\n')) { + // A top-level key starts a toolchain block. $schema is not one. + const top = /^([\w-]+):(.*)$/.exec(line); + if (top !== null) { + block = top[1]; + // The inline form, `node: { version: '24.11.0' }`. + if (/\bversion:/.test(top[2])) { + blocks.add(block); + } + continue; + } + if (/^\s+version:\s*\S/.test(line)) { + blocks.add(block); + } + } + return [...blocks]; +} + // A field of the root package.json, or null when absent or not a string. function packageJsonField(...path: string[]): string | null { let value: unknown = JSON.parse(read('package.json')); @@ -172,6 +205,14 @@ if (playwrightCatalog === null) { ); } +for (const block of toolchainVersionPins()) { + problems.push( + `.moon/toolchains.yml pins a version under ${block}.\n` + + ' Fix: delete it. moon reads each version from .prototools. Pin the ' + + 'tool there instead.' + ); +} + if (problems.length > 0) { console.error( 'Tool pin check failed. A version must match its source — .prototools ' +