diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2b4828ab5..bab5c35bf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 5b11f79ae4..b43d80b96b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/scripts/ci/npm-release-state.d.mts b/scripts/ci/npm-release-state.d.mts new file mode 100644 index 0000000000..4363837b60 --- /dev/null +++ b/scripts/ci/npm-release-state.d.mts @@ -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, +): Promise; +export function requireMatchingNpmRelease( + tarball: string, + version: string, + published: NpmReleaseState, +): Promise; diff --git a/scripts/ci/npm-release-state.mjs b/scripts/ci/npm-release-state.mjs new file mode 100644 index 0000000000..733d06fa94 --- /dev/null +++ b/scripts/ci/npm-release-state.mjs @@ -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`, + ); +} diff --git a/scripts/ci/npm-release.d.mts b/scripts/ci/npm-release.d.mts index 1d70d3c33b..266207cbde 100644 --- a/scripts/ci/npm-release.d.mts +++ b/scripts/ci/npm-release.d.mts @@ -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, ): Promise; -export function requireMatchingNpmRelease( - tarball: string, - version: string, - published: NpmReleaseState, -): Promise; export function stableUpdateVersion( version: string, published: NpmReleaseState, diff --git a/scripts/ci/npm-release.mjs b/scripts/ci/npm-release.mjs index 8bd0f1464b..ec06843333 100644 --- a/scripts/ci/npm-release.mjs +++ b/scripts/ci/npm-release.mjs @@ -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 ", + "Usage: node scripts/ci/npm-release.mjs verify ", ); - 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`, diff --git a/scripts/ci/prepare-npm-publication.mjs b/scripts/ci/prepare-npm-publication.mjs new file mode 100644 index 0000000000..ad9ae789a7 --- /dev/null +++ b/scripts/ci/prepare-npm-publication.mjs @@ -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(); diff --git a/scripts/ci/qualified-package-envelope.mjs b/scripts/ci/qualified-package-envelope.mjs new file mode 100644 index 0000000000..2eeaabf213 --- /dev/null +++ b/scripts/ci/qualified-package-envelope.mjs @@ -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; +} diff --git a/scripts/ci/release-recovery.mjs b/scripts/ci/release-recovery.mjs index fefaa25d35..0bf04fe498 100644 --- a/scripts/ci/release-recovery.mjs +++ b/scripts/ci/release-recovery.mjs @@ -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 }; diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 004521e4de..f29cda9704 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -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", diff --git a/scripts/ci/verified-package.mjs b/scripts/ci/verified-package.mjs index 444289bab2..84aaf63d61 100644 --- a/scripts/ci/verified-package.mjs +++ b/scripts/ci/verified-package.mjs @@ -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 \n", ); process.exitCode = 2; - } else { - await validateFromCli(directory); - } + } else await validateFromCli(directory); } diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 1422d918d9..6939bbf0a6 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -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 }, diff --git a/tests/boundary/filesystem/ciVerifiedPackage.test.ts b/tests/boundary/filesystem/ciVerifiedPackage.test.ts index c2ee285249..57e44ad730 100644 --- a/tests/boundary/filesystem/ciVerifiedPackage.test.ts +++ b/tests/boundary/filesystem/ciVerifiedPackage.test.ts @@ -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; 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(""); + }, +); diff --git a/tests/boundary/filesystem/npmRelease.test.ts b/tests/boundary/filesystem/npmRelease.test.ts index c355e21b7d..cf8346f392 100644 --- a/tests/boundary/filesystem/npmRelease.test.ts +++ b/tests/boundary/filesystem/npmRelease.test.ts @@ -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(); +}); diff --git a/tests/boundary/filesystem/releaseRecovery.test.ts b/tests/boundary/filesystem/releaseRecovery.test.ts index ee1772d13a..08e483e0e1 100644 --- a/tests/boundary/filesystem/releaseRecovery.test.ts +++ b/tests/boundary/filesystem/releaseRecovery.test.ts @@ -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({ diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index 5edd662e1d..db841e74e0 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -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"); } });