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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user