import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; import { DIST_ROOT, ROOT, rootVersion } from "../build/build.ts"; import { BUILD_MANIFEST, listFiles } from "../build/publish.ts"; const RELEASE_ROOT: string = join(ROOT, ".pi", "release"); const FIXTURE_ROOT = join(ROOT, "test", "fixtures", "release"); interface PublishConfig { publish: true; name: string; git: string; scenario: string; } function fail(message: string): never { throw new Error(`release: ${message}`); } function json(path: string): Record { return JSON.parse(readFileSync(path, "utf8")) as Record; } function selectedPublishConfig( name: string, root: string = ROOT, ): PublishConfig { const pkg = json(join(root, "extensions", name, "package.json")); const value = pkg["pi-ext"]; const config = typeof value === "object" && value ? (value as Record) : {}; if (config["publish"] !== true) fail(`${name} is not eligible; set pi-ext.publish: true`); if (typeof config["publicName"] !== "string" || !config["publicName"]) fail(`${name} needs pi-ext.publicName`); if (typeof config["git"] !== "string" || !config["git"]) fail(`${name} needs pi-ext.git destination`); if ( typeof config["releaseScenario"] !== "string" || !config["releaseScenario"] ) fail(`${name} needs pi-ext.releaseScenario`); const scenario = resolve( join(root, "extensions", name), config["releaseScenario"], ); if ( !scenario.startsWith(resolve(join(root, "extensions", name)) + sep) || !existsSync(scenario) ) fail(`${name} release scenario is missing or unsafe`); return { publish: true, name: config["publicName"], git: config["git"], scenario, }; } function sha(text: string | Buffer): string { return createHash("sha256").update(text).digest("hex"); } /** Deterministic tree digest, rejecting symlinks and path escapes. */ export function digestTree(dir: string): string { const hash = createHash("sha256"); for (const file of listFiles(dir)) { if (file.includes("..") || file.startsWith("/")) fail(`unsafe package path ${file}`); const path = join(dir, file); if (statSync(path).isSymbolicLink()) fail(`symlink forbidden in package: ${file}`); hash.update(`${file}\0${sha(readFileSync(path))}\n`); } return hash.digest("hex"); } function generationFiles(dir: string): string[] { const manifest = json(join(dir, BUILD_MANIFEST)); if ( !Array.isArray(manifest["files"]) || !manifest["files"].every( (file) => typeof file === "string" && !file.startsWith(".") && !file.includes(".."), ) ) fail(`${dir} has invalid ${BUILD_MANIFEST}`); return [...manifest["files"]].sort(); } function digestGeneration(dir: string): string { const hash = createHash("sha256"); for (const file of generationFiles(dir)) hash.update(`${file}\0${sha(readFileSync(join(dir, file)))}\n`); return hash.digest("hex"); } function standaloneFiles(snapshot: string, scenario: string): void { const ci = join(snapshot, ".ci"); mkdirSync(ci, { recursive: true }); writeFileSync( join(ci, "standalone.kdl"), 'job "ci" {\n image "node:24-alpine"\n trigger { push { branch "trunk" tag "v*" } }\n run "mise run ci"\n}\n', ); writeFileSync( join(snapshot, "mise.toml"), '[tasks.ci]\ndescription = "Verify standalone package"\nrun = "node verify-package.mjs scenario.mjs"\n', ); cpSync( join(ROOT, "test", "fixture", "scripted-provider.mjs"), join(snapshot, "scripted-provider.mjs"), ); cpSync(scenario, join(snapshot, "scenario.mjs")); cpSync( join(FIXTURE_ROOT, "verify-package.mjs"), join(snapshot, "verify-package.mjs"), ); } export interface SnapshotReceipt { version: string; extension: string; package: string; digest: string; distDigest: string; path: string; git: string; } /** Copy exact current generation, add standalone verification, then hash it. */ export function snapshot( name: string, options: { root?: string; distRoot?: string; releaseRoot?: string } = {}, ): SnapshotReceipt { const root = options.root ?? ROOT; const distRoot = options.distRoot ?? DIST_ROOT; const releaseRoot = options.releaseRoot ?? RELEASE_ROOT; const config = selectedPublishConfig(name, root); const dist = join(distRoot, name); if (!existsSync(dist)) fail( `${name} has no built package at ${dist}; run build-ext --release first`, ); mkdirSync(releaseRoot, { recursive: true }); const stage = mkdtempSync(join(tmpdir(), "pi-ext-release-stage-")); const distDigest = digestGeneration(dist); try { for (const file of generationFiles(dist)) { const source = join(dist, file); const target = join(stage, file); mkdirSync(join(target, ".."), { recursive: true }); cpSync(source, target, { dereference: false }); } const packageJson = json(join(stage, "package.json")); packageJson["name"] = config.name; packageJson["version"] = rootVersion(root); packageJson["private"] = false; delete packageJson["scripts"]; packageJson["files"] = [ ...generationFiles(dist).filter( (file) => !file.endsWith(".map") && file !== BUILD_MANIFEST, ), "package.json", ]; writeFileSync( join(stage, "package.json"), `${JSON.stringify(packageJson, null, "\t")}\n`, ); standaloneFiles(stage, config.scenario); const digest = digestTree(stage); const target = join(releaseRoot, digest, name); mkdirSync(join(target, ".."), { recursive: true }); if (!existsSync(target)) cpSync(stage, target, { recursive: true, dereference: false }); const receipt: SnapshotReceipt = { version: rootVersion(root), extension: name, package: config.name, digest, distDigest, path: target, git: config.git, }; writeFileSync( join(releaseRoot, `${name}.json`), `${JSON.stringify(receipt, null, "\t")}\n`, ); return receipt; } finally { rmSync(stage, { recursive: true, force: true }); } } export function verifiedReceipt( name: string, options: { distRoot?: string; releaseRoot?: string } = {}, ): SnapshotReceipt { const releaseRoot = options.releaseRoot ?? RELEASE_ROOT; const path = join(releaseRoot, `${name}.json`); if (!existsSync(path)) fail(`${name} has no verified snapshot receipt`); const receipt = json(path) as unknown as SnapshotReceipt; if (receipt.extension !== name || !receipt.digest || !receipt.path) fail(`${name} receipt is invalid`); if (digestTree(receipt.path) !== receipt.digest) fail(`${name} snapshot digest mismatch`); const current = join(options.distRoot ?? DIST_ROOT, name); if (digestGeneration(current) !== receipt.distDigest) fail(`${name} current dist differs from verified snapshot`); return receipt; } function run(command: string, args: string[], cwd: string): void { const result = spawnSync(command, args, { cwd, encoding: "utf8" }); if (result.status !== 0) fail( `${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`, ); } /** Local Git delivery is intentionally created only after receipt/digest gates pass. */ export function deliverGit( name: string, options: { distRoot?: string; releaseRoot?: string; remote?: boolean } = {}, ): string { const receipt = verifiedReceipt(name, options); const repo = mkdtempSync(join(tmpdir(), "pi-ext-release-git-")); cpSync(receipt.path, repo, { recursive: true, dereference: false }); if (digestTree(repo) !== receipt.digest) fail(`${name} Git delivery bytes differ from verified snapshot`); run("git", ["init", "-q"], repo); run("git", ["add", "."], repo); run( "git", [ "-c", "user.name=pi-ext", "-c", "user.email=release@invalid", "commit", "-qm", `release: v${receipt.version}`, ], repo, ); run("git", ["-c", "tag.gpgSign=false", "tag", `v${receipt.version}`], repo); if (options.remote) { run("git", ["remote", "add", "origin", receipt.git], repo); run("git", ["push", "origin", "HEAD:trunk", `v${receipt.version}`], repo); } return repo; } export function npmPackFiles(dir: string): string[] { const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { cwd: dir, encoding: "utf8", }); if (result.status !== 0) fail(`npm pack failed: ${result.stderr || result.stdout}`); const packed = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }>; }>; return packed[0]?.files.map((file) => file.path).sort() ?? []; }