diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1ea4a3d..13892e4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -38,6 +38,8 @@ jobs: - name: Validate error normalization run: pnpm errors:normalize:test && pnpm errors:normalize:check + - name: Validate workflow pnpm version matches packageManager + run: pnpm ci:pnpm-version:test && pnpm ci:pnpm-version:check - name: Mainnet readiness matches the committed manifest (recorded fixture, no RPC, no secrets) run: | diff --git a/docs/CI.md b/docs/CI.md index b9a7295..a5a3f82 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -60,6 +60,34 @@ ratio after including unexecuted `src/` files. Lower the threshold only when the existing codebase cannot meet a higher bar yet; prefer adding tests instead of weakening the gate. +## pnpm version manifest check + +CI fails if any GitHub Actions workflow's `pnpm/action-setup` step declares +a different pnpm version than the root `package.json` `packageManager` field +(currently `pnpm@10.13.1`). This keeps every workflow on the exact pnpm +version developers and the committed lockfile use. + +### Run locally + +```bash +pnpm ci:pnpm-version:test # unit tests for the checker itself +pnpm ci:pnpm-version:check # scans .github/workflows/*.yml +``` + +Checker source: `scripts/check-pnpm-version.mjs` (tests in +`scripts/check-pnpm-version.test.mjs`). + +### CI workflow + +Wired into `.github/workflows/coverage.yml` (Node-only, runs on every push +and pull request to `main`). + +### Changing the pnpm version + +Bump `packageManager` in `package.json` and every `pnpm/action-setup` +`version:` in `.github/workflows/*.yml` together in the same PR; the check +fails loudly if they drift apart. + ## Bindings worktree cleanliness gate After `pnpm bindings:check` regenerates the round contract's TypeScript diff --git a/package.json b/package.json index 66e5b7c..dfcb0bc 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,8 @@ "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs", "errors:normalize:check": "node scripts/check-error-normalization.mjs", "errors:normalize:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-error-normalization.test.mjs", + "ci:pnpm-version:check": "node scripts/check-pnpm-version.mjs", + "ci:pnpm-version:test": "node --test scripts/check-pnpm-version.test.mjs", "line-lengths:check": "node scripts/check-line-lengths.mjs", "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs" } diff --git a/scripts/check-pnpm-version.mjs b/scripts/check-pnpm-version.mjs new file mode 100644 index 0000000..29ab8dd --- /dev/null +++ b/scripts/check-pnpm-version.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Verify every GitHub Actions workflow's `pnpm/action-setup` version matches + * the root `package.json` `packageManager` field, so CI never silently runs + * a different pnpm version than the one developers and lockfiles use. + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createLogger } from '../packages/logging/src/index.cjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const logger = createLogger('scripts.check-pnpm-version'); +const WORKFLOWS_DIR = '.github/workflows'; + +/** + * @param {string} packageJsonText + * @returns {string} the pnpm version declared by `packageManager`, e.g. "10.13.1" + */ +export function parsePackageManagerVersion(packageJsonText) { + const pkg = JSON.parse(packageJsonText); + const packageManager = pkg.packageManager; + if (typeof packageManager !== 'string') { + throw new Error('package.json is missing a "packageManager" field'); + } + const match = packageManager.match(/^pnpm@([0-9][0-9A-Za-z.+-]*)$/); + if (!match) { + throw new Error(`Unrecognized packageManager format: ${packageManager}`); + } + return match[1]; +} + +/** + * @typedef {{ file: string, version: string }} WorkflowPnpmVersion + * @param {string} workflowDir + * @param {(path: string) => string} readFile + * @param {(path: string) => string[]} listDir + * @returns {WorkflowPnpmVersion[]} + */ +export function findWorkflowPnpmVersions( + workflowDir, + readFile = (path) => readFileSync(path, 'utf8'), + listDir = (path) => readdirSync(path), +) { + const results = []; + for (const name of listDir(workflowDir)) { + if (!/\.ya?ml$/.test(name)) continue; + const text = readFile(join(workflowDir, name)); + const stepPattern = /uses:\s*pnpm\/action-setup@v\d+[\s\S]{0,120}?version:\s*["']?([^"'\s]+)["']?/g; + for (const match of text.matchAll(stepPattern)) { + results.push({ file: name, version: match[1] }); + } + } + return results; +} + +/** + * @param {string} expectedVersion + * @param {WorkflowPnpmVersion[]} entries + * @returns {WorkflowPnpmVersion[]} + */ +export function findMismatches(expectedVersion, entries) { + return entries.filter((entry) => entry.version !== expectedVersion); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const expected = parsePackageManagerVersion(readFileSync(join(root, 'package.json'), 'utf8')); + const entries = findWorkflowPnpmVersions(join(root, WORKFLOWS_DIR)); + const mismatches = findMismatches(expected, entries); + + if (entries.length === 0) { + logger.error('pnpm-version-missing', 'No pnpm/action-setup steps found in .github/workflows', { + workflowsDir: WORKFLOWS_DIR, + }); + process.exitCode = 1; + } else if (mismatches.length > 0) { + logger.error( + 'pnpm-version-mismatch', + `Workflow pnpm/action-setup version must match package.json packageManager (pnpm@${expected})`, + { expected, mismatches }, + ); + process.exitCode = 1; + } else { + logger.info( + 'pnpm-version-check-passed', + `All ${entries.length} workflow pnpm/action-setup version(s) match packageManager (pnpm@${expected})`, + ); + } +} diff --git a/scripts/check-pnpm-version.test.mjs b/scripts/check-pnpm-version.test.mjs new file mode 100644 index 0000000..c9917c1 --- /dev/null +++ b/scripts/check-pnpm-version.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parsePackageManagerVersion, + findWorkflowPnpmVersions, + findMismatches, +} from './check-pnpm-version.mjs'; + +test('parsePackageManagerVersion extracts the version from a pnpm@x.y.z string', () => { + const pkg = JSON.stringify({ packageManager: 'pnpm@10.13.1' }); + assert.equal(parsePackageManagerVersion(pkg), '10.13.1'); +}); + +test('parsePackageManagerVersion throws when packageManager is missing', () => { + assert.throws(() => parsePackageManagerVersion(JSON.stringify({})), /missing/); +}); + +test('parsePackageManagerVersion throws for a non-pnpm packageManager', () => { + assert.throws( + () => parsePackageManagerVersion(JSON.stringify({ packageManager: 'yarn@4.0.0' })), + /Unrecognized packageManager format/, + ); +}); + +test('findWorkflowPnpmVersions reads the version from each workflow file', () => { + const files = { + 'a.yml': [ + 'jobs:', + ' build:', + ' steps:', + ' - uses: pnpm/action-setup@v4', + ' with:', + ' version: 10.13.1', + ].join('\n'), + 'b.yml': [ + 'jobs:', + ' test:', + ' steps:', + " - uses: pnpm/action-setup@v4", + ' with:', + ' version: "9.0.0"', + ].join('\n'), + 'not-a-workflow.txt': 'irrelevant', + }; + const result = findWorkflowPnpmVersions( + '.github/workflows', + (path) => files[path.split('/').pop()], + () => Object.keys(files), + ); + + assert.deepEqual(result, [ + { file: 'a.yml', version: '10.13.1' }, + { file: 'b.yml', version: '9.0.0' }, + ]); +}); + +test('findWorkflowPnpmVersions ignores workflows with no pnpm/action-setup step', () => { + const files = { 'rust-only.yml': 'jobs:\n build:\n steps:\n - uses: actions/checkout@v4\n' }; + const result = findWorkflowPnpmVersions( + '.github/workflows', + (path) => files[path.split('/').pop()], + () => Object.keys(files), + ); + assert.deepEqual(result, []); +}); + +test('findMismatches returns only entries that differ from the expected version', () => { + const entries = [ + { file: 'a.yml', version: '10.13.1' }, + { file: 'b.yml', version: '9.0.0' }, + { file: 'c.yml', version: '10.13.1' }, + ]; + assert.deepEqual(findMismatches('10.13.1', entries), [{ file: 'b.yml', version: '9.0.0' }]); +}); + +test('findMismatches returns an empty array when every entry matches', () => { + const entries = [ + { file: 'a.yml', version: '10.13.1' }, + { file: 'b.yml', version: '10.13.1' }, + ]; + assert.deepEqual(findMismatches('10.13.1', entries), []); +});