Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
28 changes: 28 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
89 changes: 89 additions & 0 deletions scripts/check-pnpm-version.mjs
Original file line number Diff line number Diff line change
@@ -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})`,
);
}
}
82 changes: 82 additions & 0 deletions scripts/check-pnpm-version.test.mjs
Original file line number Diff line number Diff line change
@@ -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), []);
});
Loading