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 { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; 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. */ export function createVerifiedPackageMetadata({ sourceSha, runId, runAttempt, platform, architecture, packageVersion, nodeVersion, }) { if (!/^[a-f0-9]{40}$/u.test(sourceSha)) throw new Error("Source SHA must be a full commit SHA"); return { sourceSha, runId: parsePositiveInteger(runId, "GITHUB_RUN_ID"), runAttempt: parsePositiveInteger(runAttempt, "GITHUB_RUN_ATTEMPT"), platform, architecture, packageVersion, nodeVersion, }; } /** Build the single supported manifest representation for a verified tarball. */ function createVerifiedPackageManifest(metadata) { const manifest = { schemaVersion: 1, ...metadata }; validateManifestShape(manifest); return manifest; } /** Write a manifest after the caller has completed qualification and cleanup. */ export async function writeVerifiedPackageManifest(directory, metadata) { const manifest = createVerifiedPackageManifest(metadata); await writeFile( join(directory, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8" }, ); return manifest; } /** Copy the qualified archive under its fixed name and prepare its manifest. */ export async function retainVerifiedPackage({ sourceTarball, directory, metadata, }) { const tarball = join(directory, TARBALL_NAME); await copyFile(sourceTarball, tarball); const sha256 = await sha256File(tarball); return { directory, metadata: createVerifiedPackageManifest({ ...metadata, sha256 }), }; } /** Validate a retained package against the current checkout and workflow attempt. */ 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"); 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 ( 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"); } 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, }); 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}\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) ) { const [command, directory, ...extra] = process.argv.slice(2); if (command !== "validate" || directory === undefined || extra.length > 0) { process.stderr.write( "Usage: node scripts/ci/verified-package.mjs validate \n", ); process.exitCode = 2; } else { await validateFromCli(directory); } }