Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/release.ts

Raw
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<string, string[]> }>,
) {
	const pi: Record<string, string[]> = {};
	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<void> {
	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();