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

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

Static/docs checks, artifact/registry/recovery tests and real package/rehearsal verification pass. All four hosted package platforms pass. Portable CI retains the independently reproduced main PTX failure in #6675 and fail-fast missing coverage; use the owner-authorized existing PR-only exception, with limitations documented in the PR. Refs #6480.
This commit is contained in:
gensokyo-dev
2026-10-23 20:27:24 +08:00
committed by GitHub
parent 8c4f96ef47
commit 5d6b8a0ab6
16 changed files with 780 additions and 364 deletions
+19
View File
@@ -0,0 +1,19 @@
export type NpmReleaseState =
| { status: "absent" }
| {
status: "published";
integrity: string;
tag: "latest" | "next";
taggedVersion: string | null;
stableVersion: string | null;
};
export function readQualifiedNpmRelease(
version: string,
tag: "latest" | "next",
request?: (url: string, init: RequestInit) => Promise<Response>,
): Promise<NpmReleaseState>;
export function requireMatchingNpmRelease(
tarball: string,
version: string,
published: NpmReleaseState,
): Promise<void>;
+58
View File
@@ -0,0 +1,58 @@
import { verifyPublishedPackageIntegrity } from "./qualified-package-envelope.mjs";
/** Parse fresh registry state using the channel already owned by qualification. */
export async function readQualifiedNpmRelease(version, tag, request = fetch) {
if (!["latest", "next"].includes(tag))
throw new Error("Invalid qualified npm channel");
const response = await request("https://registry.npmjs.org/rea-agents", {
headers: {
Accept: "application/vnd.npm.install-v1+json",
"Cache-Control": "no-cache",
},
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return { status: "absent" };
if (!response.ok)
throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
const metadata = requireRecord(await response.json());
if (metadata.name !== "rea-agents")
throw new Error("Unexpected npm package name");
const versions = requireRecord(metadata.versions);
const tags = requireRecord(metadata["dist-tags"]);
if (Object.values(tags).some((value) => typeof value !== "string"))
throw new Error("Invalid npm dist-tags");
if (!Object.hasOwn(versions, version)) return { status: "absent" };
const published = requireRecord(versions[version]);
const dist = requireRecord(published.dist);
if (
published.name !== "rea-agents" ||
published.version !== version ||
typeof dist.integrity !== "string" ||
dist.integrity.length === 0
)
throw new Error("Invalid published npm identity or integrity");
return {
status: "published",
integrity: dist.integrity,
tag,
taggedVersion: tags[tag] ?? null,
stableVersion: tags.latest ?? null,
};
}
function requireRecord(value) {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error("Invalid npm registry metadata");
return value;
}
/** Validate content before accepting a previously published or newly visible version. */
export async function requireMatchingNpmRelease(tarball, version, published) {
if (published.status !== "published")
throw new Error(`rea-agents@${version} is not published`);
await verifyPublishedPackageIntegrity(tarball, published.integrity);
if (published.taggedVersion !== version)
throw new Error(
`Expected npm ${published.tag} to select ${version}, observed ${published.taggedVersion ?? "missing"}; refusing to change an existing dist-tag`,
);
}
+2 -14
View File
@@ -1,21 +1,9 @@
export type NpmReleaseState =
| { status: "absent" }
| {
status: "published";
integrity: string;
tag: "latest" | "next";
taggedVersion: string | null;
stableVersion: string | null;
};
import type { NpmReleaseState } from "./npm-release-state.mjs";
export function readNpmRelease(
version: string,
request?: (url: string, init: RequestInit) => Promise<Response>,
): Promise<NpmReleaseState>;
export function requireMatchingNpmRelease(
tarball: string,
version: string,
published: NpmReleaseState,
): Promise<void>;
export function stableUpdateVersion(
version: string,
published: NpmReleaseState,
+11 -61
View File
@@ -2,46 +2,16 @@ import { appendFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import { prerelease, valid } from "semver";
import { releaseNpmTag } from "../release-npm-tag.mjs";
import { verifyPublishedPackageIntegrity } from "./verified-package.mjs";
import {
readQualifiedNpmRelease,
requireMatchingNpmRelease,
} from "./npm-release-state.mjs";
/** Parse one npm metadata snapshot; errors and malformed records are never absence. */
/** Read-only callers derive the channel through the canonical SemVer owner. */
export async function readNpmRelease(version, request = fetch) {
const tag = releaseNpmTag(version);
const response = await request("https://registry.npmjs.org/rea-agents", {
headers: {
Accept: "application/vnd.npm.install-v1+json",
"Cache-Control": "no-cache",
},
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return { status: "absent" };
if (!response.ok)
throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
const metadata = z
.object({
name: z.literal("rea-agents"),
versions: z.record(z.string(), z.unknown()),
"dist-tags": z.record(z.string(), z.string()),
})
.parse(await response.json());
if (!Object.hasOwn(metadata.versions, version)) return { status: "absent" };
const published = z
.object({
name: z.literal("rea-agents"),
version: z.literal(version),
dist: z.object({ integrity: z.string().min(1) }),
})
.parse(metadata.versions[version]);
return {
status: "published",
integrity: published.dist.integrity,
tag,
taggedVersion: metadata["dist-tags"][tag] ?? null,
stableVersion: metadata["dist-tags"].latest ?? null,
};
return readQualifiedNpmRelease(version, releaseNpmTag(version), request);
}
/** REA update follows stable latest even when the candidate is published on next. */
@@ -60,17 +30,6 @@ export function stableUpdateVersion(version, published) {
return stable;
}
/** Validate content before accepting a previously published or newly visible version. */
export async function requireMatchingNpmRelease(tarball, version, published) {
if (published.status !== "published")
throw new Error(`rea-agents@${version} is not published`);
await verifyPublishedPackageIntegrity(tarball, published.integrity);
if (published.taggedVersion !== version)
throw new Error(
`Expected npm ${published.tag} to select ${version}, observed ${published.taggedVersion ?? "missing"}; refusing to change an existing dist-tag`,
);
}
/** Retry only confirmed absence during the existing registry propagation window. */
export async function waitForNpmRelease(
version,
@@ -91,26 +50,17 @@ export async function waitForNpmRelease(
async function main() {
const [operation, tarball, version, ...extra] = process.argv.slice(2);
if (
!["inspect", "verify"].includes(operation) ||
operation !== "verify" ||
tarball === undefined ||
version === undefined ||
extra.length
)
throw new Error(
"Usage: node scripts/ci/npm-release.mjs inspect|verify <qualified-tarball> <version>",
"Usage: node scripts/ci/npm-release.mjs verify <qualified-tarball> <version>",
);
const published =
operation === "inspect"
? await readNpmRelease(version)
: await waitForNpmRelease(version);
if (published.status === "published")
await requireMatchingNpmRelease(tarball, version, published);
if (operation === "inspect") {
await appendFile(
z.string().min(1).parse(process.env.GITHUB_OUTPUT),
`published=${published.status === "published"}\n`,
);
} else if (process.env.GITHUB_STEP_SUMMARY !== undefined) {
const published = await waitForNpmRelease(version);
await requireMatchingNpmRelease(tarball, version, published);
if (process.env.GITHUB_STEP_SUMMARY !== undefined) {
await appendFile(
process.env.GITHUB_STEP_SUMMARY,
`npm: rea-agents@${version} is published, matches the qualified tarball, and is selected by ${published.tag}.\n`,
+45
View File
@@ -0,0 +1,45 @@
import { appendFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
validateVerifiedPackageEnvelope,
workflowPackageIdentity,
} from "./qualified-package-envelope.mjs";
import {
readQualifiedNpmRelease,
requireMatchingNpmRelease,
} from "./npm-release-state.mjs";
/** Validate the original artifact and fresh registry state on every publisher attempt. */
export async function prepareNpmPublication(request = fetch) {
const { manifest, tarball } = await validateVerifiedPackageEnvelope({
directory: ".cache/verified-package",
...(await workflowPackageIdentity()),
});
const published = await readQualifiedNpmRelease(
manifest.packageVersion,
manifest.releaseTag,
request,
);
if (published.status === "published")
await requireMatchingNpmRelease(
tarball,
manifest.packageVersion,
published,
);
if (!process.env.GITHUB_OUTPUT)
throw new Error("GITHUB_OUTPUT is required for publication intent");
// Fixed archive path and whitelisted channel; no opaque version enters workflow outputs.
await appendFile(
process.env.GITHUB_OUTPUT,
`tag=${manifest.releaseTag}\npublished=${published.status === "published"}\n`,
);
}
// The complete import graph intentionally uses only Node built-ins.
// Rehearsal validates SemVer/channel semantics on the same immutable artifact first.
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
await prepareNpmPublication();
+173
View File
@@ -0,0 +1,173 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
export const MANIFEST_NAME = "manifest.json";
export const TARBALL_NAME = "package.tgz";
const execFileAsync = promisify(execFile);
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"releaseTag",
"sha256",
].sort();
/** Construct the immutable envelope; semantic release intent belongs to qualification. */
export function createVerifiedPackageEnvelope(metadata) {
const manifest = { schemaVersion: 2, ...metadata };
validateManifestShape(manifest);
return manifest;
}
/** Validate a retained package against the current checkout and workflow attempt. */
export async function validateVerifiedPackageEnvelope({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
allowLegacyManifest = false,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest, allowLegacyManifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
return { tarball, manifest };
}
function validateManifestShape(manifest, allowLegacyManifest = false) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const legacy = allowLegacyManifest && manifest.schemaVersion === 1;
const expectedKeys = legacy
? MANIFEST_KEYS.filter((key) => key !== "releaseTag")
: MANIFEST_KEYS;
const keys = Object.keys(manifest).sort();
if (
keys.length !== expectedKeys.length ||
keys.some((key, index) => key !== expectedKeys[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 2 && !legacy)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (!legacy && !["latest", "next"].includes(manifest.releaseTag))
throw new Error("Verified package manifest has an invalid release tag");
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
export async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
/** Require the registry to serve the exact archive selected for publication. */
export async function verifyPublishedPackageIntegrity(tarball, integrity) {
const hash = createHash("sha512");
for await (const chunk of createReadStream(tarball)) hash.update(chunk);
if (integrity !== `sha512-${hash.digest("base64")}`)
throw new Error(
"Published npm integrity differs from the original qualified package",
);
}
/** Bind each consumer to the checked-out source and the same workflow run. */
export async function workflowPackageIdentity() {
const sourceSha = process.env.GITHUB_SHA;
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
return {
sourceSha,
runId: parsePositiveInteger(process.env.GITHUB_RUN_ID, "GITHUB_RUN_ID"),
runAttempt: parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
),
packageVersion: packageJson.version,
nodeVersion: process.version,
};
}
export function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
+16 -6
View File
@@ -6,10 +6,8 @@ import { promisify } from "node:util";
import { valid } from "semver";
import { z } from "zod";
import {
validateVerifiedPackage,
verifyPublishedPackageIntegrity,
} from "./verified-package.mjs";
import { validateVerifiedPackage } from "./verified-package.mjs";
import { verifyPublishedPackageIntegrity } from "./qualified-package-envelope.mjs";
const execute = promisify(execFile);
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
@@ -82,9 +80,19 @@ export function selectRecoveryRelease({
throw new Error(`Original release did not pass ${name}`);
const publish = requireJob("Publish to npm");
const writes = publish.steps.filter((step) => step.name === "Publish");
const verified = publish.steps.filter(
(step) => step.name === "Verify published npm bytes and dist-tag",
const verificationJobs = parsedJobs.filter(
(job) => job.name === "Verify published npm package",
);
if (
verificationJobs.length > 1 ||
verificationJobs.some((job) => job.status !== "completed")
)
throw new Error(
"Expected at most one completed original npm verification job",
);
const verified = [publish, ...verificationJobs]
.flatMap((job) => job.steps)
.filter((step) => step.name === "Verify published npm bytes and dist-tag");
if (
!(
(writes.length === 1 && writes[0].conclusion === "success") ||
@@ -118,6 +126,8 @@ export async function verifyRecoveryPackage({
runAttempt,
packageVersion: version,
nodeVersion: process.version,
// Existing published releases retain their original v1 artifact; never rewrite it.
allowLegacyManifest: true,
});
await verifyPublishedPackageIntegrity(tarball, integrity);
return { version, sourceSha, runId, runAttempt, integrity };
+3
View File
@@ -109,7 +109,10 @@ export const SCOPES = {
".github/workflows/release-recovery.yml",
"scripts/ci/release-recovery.mjs",
"scripts/ci/verified-package.mjs",
"scripts/ci/qualified-package-envelope.mjs",
"scripts/ci/prepare-npm-publication.mjs",
"scripts/ci/npm-release.*",
"scripts/ci/npm-release-state.*",
"scripts/verify-release-artifact.mjs",
"scripts/lib/release-package-canary.mjs",
"scripts/verify-published-package.mjs",
+43 -178
View File
@@ -1,28 +1,19 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { copyFile, readFile, writeFile } from "node:fs/promises";
import { copyFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { valid } from "semver";
import { releaseNpmTag } from "../release-npm-tag.mjs";
import {
MANIFEST_NAME,
TARBALL_NAME,
createVerifiedPackageEnvelope,
parsePositiveInteger,
sha256File,
validateVerifiedPackageEnvelope,
workflowPackageIdentity,
} from "./qualified-package-envelope.mjs";
const MANIFEST_NAME = "manifest.json";
const TARBALL_NAME = "package.tgz";
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"sha256",
].sort();
const execFileAsync = promisify(execFile);
/** Normalize workflow identity and host facts into the manifest-owned fields. */
/** Qualification owns the exact SemVer and its parsed publication channel. */
export function createVerifiedPackageMetadata({
sourceSha,
runId,
@@ -42,28 +33,38 @@ export function createVerifiedPackageMetadata({
architecture,
packageVersion,
nodeVersion,
releaseTag: releaseNpmTag(packageVersion),
};
}
/** Build the single supported manifest representation for a verified tarball. */
function validateReleaseIntent(manifest) {
if (valid(manifest.packageVersion) !== manifest.packageVersion)
throw new Error("Verified package manifest has an invalid package version");
if (
manifest.schemaVersion === 2 &&
releaseNpmTag(manifest.packageVersion) !== manifest.releaseTag
)
throw new Error("Verified package release tag mismatch");
}
function createVerifiedPackageManifest(metadata) {
const manifest = { schemaVersion: 1, ...metadata };
validateManifestShape(manifest);
const manifest = createVerifiedPackageEnvelope(metadata);
validateReleaseIntent(manifest);
return manifest;
}
/** Write a manifest after the caller has completed qualification and cleanup. */
/** Write only after qualification and its cleanup have completed. */
export async function writeVerifiedPackageManifest(directory, metadata) {
const manifest = createVerifiedPackageManifest(metadata);
await writeFile(
join(directory, MANIFEST_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
{ encoding: "utf8" },
"utf8",
);
return manifest;
}
/** Copy the qualified archive under its fixed name and prepare its manifest. */
/** Retain the exact archive without unpacking or rebuilding it. */
export async function retainVerifiedPackage({
sourceTarball,
directory,
@@ -71,174 +72,40 @@ export async function retainVerifiedPackage({
}) {
const tarball = join(directory, TARBALL_NAME);
await copyFile(sourceTarball, tarball);
const sha256 = await sha256File(tarball);
return {
directory,
metadata: createVerifiedPackageManifest({ ...metadata, sha256 }),
metadata: createVerifiedPackageManifest({
...metadata,
sha256: await sha256File(tarball),
}),
};
}
/** Validate a retained package against the current checkout and workflow attempt. */
export async function validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
/** Read-only consumers additionally validate SemVer and its qualified channel. */
export async function validateVerifiedPackage(options) {
const { tarball, manifest } = await validateVerifiedPackageEnvelope(options);
validateReleaseIntent(manifest);
return tarball;
}
function validateManifestShape(manifest) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const keys = Object.keys(manifest).sort();
if (
keys.length !== MANIFEST_KEYS.length ||
keys.some((key, index) => key !== MANIFEST_KEYS[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 1)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (valid(manifest.packageVersion) !== manifest.packageVersion)
throw new Error("Verified package manifest has an invalid package version");
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
/** Require the registry to serve the exact archive selected for publication. */
export async function verifyPublishedPackageIntegrity(tarball, integrity) {
const hash = createHash("sha512");
for await (const chunk of createReadStream(tarball)) hash.update(chunk);
if (integrity !== `sha512-${hash.digest("base64")}`)
throw new Error(
"Published npm integrity differs from the original qualified package",
);
}
async function validateFromCli(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = parsePositiveInteger(
process.env.GITHUB_RUN_ID,
"GITHUB_RUN_ID",
);
const runAttempt = parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
);
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const tarball = await validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion: packageJson.version,
nodeVersion: process.version,
});
const expected = await workflowPackageIdentity();
const tarball = await validateVerifiedPackage({ directory, ...expected });
if (process.env.GITHUB_OUTPUT === undefined)
throw new Error(
"GITHUB_OUTPUT is required to publish the validated tarball path",
);
await writeFile(
process.env.GITHUB_OUTPUT,
`tarball=${tarball}\nversion=${packageJson.version}\n`,
{
encoding: "utf8",
flag: "a",
},
`tarball=${tarball}\nversion=${expected.packageVersion}\n`,
{ encoding: "utf8", flag: "a" },
);
process.stdout.write(`${tarball}\n`);
}
function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
resolve(invokedPath) === fileURLToPath(import.meta.url)
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const [command, directory, ...extra] = process.argv.slice(2);
if (command !== "validate" || directory === undefined || extra.length > 0) {
@@ -246,7 +113,5 @@ if (
"Usage: node scripts/ci/verified-package.mjs validate <artifact-directory>\n",
);
process.exitCode = 2;
} else {
await validateFromCli(directory);
}
} else await validateFromCli(directory);
}