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/.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/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/.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/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. diff --git a/moon.yml b/moon.yml index 270440d03..29e7755b5 100644 --- a/moon.yml +++ b/moon.yml @@ -124,6 +124,27 @@ tasks: options: runInCI: 'always' + # 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 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: + command: 'bun --silent scripts/check-tool-pins.ts' + inputs: + - 'scripts/check-tool-pins.ts' + - 'scripts/prototools.ts' + - '.prototools' + - '.node-version' + - '.moon/workspace.yml' + - '.moon/toolchains.yml' + - '.github/workflows/ci.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/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" } 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..4416fa38d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,8 +12,19 @@ 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` — codegen behind the `root:icons` task. +- Two scripts guard the versions this repo states more than once. They differ in + what they read: + - `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 + 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/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/check-tool-pins.ts b/scripts/check-tool-pins.ts new file mode 100644 index 000000000..981944b17 --- /dev/null +++ b/scripts/check-tool-pins.ts @@ -0,0 +1,236 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { pinnedVersion, repoRoot } from './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. + * - 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`. + * - `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`. + * + * 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. + * + * `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; +} + +// 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') + ); +} + +// 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')); + 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'); +const bunPin = pinnedVersion('bun'); + +if ( + moonPin === null || + pnpmPin === null || + nodePin === null || + bunPin === null +) { + console.error( + 'Tool pin check failed: .prototools must pin moon, pnpm, node, and bun.' + ); + process.exit(1); +} + +expect( + '.moon/workspace.yml versionConstraint', + matchIn('.moon/workspace.yml', /^versionConstraint:\s*'?([^'\s#]+)/m), + moonPin, + `set versionConstraint: '${moonPin}'` +); + +expect( + "pnpm-workspace.yaml catalog '@moonrepo/cli'", + catalogVersion('@moonrepo/cli'), + 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" }` +); + +// 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` + ); +} + +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 ' + + `(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 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}, ` + + `bun ${bunPin}, playwright ${playwrightCatalog ?? 'unset'}.` +); 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; +}