Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/release/index.ts

Raw
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<string, unknown> {
	return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
}

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<string, unknown>)
			: {};
	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() ?? [];
}