mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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("");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user