diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a371b84..68d17b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -320,7 +320,7 @@ jobs: while IFS=$'\t' read -r package_name version integrity filename; do registry_response='' if registry_response="$(npm view "$package_name@$version" dist.integrity --json 2>&1)"; then - live_integrity="$(node -e 'const v=JSON.parse(process.argv[1]);if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" + live_integrity="$(node -e 'const parsed=JSON.parse(process.argv[1]);const v=Array.isArray(parsed)?(parsed.length===1?parsed[0]:undefined):parsed;if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" test "$live_integrity" = "$integrity" preexisting_exact["$package_name"]=1 existing_exact_count=$((existing_exact_count + 1)) @@ -346,7 +346,7 @@ jobs: publish_converged=false for attempt in {1..3}; do if registry_response="$(npm view "$package_name@$version" dist.integrity --json 2>&1)"; then - live_integrity="$(node -e 'const v=JSON.parse(process.argv[1]);if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" + live_integrity="$(node -e 'const parsed=JSON.parse(process.argv[1]);const v=Array.isArray(parsed)?(parsed.length===1?parsed[0]:undefined):parsed;if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" if [[ "$live_integrity" != "$integrity" ]]; then printf 'npm integrity conflict for %s@%s\n' "$package_name" "$version" >&2 exit 1 @@ -370,7 +370,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ needs.authorize-release.outputs.reviewed-commit }} + ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -382,11 +382,27 @@ jobs: npm install --global npm@12.0.1 npm --version npm ci --ignore-scripts + - name: Load the exact reviewed release manifest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_RELEASE_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + EXPECTED_RELEASE_MANIFEST_PATH: ${{ runner.temp }}/release-manifest.json + EXPECTED_RELEASE_MANIFEST_SHA512: ${{ needs.authorize-release.outputs.manifest-sha512 }} + run: | + set -euo pipefail + gh api \ + --header 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/release-manifest.json?ref=$EXPECTED_RELEASE_COMMIT" \ + > "$EXPECTED_RELEASE_MANIFEST_PATH" + test "$(sha512sum "$EXPECTED_RELEASE_MANIFEST_PATH" | cut -d' ' -f1)" = "$EXPECTED_RELEASE_MANIFEST_SHA512" - name: Prove all exact live npm artifacts shell: bash env: EXPECTED_RELEASE_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} EXPECTED_RELEASE_INTEGRITIES: ${{ needs.build-release-artifacts.outputs.integrities }} + EXPECTED_RELEASE_MANIFEST_PATH: ${{ runner.temp }}/release-manifest.json + EXPECTED_RELEASE_MANIFEST_SHA512: ${{ needs.authorize-release.outputs.manifest-sha512 }} EXPECTED_MCP_VERSION: ${{ inputs.expected_version }} EXPECTED_MCP_INTEGRITY: ${{ needs.build-release-artifacts.outputs.mcp-integrity }} EXPECTED_GIT_HEAD: ${{ needs.authorize-release.outputs.reviewed-commit }} diff --git a/package.json b/package.json index 0120700..24432bf 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 tests/npm-pack-output.test.mjs", + "test:release-supply-chain": "node --test tests/release-supply-chain.test.mjs tests/npm-pack-output.test.mjs tests/npm-view-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 ff2e4de..83896d9 100644 --- a/scripts/check-release-workflows.mjs +++ b/scripts/check-release-workflows.mjs @@ -245,6 +245,11 @@ assert.match( /test "\$live_integrity" = "\$integrity"[\s\S]*E404/, 'preflight must reject every integrity mismatch and distinguish only an absent version', ); +assert.equal( + [...publishJob.matchAll(/Array\.isArray\(parsed\)/g)].length, + 2, + 'both npm integrity reads must normalize npm 12 exact-version arrays', +); assert.match( publishJob.slice(privilegedConditionalMainGate, privilegedPublishLoop), /if \[\[ "\$existing_exact_count" -eq 0 \]\]; then[\s\S]*test "\$REMOTE_MAIN" = "\$EXPECTED_COMMIT"[\s\S]*fi/, @@ -358,6 +363,23 @@ assert.match( 'the npm proof job must use explicit contents-read-only permissions', ); assert.doesNotMatch(proofJob, /^ id-token:\s+write$/m, 'npm proof must not mint OIDC'); +assert.match( + proofJob, + /ref:\s+\$\{\{ github\.sha \}\}/, + 'partial-release recovery must use the protected dispatch commit for repaired proof tooling', +); +assert.match( + proofJob, + /contents\/release-manifest\.json\?ref=\$EXPECTED_RELEASE_COMMIT/, + 'partial-release recovery must reload the release manifest from the reviewed artifact commit', +); +for (const variable of ['EXPECTED_RELEASE_MANIFEST_PATH', 'EXPECTED_RELEASE_MANIFEST_SHA512']) { + assert.match( + proofJob, + new RegExp(`^ ${variable}:`, 'm'), + `the npm proof job must bind ${variable}`, + ); +} assert.match(release, /node-version:\s+24/, 'release must use Node 24'); assert.match(release, /npm@12\.0\.1/, 'release must pin the reviewed npm CLI'); assert.match( diff --git a/scripts/lib/npm-view-output.mjs b/scripts/lib/npm-view-output.mjs new file mode 100644 index 0000000..0515574 --- /dev/null +++ b/scripts/lib/npm-view-output.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; + +function parseSingleNpmViewResult(output, message) { + const parsed = JSON.parse(output); + if (!Array.isArray(parsed)) { + return parsed; + } + assert.equal(parsed.length, 1, message); + return parsed[0]; +} + +export function parseNpmViewValue(output, label) { + return parseSingleNpmViewResult(output, `Expected one npm view value for ${label}`); +} + +export function parseNpmViewMetadata(output, expectedName) { + const metadata = parseSingleNpmViewResult( + output, + `Expected one npm view result for ${expectedName}`, + ); + assert( + metadata && typeof metadata === 'object' && !Array.isArray(metadata), + `Expected one npm view result for ${expectedName}`, + ); + assert.equal(metadata.name, expectedName, `Viewed the wrong package for ${expectedName}`); + return metadata; +} diff --git a/scripts/prove-mcp-published.mjs b/scripts/prove-mcp-published.mjs index 2d53ccf..0d684be 100644 --- a/scripts/prove-mcp-published.mjs +++ b/scripts/prove-mcp-published.mjs @@ -8,6 +8,8 @@ import { promisify } from 'node:util'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { parseNpmViewValue } from './lib/npm-view-output.mjs'; + const execFileAsync = promisify(execFile); const expectedVersion = process.env.EXPECTED_MCP_VERSION?.trim(); const expectedIntegrity = process.env.EXPECTED_MCP_INTEGRITY?.trim(); @@ -119,7 +121,7 @@ try { ['view', '@buzzr/mcp', 'dist-tags.latest', '--json'], { cwd: temporaryRoot, env: environment }, ); - const latest = JSON.parse(latestOutput); + const latest = parseNpmViewValue(latestOutput, 'dist-tags.latest'); assert.equal( latest, expectedVersion, @@ -132,7 +134,7 @@ try { env: environment, }); assert(stdout.trim(), `Published package has no ${field} metadata`); - return JSON.parse(stdout); + return parseNpmViewValue(stdout, field); }; const [integrity, gitHead, tarball, provenance] = await Promise.all([ viewField('dist.integrity'), diff --git a/scripts/prove-published-release.mjs b/scripts/prove-published-release.mjs index 72c0b73..26c08a6 100644 --- a/scripts/prove-published-release.mjs +++ b/scripts/prove-published-release.mjs @@ -1,17 +1,34 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { parseNpmViewMetadata } from './lib/npm-view-output.mjs'; + const execFileAsync = promisify(execFile); const expectedCommit = process.env.EXPECTED_RELEASE_COMMIT?.trim(); const expectedIntegritiesJson = process.env.EXPECTED_RELEASE_INTEGRITIES?.trim(); +const releaseManifestPath = process.env.EXPECTED_RELEASE_MANIFEST_PATH?.trim(); +const expectedReleaseManifestSha512 = process.env.EXPECTED_RELEASE_MANIFEST_SHA512?.trim(); assert.match(expectedCommit ?? '', /^[0-9a-f]{40}$/, 'EXPECTED_RELEASE_COMMIT is required'); assert(expectedIntegritiesJson, 'EXPECTED_RELEASE_INTEGRITIES is required'); +assert(releaseManifestPath, 'EXPECTED_RELEASE_MANIFEST_PATH is required'); +assert.match( + expectedReleaseManifestSha512 ?? '', + /^[0-9a-f]{128}$/, + 'EXPECTED_RELEASE_MANIFEST_SHA512 is required', +); -const release = JSON.parse(await readFile('release-manifest.json', 'utf8')); +const releaseManifest = await readFile(releaseManifestPath); +assert.equal( + createHash('sha512').update(releaseManifest).digest('hex'), + expectedReleaseManifestSha512, + 'release manifest changed after authorization', +); +const release = JSON.parse(releaseManifest.toString('utf8')); const packages = release.packages.filter((entry) => entry.publish); const expectedIntegrities = JSON.parse(expectedIntegritiesJson); assert.deepEqual( @@ -82,7 +99,7 @@ async function provePackage(entry, environment, cwd) { cwd, env: environment, }); - const metadata = JSON.parse(stdout); + const metadata = parseNpmViewMetadata(stdout, entry.name); const expectedIntegrity = expectedIntegrities[entry.name]; assert.equal(metadata.name, entry.name); assert.equal(metadata.version, entry.version); diff --git a/tests/npm-view-output.test.mjs b/tests/npm-view-output.test.mjs new file mode 100644 index 0000000..5656bd9 --- /dev/null +++ b/tests/npm-view-output.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { parseNpmViewMetadata, parseNpmViewValue } from '../scripts/lib/npm-view-output.mjs'; + +const metadata = { + name: '@buzzr/dfs-cli', + version: '5.0.1', + gitHead: 'c26ad054f999ea726eb29c93d75e5a0dd8a7ea7e', + dist: { integrity: 'sha512-example' }, +}; + +test('parses the npm 10 and npm 11 metadata object', () => { + assert.deepEqual(parseNpmViewMetadata(JSON.stringify(metadata), '@buzzr/dfs-cli'), metadata); +}); + +test('normalizes the npm 12 exact-version metadata array', () => { + assert.deepEqual(parseNpmViewMetadata(JSON.stringify([metadata]), '@buzzr/dfs-cli'), metadata); +}); + +test('rejects an empty metadata array', () => { + assert.throws( + () => parseNpmViewMetadata('[]', '@buzzr/dfs-cli'), + /Expected one npm view result for @buzzr\/dfs-cli/, + ); +}); + +test('rejects multiple metadata entries', () => { + assert.throws( + () => parseNpmViewMetadata(JSON.stringify([metadata, metadata]), '@buzzr/dfs-cli'), + /Expected one npm view result for @buzzr\/dfs-cli/, + ); +}); + +test('rejects metadata for a different package', () => { + const output = JSON.stringify([{ ...metadata, name: '@buzzr/dfs-engine' }]); + assert.throws( + () => parseNpmViewMetadata(output, '@buzzr/dfs-cli'), + /Viewed the wrong package for @buzzr\/dfs-cli/, + ); +}); + +test('parses npm 10 and npm 11 scalar field output', () => { + assert.equal(parseNpmViewValue(JSON.stringify('5.1.0'), 'dist-tags.latest'), '5.1.0'); +}); + +test('normalizes npm 12 exact-version field output', () => { + const provenance = { predicateType: 'https://slsa.dev/provenance/v1' }; + assert.deepEqual( + parseNpmViewValue(JSON.stringify([provenance]), 'dist.attestations.provenance'), + provenance, + ); +}); + +test('rejects empty and multiple field results', () => { + assert.throws( + () => parseNpmViewValue('[]', 'dist.integrity'), + /Expected one npm view value for dist\.integrity/, + ); + assert.throws( + () => parseNpmViewValue('["first","second"]', 'dist.integrity'), + /Expected one npm view value for dist\.integrity/, + ); +});