repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/release/index.ts
Rawimport { 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() ?? [];
}