import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import { join, sep } from "node:path"; import { allExtensionNames, buildExtension, ROOT, rootVersion, } from "./build.ts"; import { listFiles } from "./publish.ts"; import { readBuildSpec } from "./spec.ts"; import { verifyCollection, verifyPackage } from "./verify.ts"; export function treeDigest(dir: string): string { const hash = createHash("sha256"); for (const file of listFiles(dir)) { const path = join(dir, file); const stat = lstatSync(path); if (stat.isSymbolicLink()) { if (!realpathSync(path).startsWith(realpathSync(dir) + sep)) throw new Error(`Symlink escapes package: ${path}`); hash.update(`${file}\0link:${readlinkSync(path)}\n`); } else { if (!stat.isFile()) throw new Error(`Not a regular file: ${path}`); const digest = createHash("sha256") .update(readFileSync(path)) .digest("hex"); hash.update(`${file}\0file:${digest}\n`); } } return hash.digest("hex"); } export function collectionManifest( version: string, packages: Array<{ name: string; pi: Record }>, ) { const pi: Record = {}; for (const pkg of packages) { for (const [kind, paths] of Object.entries(pkg.pi)) { pi[kind] ??= []; pi[kind].push(...paths.map((path) => `packages/${pkg.name}/${path}`)); } } return { name: "pi-ext", version, private: true, type: "module", pi }; } function tar(args: string[]): void { const result = spawnSync("tar", args, { encoding: "utf8" }); if (result.status !== 0) throw new Error(`tar failed: ${result.stderr}`); } /** Validate the actual archived bytes, not just the pre-pack directory. */ export function archivePackage( dir: string, archive: string, scratch: string, ): string { const digest = treeDigest(dir); tar(["-czf", archive, "-C", dir, "."]); const extracted = mkdtempSync(join(scratch, "extract-")); try { tar(["-xzf", archive, "-C", extracted]); if (treeDigest(extracted) !== digest) throw new Error(`Archive differs from package: ${archive}`); } finally { rmSync(extracted, { recursive: true, force: true }); } return createHash("sha256").update(readFileSync(archive)).digest("hex"); } export async function releaseAll(): Promise { const version = rootVersion(); const scratch = join(ROOT, ".pi", "tmp"); mkdirSync(scratch, { recursive: true }); const stage = mkdtempSync(join(scratch, "release-")); const output = join(scratch, "release-artifacts"); // This task owns only its disposable artifact output, never dist or source. rmSync(output, { recursive: true, force: true }); mkdirSync(output, { recursive: true }); const packages = []; const receipts = []; try { for (const name of allExtensionNames()) { const built = await buildExtension(name, { distRoot: join(stage, "packages"), release: true, }); const report = await verifyPackage(built.dir, { externals: readBuildSpec(join(ROOT, "extensions", name)).externals, }); if (!report.ok) throw new Error(`${name}: ${JSON.stringify(report)}`); const pkg = JSON.parse( readFileSync(join(built.dir, "package.json"), "utf8"), ); packages.push({ name, pi: pkg.pi }); const file = `${name}-${version}.tgz`; const digest = treeDigest(built.dir); const sha256 = archivePackage(built.dir, join(output, file), scratch); receipts.push({ extension: name, version, file, sha256, digest }); console.log( `OK release ${name}@${version}: relocated load + archive bytes`, ); } writeFileSync( join(stage, "package.json"), `${JSON.stringify(collectionManifest(version, packages), null, "\t")}\n`, ); cpSync(join(ROOT, "README.md"), join(stage, "README.md")); const collection = await verifyCollection([stage]); if (!collection.ok) throw new Error(`Collection: ${JSON.stringify(collection)}`); const file = `pi-ext-${version}.tgz`; const sha256 = archivePackage(stage, join(output, file), scratch); writeFileSync( join(output, "manifest.json"), `${JSON.stringify({ version, collection: { file, sha256, digest: treeDigest(stage) }, extensions: receipts }, null, "\t")}\n`, ); console.log( `OK release pi-ext@${version}: ${packages.length} extensions; ${output}`, ); } catch (error) { rmSync(output, { recursive: true, force: true }); throw error; } finally { rmSync(stage, { recursive: true, force: true }); } } if (import.meta.main) await releaseAll();