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
22 changes: 19 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions scripts/check-release-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions scripts/lib/npm-view-output.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 4 additions & 2 deletions scripts/prove-mcp-published.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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'),
Expand Down
21 changes: 19 additions & 2 deletions scripts/prove-published-release.mjs
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions tests/npm-view-output.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading