diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b18fcd..0b679f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm + - if: matrix.node == 24 + run: npm install --global npm@12.0.1 - run: npm ci - run: npm run typecheck - run: npm run lint @@ -32,8 +34,7 @@ jobs: - run: npm run test:coverage - run: npm run build - run: npm run test:mcp:packed - - if: matrix.node == 22 - run: npm run test:packages:packed + - run: npm run test:packages:packed - run: npm run check:mcp:client-skill - run: npm run check:mcp:examples - run: npm run check:skill diff --git a/package.json b/package.json index bb334c0..0120700 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "size:check": "node scripts/check-package-size.mjs", "audit:high": "npm audit --audit-level=high", "check:workflows": "node scripts/check-release-workflows.mjs", - "test:release-supply-chain": "node --test tests/release-supply-chain.test.mjs", + "test:release-supply-chain": "node --test tests/release-supply-chain.test.mjs tests/npm-pack-output.test.mjs", "check:mcp-registry": "node scripts/check-mcp-registry-metadata.mjs", "check:api-docs": "node scripts/check-api-docs.mjs", "check:docs": "node scripts/check-public-docs.mjs", diff --git a/scripts/check-release-workflows.mjs b/scripts/check-release-workflows.mjs index 34f7013..ff2e4de 100644 --- a/scripts/check-release-workflows.mjs +++ b/scripts/check-release-workflows.mjs @@ -45,6 +45,16 @@ for (const command of [ ]) { assert.ok(ci.includes(`run: ${command}`), `CI must run ${command}`); } +assert.match( + ci, + /- if: matrix\.node == 24\n\s+run: npm install --global npm@12\.0\.1/, + 'Node 24 CI must use the exact npm CLI pinned by the release workflow', +); +assert.match( + ci, + /- run: npm run test:packages:packed/, + 'Every supported Node CI job must exercise the complete packed-package suite', +); const proof = workflows.find(({ path }) => path.endsWith('/prove-mcp-published.yml'))?.body ?? ''; for (const input of ['expected_version', 'expected_integrity', 'expected_git_head']) { diff --git a/scripts/lib/npm-pack-output.mjs b/scripts/lib/npm-pack-output.mjs new file mode 100644 index 0000000..10c347e --- /dev/null +++ b/scripts/lib/npm-pack-output.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; + +function parseJsonTail(output) { + const offsets = [0]; + for (let index = 0; index < output.length; index += 1) { + if (output[index] === '\n') { + offsets.push(index + 1); + } + } + + for (const offset of offsets.reverse()) { + const candidate = output.slice(offset).trim(); + if (!candidate.startsWith('[') && !candidate.startsWith('{')) { + continue; + } + try { + return JSON.parse(candidate); + } catch { + // Lifecycle output may contain earlier JSON-looking lines. Keep searching for the final result. + } + } + return undefined; +} + +export function parseNpmPackArtifacts(output, workspace) { + const parsed = parseJsonTail(output); + assert.notEqual(parsed, undefined, `Could not parse npm pack JSON output for ${workspace}`); + + let artifacts; + if (Array.isArray(parsed)) { + artifacts = parsed; + } else { + assert( + parsed && typeof parsed === 'object' && Object.hasOwn(parsed, workspace), + `Expected npm pack output for ${workspace}`, + ); + assert.equal(Object.keys(parsed).length, 1, `Expected one packed artifact for ${workspace}`); + artifacts = [parsed[workspace]]; + } + + assert.equal(artifacts.length, 1, `Expected one packed artifact for ${workspace}`); + assert( + artifacts[0] && typeof artifacts[0] === 'object', + `Expected one packed artifact for ${workspace}`, + ); + assert.equal(artifacts[0].name, workspace, `Packed the wrong workspace for ${workspace}`); + return artifacts; +} diff --git a/scripts/pack-release-artifacts.mjs b/scripts/pack-release-artifacts.mjs index 2c27994..bb5cb39 100644 --- a/scripts/pack-release-artifacts.mjs +++ b/scripts/pack-release-artifacts.mjs @@ -5,6 +5,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { parseNpmPackArtifacts } from './lib/npm-pack-output.mjs'; + const execFileAsync = promisify(execFile); const argumentsByName = new Map(); for (let index = 2; index < process.argv.length; index += 2) { @@ -47,9 +49,7 @@ async function pack(entry) { const { stdout } = await execFileAsync(command.command, command.args, { maxBuffer: 20 * 1_024 * 1_024, }); - const jsonStart = stdout.lastIndexOf('\n['); - const packed = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); - assert.equal(packed.length, 1, `expected one artifact for ${entry.name}`); + const packed = parseNpmPackArtifacts(stdout, entry.name); const artifact = packed[0]; assert.equal(artifact.name, entry.name, `packed the wrong workspace for ${entry.name}`); assert.equal(artifact.version, entry.version, `${entry.name} packed at the wrong version`); diff --git a/scripts/test-mcp-packed.mjs b/scripts/test-mcp-packed.mjs index ed5e5a5..0ae5d03 100644 --- a/scripts/test-mcp-packed.mjs +++ b/scripts/test-mcp-packed.mjs @@ -12,6 +12,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { JSONRPCMessageSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; +import { parseNpmPackArtifacts } from './lib/npm-pack-output.mjs'; + const execFileAsync = promisify(execFile); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const workspaces = [ @@ -91,9 +93,7 @@ async function packWorkspace(workspace, destination, cache) { ['pack', '--workspace', workspace, '--json', '--pack-destination', destination], { cwd: root, env: commandEnvironment(cache), maxBuffer: 10 * 1024 * 1024 }, ); - const jsonStart = stdout.lastIndexOf('\n['); - const results = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); - assert.equal(results.length, 1, `Expected one packed artifact for ${workspace}`); + const results = parseNpmPackArtifacts(stdout, workspace); return results[0]; } diff --git a/scripts/test-release-artifacts.mjs b/scripts/test-release-artifacts.mjs index 4149c05..2c2b2aa 100644 --- a/scripts/test-release-artifacts.mjs +++ b/scripts/test-release-artifacts.mjs @@ -7,6 +7,8 @@ import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; +import { parseNpmPackArtifacts } from './lib/npm-pack-output.mjs'; + const execFileAsync = promisify(execFile); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packages = [ @@ -67,9 +69,7 @@ async function packWorkspace(packageName, destination, environment) { ['pack', '--workspace', packageName, '--json', '--pack-destination', destination], { env: environment }, ); - const jsonStart = stdout.lastIndexOf('\n['); - const result = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); - assert.equal(result.length, 1, `Expected one packed artifact for ${packageName}`); + const result = parseNpmPackArtifacts(stdout, packageName); assert.equal(result[0].name, packageName, `Packed the wrong workspace for ${packageName}`); assert( result[0].files.some((file) => file.path === 'package.json'), diff --git a/tests/npm-pack-output.test.mjs b/tests/npm-pack-output.test.mjs new file mode 100644 index 0000000..3fd5bd7 --- /dev/null +++ b/tests/npm-pack-output.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { parseNpmPackArtifacts } from '../scripts/lib/npm-pack-output.mjs'; + +const artifact = { + name: '@buzzr/mcp', + version: '5.1.0', + filename: 'buzzr-mcp-5.1.0.tgz', + files: [{ path: 'package.json', mode: 0o644 }], +}; + +test('parses the npm 10 and npm 11 array response after lifecycle output', () => { + const output = `build completed\n${JSON.stringify([artifact], null, 2)}\n`; + assert.deepEqual(parseNpmPackArtifacts(output, '@buzzr/mcp'), [artifact]); +}); + +test('normalizes the npm 12 workspace-keyed response', () => { + const output = JSON.stringify({ '@buzzr/mcp': artifact }, null, 2); + assert.deepEqual(parseNpmPackArtifacts(output, '@buzzr/mcp'), [artifact]); +}); + +test('rejects a keyed response for a different workspace', () => { + const output = JSON.stringify({ '@buzzr/dfs-engine': artifact }); + assert.throws( + () => parseNpmPackArtifacts(output, '@buzzr/mcp'), + /Expected npm pack output for @buzzr\/mcp/, + ); +}); + +test('rejects an array response for a different workspace', () => { + const output = JSON.stringify([{ ...artifact, name: '@buzzr/dfs-engine' }]); + assert.throws( + () => parseNpmPackArtifacts(output, '@buzzr/mcp'), + /Packed the wrong workspace for @buzzr\/mcp/, + ); +}); + +test('rejects multiple packed artifacts', () => { + const output = JSON.stringify([artifact, { ...artifact, name: '@buzzr/dfs-engine' }]); + assert.throws( + () => parseNpmPackArtifacts(output, '@buzzr/mcp'), + /Expected one packed artifact for @buzzr\/mcp/, + ); +}); + +test('rejects output without a JSON result', () => { + assert.throws( + () => parseNpmPackArtifacts('npm lifecycle output only', '@buzzr/mcp'), + /Could not parse npm pack JSON output for @buzzr\/mcp/, + ); +});