fix(release): isolate npm publication from dependency execution (#6681)

Keep dependency execution in read-only release jobs and validate the original qualified archive with Node built-ins before publication. Refresh registry state on every publisher attempt; retain exact bytes/channel verification, v1 read-only recovery, and installed CLI/MCP checks before MCP publication.

Static/docs checks, artifact/registry/recovery tests and real package/rehearsal verification pass. All four hosted package platforms pass. Portable CI retains the independently reproduced main PTX failure in #6675 and fail-fast missing coverage; use the owner-authorized existing PR-only exception, with limitations documented in the PR. Refs #6480.
This commit is contained in:
gensokyo-dev
2026-10-23 20:27:24 +08:00
committed by GitHub
parent 8c4f96ef47
commit 5d6b8a0ab6
16 changed files with 780 additions and 364 deletions
+47 -23
View File
@@ -315,6 +315,9 @@ jobs:
rehearsal:
name: Rehearse qualified release on a clean runner
env:
TURBO_TOKEN: ""
TURBO_CACHE: local:rw
needs: [merged-release, release-please, qualification]
if: >-
always() && !cancelled() && needs.qualification.result == 'success'
@@ -348,6 +351,9 @@ jobs:
always() && !cancelled() &&
needs.qualification.result == 'success' && needs.rehearsal.result == 'success'
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ""
TURBO_CACHE: local:rw
permissions:
contents: read
id-token: write
@@ -356,41 +362,56 @@ jobs:
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
registry-url: https://registry.npmjs.org
- name: Download the original qualified package
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: verified-release-package
path: .cache/verified-package/
- name: Bind publication to qualified intent
id: package
run: node scripts/ci/prepare-npm-publication.mjs
- name: Publish
if: steps.package.outputs.published == 'false'
env:
NPM_RELEASE_TAG: ${{ steps.package.outputs.tag }}
run: >-
npm publish .cache/verified-package/package.tgz --ignore-scripts
--registry=https://registry.npmjs.org --access=public --tag="$NPM_RELEASE_TAG"
verify-npm:
name: Verify published npm package
needs: [merged-release, release-please, publish]
if: >-
always() && !cancelled() && needs.publish.result == 'success'
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ""
TURBO_CACHE: local:rw
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
- run: npm ci
- uses: ./.github/actions/prepare-release-package
id: package
- name: Inspect existing npm version
id: registry
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
run: node scripts/ci/npm-release.mjs inspect "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION"
- name: Publish
if: steps.registry.outputs.published == 'false'
shell: bash
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
run: |
tag="$(node scripts/release-npm-tag.mjs "$VERIFIED_PACKAGE_VERSION")"
npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"
- name: Verify published npm bytes and dist-tag
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
run: node scripts/ci/npm-release.mjs verify "$VERIFIED_PACKAGE_TARBALL" "$VERIFIED_PACKAGE_VERSION"
- name: Verify published CLI from npm
env:
VERIFIED_PACKAGE_VERSION: ${{ steps.package.outputs.version }}
@@ -398,9 +419,12 @@ jobs:
publish-mcp:
name: Publish to MCP Registry
needs: [merged-release, release-please, publish]
env:
TURBO_TOKEN: ""
TURBO_CACHE: local:rw
needs: [merged-release, release-please, verify-npm]
if: >-
always() && !cancelled() && needs.publish.result == 'success' &&
always() && !cancelled() && needs.verify-npm.result == 'success' &&
((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') ||
(needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'))
runs-on: ubuntu-latest
+24 -5
View File
@@ -15,8 +15,16 @@ permissions restores the retained tarball and verifier runtime. It installs
that exact archive into an empty prefix, then exercises CLI help/discovery and
the real stdio MCP catalog/session contract. Installation finishes before the
protocol deadline begins, with dependency lifecycle scripts disabled. Only a
successful rehearsal enables the job with npm publishing authority. Both jobs
use the same release-input action; neither rebuilds or repacks the archive.
successful rehearsal enables the job with npm publishing authority. The publisher
downloads only
the original qualified archive and manifest, validates their source/run/attempt,
version, Node identity and streamed digest using Node built-ins, then invokes
`npm publish --ignore-scripts` against the official registry. A shared built-in
registry reader checks existing bytes and tags afresh on every publisher attempt,
including retries after partial publication. It does not install
application dependencies, restore verifier code or execute package canaries.
Pinned Node/npm, Actions and the runner remain trusted toolchain inputs. Neither
job rebuilds or repacks the archive.
This rehearsal checks package consumption on the Linux release runner;
qualification retains its broader native-host and real-provider checks.
@@ -25,10 +33,19 @@ An existing version must match the qualified tarball's SHA-512 and intended
`latest`/`next` dist-tag before it can be reused. Publication never overwrites
an existing version or silently changes a dist-tag. After publication, only
confirmed registry absence is retried within the propagation window. Content
or tag mismatches fail immediately. The post-publication verifier installs
or tag mismatches fail immediately. A separate post-publication job with read-only
repository/actions permissions verifies registry bytes and tags, then installs
from npm and repeats the same CLI/MCP checks; it also exercises a real update
from an older stable release. For a prerelease on `next`, that updater still
targets stable `latest`, as the public update command does.
targets stable `latest`, as the public update command does. MCP publication
depends on that verification job succeeding. Rehearsal, publication and both
registry-verification/publication jobs do not inherit remote-cache credentials.
Qualification records the parsed `latest`/`next` channel in manifest schema v2.
Rehearsal validates its exact SemVer mapping; the minimal publisher consumes the
qualified channel without a second version parser. Only read-only recovery
accepts existing schema-v1 artifacts, preserving their original bytes and
source/run identity. New publication requires schema v2.
The required reverse-tools lane runs fresh coverage and both mutation
preflights. Complete mutation sweeps run separately on a schedule or manual
@@ -50,7 +67,9 @@ gh workflow run release-recovery.yml --ref main \
```
Select the existing version and its original automatic Release run. Recovery
requires that run's successful exact-source CI gate and npm publish step. It
requires that run's successful exact-source CI gate plus a successful npm write
or registry-byte verification. It recognizes verification in either the older
publisher job or the separate post-publication job. It
uses the existing release tag, original qualified tarball and verifier runtime;
expired or missing artifacts stop recovery. The archive's source, version,
original run/attempt and Node version must match, and its SHA-512 must equal
+19
View File
@@ -0,0 +1,19 @@
export type NpmReleaseState =
| { status: "absent" }
| {
status: "published";
integrity: string;
tag: "latest" | "next";
taggedVersion: string | null;
stableVersion: string | null;
};
export function readQualifiedNpmRelease(
version: string,
tag: "latest" | "next",
request?: (url: string, init: RequestInit) => Promise<Response>,
): Promise<NpmReleaseState>;
export function requireMatchingNpmRelease(
tarball: string,
version: string,
published: NpmReleaseState,
): Promise<void>;
+58
View File
@@ -0,0 +1,58 @@
import { verifyPublishedPackageIntegrity } from "./qualified-package-envelope.mjs";
/** Parse fresh registry state using the channel already owned by qualification. */
export async function readQualifiedNpmRelease(version, tag, request = fetch) {
if (!["latest", "next"].includes(tag))
throw new Error("Invalid qualified npm channel");
const response = await request("https://registry.npmjs.org/rea-agents", {
headers: {
Accept: "application/vnd.npm.install-v1+json",
"Cache-Control": "no-cache",
},
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return { status: "absent" };
if (!response.ok)
throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
const metadata = requireRecord(await response.json());
if (metadata.name !== "rea-agents")
throw new Error("Unexpected npm package name");
const versions = requireRecord(metadata.versions);
const tags = requireRecord(metadata["dist-tags"]);
if (Object.values(tags).some((value) => typeof value !== "string"))
throw new Error("Invalid npm dist-tags");
if (!Object.hasOwn(versions, version)) return { status: "absent" };
const published = requireRecord(versions[version]);
const dist = requireRecord(published.dist);
if (
published.name !== "rea-agents" ||
published.version !== version ||
typeof dist.integrity !== "string" ||
dist.integrity.length === 0
)
throw new Error("Invalid published npm identity or integrity");
return {
status: "published",
integrity: dist.integrity,
tag,
taggedVersion: tags[tag] ?? null,
stableVersion: tags.latest ?? null,
};
}
function requireRecord(value) {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error("Invalid npm registry metadata");
return value;
}
/** Validate content before accepting a previously published or newly visible version. */
export async function requireMatchingNpmRelease(tarball, version, published) {
if (published.status !== "published")
throw new Error(`rea-agents@${version} is not published`);
await verifyPublishedPackageIntegrity(tarball, published.integrity);
if (published.taggedVersion !== version)
throw new Error(
`Expected npm ${published.tag} to select ${version}, observed ${published.taggedVersion ?? "missing"}; refusing to change an existing dist-tag`,
);
}
+2 -14
View File
@@ -1,21 +1,9 @@
export type NpmReleaseState =
| { status: "absent" }
| {
status: "published";
integrity: string;
tag: "latest" | "next";
taggedVersion: string | null;
stableVersion: string | null;
};
import type { NpmReleaseState } from "./npm-release-state.mjs";
export function readNpmRelease(
version: string,
request?: (url: string, init: RequestInit) => Promise<Response>,
): Promise<NpmReleaseState>;
export function requireMatchingNpmRelease(
tarball: string,
version: string,
published: NpmReleaseState,
): Promise<void>;
export function stableUpdateVersion(
version: string,
published: NpmReleaseState,
+11 -61
View File
@@ -2,46 +2,16 @@ import { appendFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import { prerelease, valid } from "semver";
import { releaseNpmTag } from "../release-npm-tag.mjs";
import { verifyPublishedPackageIntegrity } from "./verified-package.mjs";
import {
readQualifiedNpmRelease,
requireMatchingNpmRelease,
} from "./npm-release-state.mjs";
/** Parse one npm metadata snapshot; errors and malformed records are never absence. */
/** Read-only callers derive the channel through the canonical SemVer owner. */
export async function readNpmRelease(version, request = fetch) {
const tag = releaseNpmTag(version);
const response = await request("https://registry.npmjs.org/rea-agents", {
headers: {
Accept: "application/vnd.npm.install-v1+json",
"Cache-Control": "no-cache",
},
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return { status: "absent" };
if (!response.ok)
throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
const metadata = z
.object({
name: z.literal("rea-agents"),
versions: z.record(z.string(), z.unknown()),
"dist-tags": z.record(z.string(), z.string()),
})
.parse(await response.json());
if (!Object.hasOwn(metadata.versions, version)) return { status: "absent" };
const published = z
.object({
name: z.literal("rea-agents"),
version: z.literal(version),
dist: z.object({ integrity: z.string().min(1) }),
})
.parse(metadata.versions[version]);
return {
status: "published",
integrity: published.dist.integrity,
tag,
taggedVersion: metadata["dist-tags"][tag] ?? null,
stableVersion: metadata["dist-tags"].latest ?? null,
};
return readQualifiedNpmRelease(version, releaseNpmTag(version), request);
}
/** REA update follows stable latest even when the candidate is published on next. */
@@ -60,17 +30,6 @@ export function stableUpdateVersion(version, published) {
return stable;
}
/** Validate content before accepting a previously published or newly visible version. */
export async function requireMatchingNpmRelease(tarball, version, published) {
if (published.status !== "published")
throw new Error(`rea-agents@${version} is not published`);
await verifyPublishedPackageIntegrity(tarball, published.integrity);
if (published.taggedVersion !== version)
throw new Error(
`Expected npm ${published.tag} to select ${version}, observed ${published.taggedVersion ?? "missing"}; refusing to change an existing dist-tag`,
);
}
/** Retry only confirmed absence during the existing registry propagation window. */
export async function waitForNpmRelease(
version,
@@ -91,26 +50,17 @@ export async function waitForNpmRelease(
async function main() {
const [operation, tarball, version, ...extra] = process.argv.slice(2);
if (
!["inspect", "verify"].includes(operation) ||
operation !== "verify" ||
tarball === undefined ||
version === undefined ||
extra.length
)
throw new Error(
"Usage: node scripts/ci/npm-release.mjs inspect|verify <qualified-tarball> <version>",
"Usage: node scripts/ci/npm-release.mjs verify <qualified-tarball> <version>",
);
const published =
operation === "inspect"
? await readNpmRelease(version)
: await waitForNpmRelease(version);
if (published.status === "published")
await requireMatchingNpmRelease(tarball, version, published);
if (operation === "inspect") {
await appendFile(
z.string().min(1).parse(process.env.GITHUB_OUTPUT),
`published=${published.status === "published"}\n`,
);
} else if (process.env.GITHUB_STEP_SUMMARY !== undefined) {
const published = await waitForNpmRelease(version);
await requireMatchingNpmRelease(tarball, version, published);
if (process.env.GITHUB_STEP_SUMMARY !== undefined) {
await appendFile(
process.env.GITHUB_STEP_SUMMARY,
`npm: rea-agents@${version} is published, matches the qualified tarball, and is selected by ${published.tag}.\n`,
+45
View File
@@ -0,0 +1,45 @@
import { appendFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
validateVerifiedPackageEnvelope,
workflowPackageIdentity,
} from "./qualified-package-envelope.mjs";
import {
readQualifiedNpmRelease,
requireMatchingNpmRelease,
} from "./npm-release-state.mjs";
/** Validate the original artifact and fresh registry state on every publisher attempt. */
export async function prepareNpmPublication(request = fetch) {
const { manifest, tarball } = await validateVerifiedPackageEnvelope({
directory: ".cache/verified-package",
...(await workflowPackageIdentity()),
});
const published = await readQualifiedNpmRelease(
manifest.packageVersion,
manifest.releaseTag,
request,
);
if (published.status === "published")
await requireMatchingNpmRelease(
tarball,
manifest.packageVersion,
published,
);
if (!process.env.GITHUB_OUTPUT)
throw new Error("GITHUB_OUTPUT is required for publication intent");
// Fixed archive path and whitelisted channel; no opaque version enters workflow outputs.
await appendFile(
process.env.GITHUB_OUTPUT,
`tag=${manifest.releaseTag}\npublished=${published.status === "published"}\n`,
);
}
// The complete import graph intentionally uses only Node built-ins.
// Rehearsal validates SemVer/channel semantics on the same immutable artifact first.
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
await prepareNpmPublication();
+173
View File
@@ -0,0 +1,173 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
export const MANIFEST_NAME = "manifest.json";
export const TARBALL_NAME = "package.tgz";
const execFileAsync = promisify(execFile);
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"releaseTag",
"sha256",
].sort();
/** Construct the immutable envelope; semantic release intent belongs to qualification. */
export function createVerifiedPackageEnvelope(metadata) {
const manifest = { schemaVersion: 2, ...metadata };
validateManifestShape(manifest);
return manifest;
}
/** Validate a retained package against the current checkout and workflow attempt. */
export async function validateVerifiedPackageEnvelope({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
allowLegacyManifest = false,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest, allowLegacyManifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
return { tarball, manifest };
}
function validateManifestShape(manifest, allowLegacyManifest = false) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const legacy = allowLegacyManifest && manifest.schemaVersion === 1;
const expectedKeys = legacy
? MANIFEST_KEYS.filter((key) => key !== "releaseTag")
: MANIFEST_KEYS;
const keys = Object.keys(manifest).sort();
if (
keys.length !== expectedKeys.length ||
keys.some((key, index) => key !== expectedKeys[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 2 && !legacy)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (!legacy && !["latest", "next"].includes(manifest.releaseTag))
throw new Error("Verified package manifest has an invalid release tag");
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
export async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
/** Require the registry to serve the exact archive selected for publication. */
export async function verifyPublishedPackageIntegrity(tarball, integrity) {
const hash = createHash("sha512");
for await (const chunk of createReadStream(tarball)) hash.update(chunk);
if (integrity !== `sha512-${hash.digest("base64")}`)
throw new Error(
"Published npm integrity differs from the original qualified package",
);
}
/** Bind each consumer to the checked-out source and the same workflow run. */
export async function workflowPackageIdentity() {
const sourceSha = process.env.GITHUB_SHA;
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
return {
sourceSha,
runId: parsePositiveInteger(process.env.GITHUB_RUN_ID, "GITHUB_RUN_ID"),
runAttempt: parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
),
packageVersion: packageJson.version,
nodeVersion: process.version,
};
}
export function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
+16 -6
View File
@@ -6,10 +6,8 @@ import { promisify } from "node:util";
import { valid } from "semver";
import { z } from "zod";
import {
validateVerifiedPackage,
verifyPublishedPackageIntegrity,
} from "./verified-package.mjs";
import { validateVerifiedPackage } from "./verified-package.mjs";
import { verifyPublishedPackageIntegrity } from "./qualified-package-envelope.mjs";
const execute = promisify(execFile);
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
@@ -82,9 +80,19 @@ export function selectRecoveryRelease({
throw new Error(`Original release did not pass ${name}`);
const publish = requireJob("Publish to npm");
const writes = publish.steps.filter((step) => step.name === "Publish");
const verified = publish.steps.filter(
(step) => step.name === "Verify published npm bytes and dist-tag",
const verificationJobs = parsedJobs.filter(
(job) => job.name === "Verify published npm package",
);
if (
verificationJobs.length > 1 ||
verificationJobs.some((job) => job.status !== "completed")
)
throw new Error(
"Expected at most one completed original npm verification job",
);
const verified = [publish, ...verificationJobs]
.flatMap((job) => job.steps)
.filter((step) => step.name === "Verify published npm bytes and dist-tag");
if (
!(
(writes.length === 1 && writes[0].conclusion === "success") ||
@@ -118,6 +126,8 @@ export async function verifyRecoveryPackage({
runAttempt,
packageVersion: version,
nodeVersion: process.version,
// Existing published releases retain their original v1 artifact; never rewrite it.
allowLegacyManifest: true,
});
await verifyPublishedPackageIntegrity(tarball, integrity);
return { version, sourceSha, runId, runAttempt, integrity };
+3
View File
@@ -109,7 +109,10 @@ export const SCOPES = {
".github/workflows/release-recovery.yml",
"scripts/ci/release-recovery.mjs",
"scripts/ci/verified-package.mjs",
"scripts/ci/qualified-package-envelope.mjs",
"scripts/ci/prepare-npm-publication.mjs",
"scripts/ci/npm-release.*",
"scripts/ci/npm-release-state.*",
"scripts/verify-release-artifact.mjs",
"scripts/lib/release-package-canary.mjs",
"scripts/verify-published-package.mjs",
+43 -178
View File
@@ -1,28 +1,19 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { copyFile, readFile, writeFile } from "node:fs/promises";
import { copyFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { valid } from "semver";
import { releaseNpmTag } from "../release-npm-tag.mjs";
import {
MANIFEST_NAME,
TARBALL_NAME,
createVerifiedPackageEnvelope,
parsePositiveInteger,
sha256File,
validateVerifiedPackageEnvelope,
workflowPackageIdentity,
} from "./qualified-package-envelope.mjs";
const MANIFEST_NAME = "manifest.json";
const TARBALL_NAME = "package.tgz";
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"sha256",
].sort();
const execFileAsync = promisify(execFile);
/** Normalize workflow identity and host facts into the manifest-owned fields. */
/** Qualification owns the exact SemVer and its parsed publication channel. */
export function createVerifiedPackageMetadata({
sourceSha,
runId,
@@ -42,28 +33,38 @@ export function createVerifiedPackageMetadata({
architecture,
packageVersion,
nodeVersion,
releaseTag: releaseNpmTag(packageVersion),
};
}
/** Build the single supported manifest representation for a verified tarball. */
function validateReleaseIntent(manifest) {
if (valid(manifest.packageVersion) !== manifest.packageVersion)
throw new Error("Verified package manifest has an invalid package version");
if (
manifest.schemaVersion === 2 &&
releaseNpmTag(manifest.packageVersion) !== manifest.releaseTag
)
throw new Error("Verified package release tag mismatch");
}
function createVerifiedPackageManifest(metadata) {
const manifest = { schemaVersion: 1, ...metadata };
validateManifestShape(manifest);
const manifest = createVerifiedPackageEnvelope(metadata);
validateReleaseIntent(manifest);
return manifest;
}
/** Write a manifest after the caller has completed qualification and cleanup. */
/** Write only after qualification and its cleanup have completed. */
export async function writeVerifiedPackageManifest(directory, metadata) {
const manifest = createVerifiedPackageManifest(metadata);
await writeFile(
join(directory, MANIFEST_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
{ encoding: "utf8" },
"utf8",
);
return manifest;
}
/** Copy the qualified archive under its fixed name and prepare its manifest. */
/** Retain the exact archive without unpacking or rebuilding it. */
export async function retainVerifiedPackage({
sourceTarball,
directory,
@@ -71,174 +72,40 @@ export async function retainVerifiedPackage({
}) {
const tarball = join(directory, TARBALL_NAME);
await copyFile(sourceTarball, tarball);
const sha256 = await sha256File(tarball);
return {
directory,
metadata: createVerifiedPackageManifest({ ...metadata, sha256 }),
metadata: createVerifiedPackageManifest({
...metadata,
sha256: await sha256File(tarball),
}),
};
}
/** Validate a retained package against the current checkout and workflow attempt. */
export async function validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
/** Read-only consumers additionally validate SemVer and its qualified channel. */
export async function validateVerifiedPackage(options) {
const { tarball, manifest } = await validateVerifiedPackageEnvelope(options);
validateReleaseIntent(manifest);
return tarball;
}
function validateManifestShape(manifest) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const keys = Object.keys(manifest).sort();
if (
keys.length !== MANIFEST_KEYS.length ||
keys.some((key, index) => key !== MANIFEST_KEYS[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 1)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (valid(manifest.packageVersion) !== manifest.packageVersion)
throw new Error("Verified package manifest has an invalid package version");
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
/** Require the registry to serve the exact archive selected for publication. */
export async function verifyPublishedPackageIntegrity(tarball, integrity) {
const hash = createHash("sha512");
for await (const chunk of createReadStream(tarball)) hash.update(chunk);
if (integrity !== `sha512-${hash.digest("base64")}`)
throw new Error(
"Published npm integrity differs from the original qualified package",
);
}
async function validateFromCli(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = parsePositiveInteger(
process.env.GITHUB_RUN_ID,
"GITHUB_RUN_ID",
);
const runAttempt = parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
);
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const tarball = await validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion: packageJson.version,
nodeVersion: process.version,
});
const expected = await workflowPackageIdentity();
const tarball = await validateVerifiedPackage({ directory, ...expected });
if (process.env.GITHUB_OUTPUT === undefined)
throw new Error(
"GITHUB_OUTPUT is required to publish the validated tarball path",
);
await writeFile(
process.env.GITHUB_OUTPUT,
`tarball=${tarball}\nversion=${packageJson.version}\n`,
{
encoding: "utf8",
flag: "a",
},
`tarball=${tarball}\nversion=${expected.packageVersion}\n`,
{ encoding: "utf8", flag: "a" },
);
process.stdout.write(`${tarball}\n`);
}
function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
resolve(invokedPath) === fileURLToPath(import.meta.url)
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const [command, directory, ...extra] = process.argv.slice(2);
if (command !== "validate" || directory === undefined || extra.length > 0) {
@@ -246,7 +113,5 @@ if (
"Usage: node scripts/ci/verified-package.mjs validate <artifact-directory>\n",
);
process.exitCode = 2;
} else {
await validateFromCli(directory);
}
} else await validateFromCli(directory);
}
@@ -757,6 +757,18 @@ it.each([
"tests/boundary/cli/nativePlistCli.test.ts",
{ code: true, apple: true, package: false, runtime: false },
],
[
"scripts/ci/qualified-package-envelope.mjs",
{ code: true, package: true, runtime: true, apple: false },
],
[
"scripts/ci/prepare-npm-publication.mjs",
{ code: true, package: true, runtime: true, apple: false },
],
[
"scripts/ci/npm-release-state.mjs",
{ code: true, package: true, runtime: true, apple: false },
],
[
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
{ code: true, package: true, runtime: true, apple: false },
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -19,6 +19,8 @@ type FixtureOptions = {
override?: Record<string, string | number>;
manifestContents?: string;
writeManifest?: boolean;
version?: string;
tag?: string;
};
it("validates a tarball against the exact checkout and current workflow attempt", async () => {
@@ -46,6 +48,11 @@ it.each([
["wrong verifier platform", { platform: "darwin" }],
["wrong verifier architecture", { architecture: "arm64" }],
["wrong package digest", { sha256: "c".repeat(64) }],
["old manifest schema", { schemaVersion: 1 }],
["wrong package version", { packageVersion: "1.2.4" }],
["wrong Node version", { nodeVersion: "v24.0.0" }],
["wrong release channel", { releaseTag: "next" }],
["invalid release channel", { releaseTag: "latest\noutput=injection" }],
["invalid package version", { packageVersion: "invalid\noutput=injection" }],
])("rejects %s", async (_label, override) => {
const fixture = await createFixture({ override });
@@ -63,7 +70,7 @@ it("accepts an earlier successful attempt from the same workflow run", async ()
it.each([
["malformed manifest", "{"],
["manifest with missing fields", JSON.stringify({ schemaVersion: 1 })],
["manifest with missing fields", JSON.stringify({ schemaVersion: 2 })],
])("rejects %s", async (_label, contents) => {
const fixture = await createFixture({ manifestContents: contents });
@@ -80,12 +87,14 @@ async function createFixture({
override = {},
manifestContents,
writeManifest = true,
version = "1.2.3",
tag = "latest",
}: FixtureOptions = {}) {
const directory = await createTestTempDirectory("rea-verified-package-");
await workflowGit(directory, ["init", "--initial-branch=main"]);
await writeFile(
join(directory, "package.json"),
'{"name":"fixture","version":"1.2.3"}\n',
JSON.stringify({ name: "fixture", version }),
);
await workflowGit(directory, ["add", "package.json"]);
await workflowGit(directory, [
@@ -94,19 +103,20 @@ async function createFixture({
"test: verified package fixture",
]);
const sourceSha = await workflowGit(directory, ["rev-parse", "HEAD"]);
const artifactDirectory = join(directory, "artifact");
await mkdir(artifactDirectory);
const artifactDirectory = join(directory, ".cache", "verified-package");
await mkdir(artifactDirectory, { recursive: true });
const archive = "verified tarball fixture";
await writeFile(join(artifactDirectory, "package.tgz"), archive);
const manifest = {
schemaVersion: 1,
schemaVersion: 2,
sourceSha,
runId: 123,
runAttempt: 2,
platform: "linux",
architecture: "x64",
packageVersion: "1.2.3",
packageVersion: version,
nodeVersion: process.version,
releaseTag: tag,
sha256: createHash("sha256").update(archive).digest("hex"),
...override,
};
@@ -117,7 +127,15 @@ async function createFixture({
);
const output = join(directory, "github-output");
await writeFile(output, "");
return { directory, sourceSha, artifactDirectory, output };
return {
directory,
sourceSha,
artifactDirectory,
output,
version,
tag,
integrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`,
};
}
function validate(fixture: Fixture, sourceSha = fixture.sourceSha) {
@@ -136,3 +154,151 @@ function validate(fixture: Fixture, sourceSha = fixture.sourceSha) {
},
);
}
// Run the actual issuer-side entrypoint outside the repository's node_modules tree.
// This catches an accidental transitive dependency import rather than mocking it away.
async function preparePublication(fixture: Fixture, published = "false") {
const scripts = join(fixture.directory, "scripts", "ci");
await mkdir(scripts, { recursive: true });
for (const name of [
"prepare-npm-publication.mjs",
"qualified-package-envelope.mjs",
"npm-release-state.mjs",
]) {
await copyFile(
new URL(`../../../scripts/ci/${name}`, import.meta.url),
join(scripts, name),
);
}
if (published !== "retain")
await writeFile(
join(fixture.directory, "registry-state.json"),
JSON.stringify({
status:
published === "unknown" ? 503 : published === "false" ? 404 : 200,
body: {
name: "rea-agents",
versions: {
[fixture.version]: {
name: "rea-agents",
version: fixture.version,
dist: { integrity: fixture.integrity },
},
},
"dist-tags": { [fixture.tag]: fixture.version },
},
}),
);
await writeFile(
join(scripts, "invoke.mjs"),
`
import { readFile } from "node:fs/promises";
import { prepareNpmPublication } from "./prepare-npm-publication.mjs";
const state = JSON.parse(await readFile("registry-state.json", "utf8"));
await prepareNpmPublication(async (url) => {
if (url !== "https://registry.npmjs.org/rea-agents") throw new Error("Unexpected registry origin");
return Response.json(state.body, { status: state.status });
});
`,
);
return executeWorkflowFixture(
process.execPath,
[join(scripts, "invoke.mjs")],
{
cwd: fixture.directory,
env: {
...process.env,
GITHUB_SHA: fixture.sourceSha,
GITHUB_RUN_ID: "123",
GITHUB_RUN_ATTEMPT: "2",
GITHUB_OUTPUT: fixture.output,
},
},
);
}
it.each([
{ version: "1.2.3", tag: "latest", published: "false" },
{ version: "1.2.3-rc.1", tag: "next", published: "true" },
])(
"prepares $tag publication without installed dependencies (already published: $published)",
async ({ version, tag, published }) => {
const fixture = await createFixture({ version, tag });
await preparePublication(fixture, published);
expect(await readFile(fixture.output, "utf8")).toBe(
`tag=${tag}\npublished=${published}\n`,
);
},
);
it.each([
["old schema", { schemaVersion: 1 }],
["wrong source", { sourceSha: "c".repeat(40) }],
["wrong run", { runId: 124 }],
["future attempt", { runAttempt: 3 }],
["wrong Node", { nodeVersion: "v24.0.0" }],
["wrong version", { packageVersion: "1.2.4" }],
["wrong archive", { sha256: "c".repeat(64) }],
["unknown channel", { releaseTag: "beta\noutput=injection" }],
] as const)(
"refuses issuer-side %s without installed dependencies",
async (_label, override) => {
const fixture = await createFixture({ override });
await expect(preparePublication(fixture)).rejects.toThrow();
expect(await readFile(fixture.output, "utf8")).toBe("");
},
);
it("retains same-run reuse and rejects failed registry lookups", async () => {
const fixture = await createFixture({ override: { runAttempt: 1 } });
await expect(preparePublication(fixture, "unknown")).rejects.toThrow();
expect(await readFile(fixture.output, "utf8")).toBe("");
await preparePublication(fixture);
expect(await readFile(fixture.output, "utf8")).toBe(
"tag=latest\npublished=false\n",
);
});
it("refreshes registry identity when an accepted upload reports a failed command", async () => {
const fixture = await createFixture();
await preparePublication(fixture, "false");
expect(await readFile(fixture.output, "utf8")).toBe(
"tag=latest\npublished=false\n",
);
// A fake npm producer accepts the archive, then fails before reporting success.
// The next publisher attempt must discover the committed registry state.
await expect(
executeWorkflowFixture(
process.execPath,
[
"--input-type=module",
"-e",
`
import { readFile, writeFile } from "node:fs/promises";
const path = "registry-state.json";
const state = JSON.parse(await readFile(path, "utf8"));
state.status = 200;
await writeFile(path, JSON.stringify(state));
process.exitCode = 1;
`,
],
{ cwd: fixture.directory },
),
).rejects.toMatchObject({ code: 1 });
await writeFile(fixture.output, "");
await preparePublication(fixture, "retain");
expect(await readFile(fixture.output, "utf8")).toBe(
"tag=latest\npublished=true\n",
);
});
it.each(["integrity", "channel"])(
"refuses existing registry %s drift before returning publication intent",
async (kind) => {
const fixture = await createFixture();
if (kind === "integrity") fixture.integrity = "sha512-wrong";
else fixture.tag = "next";
await expect(preparePublication(fixture, "true")).rejects.toThrow();
expect(await readFile(fixture.output, "utf8")).toBe("");
},
);
+21 -1
View File
@@ -4,10 +4,10 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import {
readNpmRelease,
requireMatchingNpmRelease,
stableUpdateVersion,
waitForNpmRelease,
} from "../../../scripts/ci/npm-release.mjs";
import { requireMatchingNpmRelease } from "../../../scripts/ci/npm-release-state.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const metadata = (
@@ -130,3 +130,23 @@ it("waits only for confirmed propagation absence and does not retry errors", asy
).rejects.toThrow("unavailable");
expect(failures).toBe(1);
});
it.each([
{
name: "array versions",
value: { name: "rea-agents", versions: [], "dist-tags": {} },
},
{
name: "invalid tag facts",
value: { name: "rea-agents", versions: {}, "dist-tags": { latest: 42 } },
},
{
name: "different package",
value: { name: "other", versions: {}, "dist-tags": {} },
},
{ name: "null registry response", value: null },
])("does not turn $name into publication absence", async ({ value }) => {
await expect(
readNpmRelease("6.5.0", async () => Response.json(value)),
).rejects.toThrow();
});
@@ -55,18 +55,27 @@ it("recovers a failed post-publication run using its successful original qualifi
});
});
it("recovers a byte-verified existing version when the npm write was skipped", () => {
const input = selection();
input.jobs[2]!.steps[0]!.conclusion = "skipped";
input.jobs[2]!.steps.push({
name: "Verify published npm bytes and dist-tag",
conclusion: "success",
});
expect(selectRecoveryRelease(input)).toMatchObject({
version: "6.5.0",
run_id: 123,
});
});
it.each(["legacy publisher", "separate verification"])(
"recovers a byte-verified existing version from %s when the npm write was skipped",
(location) => {
const input = selection();
input.jobs[2]!.steps[0]!.conclusion = "skipped";
const verified = {
name: "Verify published npm bytes and dist-tag",
conclusion: "success",
};
if (location === "legacy publisher") input.jobs[2]!.steps.push(verified);
else
input.jobs.push({
...job("Verify published npm package", "failure"),
steps: [verified],
});
expect(selectRecoveryRelease(input)).toMatchObject({
version: "6.5.0",
run_id: 123,
});
},
);
it.each(["failure", "skipped", "cancelled"])(
"refuses recovery when original qualification was %s",
@@ -95,6 +104,28 @@ it("refuses absent or ambiguous qualification and unsuccessful npm publication",
);
});
it.each(["duplicate", "running"])(
"refuses %s original npm verification jobs",
(condition) => {
const input = selection();
const verification = {
...job("Verify published npm package"),
steps: [
{
name: "Verify published npm bytes and dist-tag",
conclusion: "success",
},
],
};
input.jobs.push(verification);
if (condition === "duplicate") input.jobs.push(verification);
else verification.status = "in_progress";
expect(() => selectRecoveryRelease(input)).toThrow(
"Expected at most one completed",
);
},
);
it("refuses foreign runs, unrelated workflows and unpublished releases", () => {
const foreign = selection();
foreign.run.head_repository.full_name = "outside/rea";
@@ -110,46 +141,50 @@ it("refuses foreign runs, unrelated workflows and unpublished releases", () => {
expect(() => selectRecoveryRelease(wrongTag)).toThrow();
});
it("requires npm bytes to match the retained archive and original source/run", async () => {
const directory = await createTestTempDirectory("rea-release-recovery-");
const archive = "original qualified archive";
await writeFile(join(directory, "package.tgz"), archive);
await writeFile(
join(directory, "manifest.json"),
JSON.stringify({
schemaVersion: 1,
it.each([1, 2])(
"requires npm bytes to match the original v%d archive/source/run",
async (schemaVersion) => {
const directory = await createTestTempDirectory("rea-release-recovery-");
const archive = "original qualified archive";
await writeFile(join(directory, "package.tgz"), archive);
await writeFile(
join(directory, "manifest.json"),
JSON.stringify({
schemaVersion,
...(schemaVersion === 2 ? { releaseTag: "latest" } : {}),
sourceSha,
runId: 123,
runAttempt: 1,
platform: "linux",
architecture: "x64",
packageVersion: "6.5.0",
nodeVersion: process.version,
sha256: createHash("sha256").update(archive).digest("hex"),
}),
);
const input = {
directory,
sourceSha,
runId: 123,
runAttempt: 1,
platform: "linux",
architecture: "x64",
packageVersion: "6.5.0",
nodeVersion: process.version,
sha256: createHash("sha256").update(archive).digest("hex"),
}),
);
const input = {
directory,
sourceSha,
runId: 123,
runAttempt: 1,
version: "6.5.0",
integrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`,
};
await expect(verifyRecoveryPackage(input)).resolves.toMatchObject({
version: "6.5.0",
sourceSha,
});
await expect(
verifyRecoveryPackage({ ...input, integrity: "sha512-wrong" }),
).rejects.toThrow("npm integrity differs");
await expect(
verifyRecoveryPackage({ ...input, sourceSha: "b".repeat(40) }),
).rejects.toThrow("source SHA mismatch");
await expect(verifyRecoveryPackage({ ...input, runId: 456 })).rejects.toThrow(
"workflow run ID mismatch",
);
});
version: "6.5.0",
integrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`,
};
await expect(verifyRecoveryPackage(input)).resolves.toMatchObject({
version: "6.5.0",
sourceSha,
});
await expect(
verifyRecoveryPackage({ ...input, integrity: "sha512-wrong" }),
).rejects.toThrow("npm integrity differs");
await expect(
verifyRecoveryPackage({ ...input, sourceSha: "b".repeat(40) }),
).rejects.toThrow("source SHA mismatch");
await expect(
verifyRecoveryPackage({ ...input, runId: 456 }),
).rejects.toThrow("workflow run ID mismatch");
},
);
it("limits recovery to verification and dependent MCP publication from the released source", async () => {
const step = z.object({
@@ -25,6 +25,8 @@ const jobSchema = z.object({
if: z.string().optional(),
needs: z.union([z.string(), z.array(z.string())]).optional(),
outputs: z.record(z.string(), z.string()).optional(),
permissions: z.record(z.string(), z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
steps: z.array(stepSchema),
});
@@ -45,6 +47,7 @@ async function readReleaseWorkflow() {
with: z.record(z.string(), z.union([z.string(), z.boolean()])),
}),
publish: jobSchema,
"verify-npm": jobSchema,
rehearsal: jobSchema.extend({
permissions: z.record(z.string(), z.string()),
}),
@@ -306,7 +309,7 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
"needs.qualification.result == 'success'",
);
expect(workflow.jobs["publish-mcp"].if).toContain(
"needs.publish.result == 'success'",
"needs.verify-npm.result == 'success'",
);
expect(workflow.jobs.publish.needs).toContain("merged-release");
expect(workflow.jobs["publish-mcp"].needs).toContain("merged-release");
@@ -341,9 +344,9 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
const publishCommand = workflow.jobs.publish.steps.find(
(step) => step.name === "Publish",
)?.run;
expect(publishCommand).toContain("scripts/release-npm-tag.mjs");
expect(publishCommand).toContain("--ignore-scripts");
expect(publishCommand).toContain(
'npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"',
'--registry=https://registry.npmjs.org --access=public --tag="$NPM_RELEASE_TAG"',
);
for (const [version, tag] of [
["6.1.0", "latest"],
@@ -698,7 +701,8 @@ it("rehearses the qualified archive before npm authority and retains registry ve
const publish = workflow.jobs.publish;
expect(publish.needs).toContain("rehearsal");
expect(publish.if).toContain("needs.rehearsal.result == 'success'");
for (const job of [rehearsal, publish]) {
const verification = workflow.jobs["verify-npm"];
for (const job of [rehearsal, verification]) {
const inputs = job.steps.findIndex(
(step) => step.uses === "./.github/actions/prepare-release-package",
);
@@ -746,28 +750,53 @@ it("rehearses the qualified archive before npm authority and retains registry ve
expect(step.if).toBeUndefined();
expect(step["continue-on-error"]).toBeUndefined();
}
const commands = publish.steps.map((step) => step.run ?? "");
const inspect = commands.findIndex((command) =>
command.includes("npm-release.mjs inspect"),
);
const write = commands.findIndex((command) =>
command.includes("npm publish"),
expect(publish.permissions).toEqual({
contents: "read",
"id-token": "write",
});
expect(
publish.steps.filter((step) => step.run).map((step) => step.run),
).toEqual([
"node scripts/ci/prepare-npm-publication.mjs",
expect.stringContaining(
"npm publish .cache/verified-package/package.tgz --ignore-scripts",
),
]);
expect(
publish.steps
.filter((step) => step.uses?.startsWith("actions/download-artifact@"))
.map((step) => step.with?.name),
).toEqual(["verified-release-package"]);
expect(publish.steps.find((step) => step.name === "Publish")?.if).toBe(
"steps.package.outputs.published == 'false'",
);
expect(verification.needs).toContain("publish");
expect(verification.if).toContain("needs.publish.result == 'success'");
expect(verification.permissions).toEqual({
contents: "read",
actions: "read",
});
const commands = verification.steps.map((step) => step.run ?? "");
const bytes = commands.findIndex((command) =>
command.includes("npm-release.mjs verify"),
);
const canary = commands.findIndex((command) =>
command.includes("verify-published-package.mjs"),
);
expect(inspect).toBeGreaterThanOrEqual(0);
expect(write).toBeGreaterThan(inspect);
expect(bytes).toBeGreaterThan(write);
expect(bytes).toBeGreaterThanOrEqual(0);
expect(canary).toBeGreaterThan(bytes);
expect(publish.steps[write]?.if).toBe(
"steps.registry.outputs.published == 'false'",
);
for (const index of [inspect, bytes, canary]) {
expect(publish.steps[index]?.if).toBeUndefined();
expect(publish.steps[index]?.["continue-on-error"]).toBeUndefined();
for (const index of [bytes, canary]) {
expect(verification.steps[index]?.if).toBeUndefined();
expect(verification.steps[index]?.["continue-on-error"]).toBeUndefined();
}
expect(workflow.jobs["publish-mcp"].needs).toContain("verify-npm");
for (const job of [
rehearsal,
publish,
verification,
workflow.jobs["publish-mcp"],
]) {
expect(job.env?.TURBO_TOKEN).toBe("");
expect(job.env?.TURBO_CACHE).toBe("local:rw");
}
});