openclaw/scripts/release-prepare.ts
Peter Steinberger 59e8bf4ad1
fix(release): stream large preparation fingerprints (#138367)
Hash generated Git diffs through the managed command owner instead of
buffering them behind a 64 MiB ceiling. Await both snapshots and child
closure before recording the candidate manifest. A real-Git regression
covers the former limit and verifies that tail changes affect the digest.
2026-09-04 09:20:51 -07:00

411 lines
12 KiB
TypeScript

// Release Prepare provides one bounded entrypoint for version alignment and generated metadata.
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { parseReleaseVersion } from "./lib/release-version.mjs";
type ReleasePrepareMode = "check" | "shadow" | "write";
type ReleasePrepareArgs = {
android: boolean;
help: boolean;
jobs: number;
json: boolean;
manifestPath: string | null;
mode: ReleasePrepareMode;
rootDir: string;
version: string | null;
};
type ReleasePrepareStep = {
args: string[];
command: string;
id: "generated-version-metadata" | "release-version";
name: string;
};
type ReleasePrepareStepResult = ReleasePrepareStep & {
durationMs: number;
status: "failed" | "passed" | "planned" | "skipped";
};
type WorktreeState = {
changedFiles: string[];
fingerprint: string;
head: string;
packageVersion: string;
status: string;
};
const DEFAULT_JOBS = 4;
const MAX_JOBS = 16;
const GIT_OUTPUT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
export function parseReleasePrepareArgs(argv: string[]): ReleasePrepareArgs {
let android = false;
let help = false;
let jobs = DEFAULT_JOBS;
let json = false;
let manifestPath: string | null = null;
let mode: ReleasePrepareMode = "shadow";
let modeFlag: string | null = null;
let rootDir = path.resolve(".");
let version: string | null = null;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--": {
break;
}
case "--android": {
android = true;
break;
}
case "--check":
case "--shadow":
case "--write": {
if (modeFlag) {
throw new Error(`Use only one mode flag; received ${modeFlag} and ${arg}.`);
}
modeFlag = arg;
mode = arg.slice(2) as ReleasePrepareMode;
break;
}
case "--jobs": {
jobs = parseJobs(readOptionValue(argv, index, arg));
index += 1;
break;
}
case "--json": {
json = true;
break;
}
case "--manifest": {
manifestPath = path.resolve(readOptionValue(argv, index, arg));
index += 1;
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, arg));
index += 1;
break;
}
case "--version": {
version = readOptionValue(argv, index, arg);
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { android, help, jobs, json, manifestPath, mode, rootDir, version };
}
export function createReleasePrepareSteps(
args: Pick<ReleasePrepareArgs, "android" | "jobs" | "mode" | "rootDir" | "version">,
): ReleasePrepareStep[] {
if (!args.version) {
throw new Error("Missing required --version.");
}
const parsedVersion = parseReleaseVersion(args.version);
if (!parsedVersion) {
throw new Error(
`Invalid release version '${args.version}'. Expected YYYY.M.PATCH, YYYY.M.PATCH-alpha.N, YYYY.M.PATCH-beta.N, or YYYY.M.PATCH-N.`,
);
}
const versionArgs = [
"--import",
"tsx",
"scripts/release-version.ts",
"--root",
args.rootDir,
"--version",
parsedVersion.version,
];
if (args.android) {
versionArgs.push("--android");
}
if (args.mode === "write") {
versionArgs.push("--write");
}
const preflightArgs = [
"scripts/release-preflight.mjs",
args.mode === "write" ? "--fix" : "--check",
"--scope",
"version",
"--jobs",
String(args.jobs),
];
return [
{
args: versionArgs,
command: process.execPath,
id: "release-version",
name: "release version alignment",
},
{
args: preflightArgs,
command: process.execPath,
id: "generated-version-metadata",
name: "version-owned generated metadata",
},
];
}
export function runReleasePrepareSteps(params: {
cwd: string;
json?: boolean;
mode: ReleasePrepareMode;
runStep?: (step: ReleasePrepareStep, cwd: string) => number;
steps: ReleasePrepareStep[];
}): ReleasePrepareStepResult[] {
if (params.mode === "shadow") {
return params.steps.map((step) => ({ ...step, durationMs: 0, status: "planned" }));
}
const runStep =
params.runStep ??
((step: ReleasePrepareStep, cwd: string) =>
runReleasePrepareStep(step, cwd, { json: params.json ?? false }));
const results: ReleasePrepareStepResult[] = [];
let blocked = false;
for (const step of params.steps) {
if (blocked) {
results.push({ ...step, durationMs: 0, status: "skipped" });
continue;
}
const startedAt = performance.now();
const exitCode = runStep(step, params.cwd);
const status = exitCode === 0 ? "passed" : "failed";
results.push({
...step,
durationMs: Math.round(performance.now() - startedAt),
status,
});
blocked = status === "failed";
}
return results;
}
export function buildReleasePreparationManifest(params: {
after: WorktreeState;
before: WorktreeState;
mode: ReleasePrepareMode;
steps: ReleasePrepareStepResult[];
version: string;
}) {
const status = params.steps.some((step) => step.status === "failed")
? "failed"
: params.mode === "shadow"
? "shadow"
: "passed";
return {
schemaVersion: 1,
requestedVersion: params.version,
mode: params.mode,
status,
sourceHead: params.before.head,
candidateFingerprint: params.after.fingerprint,
before: params.before,
after: params.after,
steps: params.steps.map((step) => ({
id: step.id,
name: step.name,
command: [step.command, ...step.args],
status: step.status,
durationMs: step.durationMs,
})),
};
}
export async function main(argv = process.argv.slice(2)): Promise<number> {
const args = parseReleasePrepareArgs(argv);
if (args.help) {
printUsage();
return 0;
}
const steps = createReleasePrepareSteps(args);
const before = await readWorktreeState(args.rootDir);
const results = runReleasePrepareSteps({
cwd: args.rootDir,
json: args.json,
mode: args.mode,
steps,
});
const after = await readWorktreeState(args.rootDir);
const manifest = buildReleasePreparationManifest({
after,
before,
mode: args.mode,
steps: results,
version: args.version!,
});
const manifestPath =
args.manifestPath ?? defaultManifestPath(args.rootDir, args.version!, after.fingerprint);
writeJsonAtomic(manifestPath, manifest);
if (args.json) {
process.stdout.write(`${JSON.stringify({ manifestPath, ...manifest }, null, 2)}\n`);
} else {
const verb = args.mode === "shadow" ? "planned" : manifest.status;
process.stdout.write(
`Release preparation ${verb} for ${args.version}; manifest: ${manifestPath}\n`,
);
for (const step of results) {
process.stdout.write(`- ${step.name}: ${step.status} (${step.durationMs}ms)\n`);
}
}
return manifest.status === "failed" ? 1 : 0;
}
export function runReleasePrepareStep(
step: ReleasePrepareStep,
cwd: string,
options: { json?: boolean } = {},
): number {
const json = options.json ?? false;
const progressStream = json ? process.stderr : process.stdout;
progressStream.write(`\n[release-prepare] ${step.name}\n`);
const result = spawnSync(step.command, step.args, {
cwd,
env: process.env,
stdio: json ? ["ignore", process.stderr.fd, process.stderr.fd] : "inherit",
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
}
export async function readWorktreeState(rootDir: string): Promise<WorktreeState> {
const head = git(rootDir, ["rev-parse", "HEAD"]);
const status = git(rootDir, ["status", "--porcelain=v1", "--untracked-files=all"]);
const fingerprint = crypto.createHash("sha256").update(`${head}\0${status}\0`);
// Generated locale diffs can exceed buffered child-output limits. Hash every
// byte as it arrives, and publish the digest only after Git closes successfully.
const diffExit = await runManagedCommand({
bin: "git",
args: ["diff", "--binary", "HEAD"],
cwd: rootDir,
stdio: ["ignore", "pipe", "inherit"],
onReady(child) {
expectDefined(child.stdout, "git diff output").on("data", (chunk: Buffer) => {
fingerprint.update(chunk);
});
},
});
if (diffExit !== 0) {
throw new Error(`git diff --binary HEAD failed (exit ${diffExit})`);
}
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8")) as {
version?: unknown;
};
const packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
const changedFiles = status
.split("\n")
.filter(Boolean)
.map((line) => line.slice(3))
.toSorted();
return {
changedFiles,
fingerprint: fingerprint.digest("hex"),
head,
packageVersion,
status,
};
}
function defaultManifestPath(rootDir: string, version: string, fingerprint: string): string {
const gitPath = git(rootDir, [
"rev-parse",
"--git-path",
`openclaw-release-cache/candidates/${version}-${fingerprint.slice(0, 12)}.json`,
]);
return path.resolve(rootDir, gitPath);
}
function git(cwd: string, args: string[]): string {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
env: process.env,
// Bound metadata capture; the larger generated diff is streamed separately.
maxBuffer: GIT_OUTPUT_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
}
return result.stdout.trimEnd();
}
function writeJsonAtomic(filePath: string, value: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.tmp`;
try {
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tempPath, filePath);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function parseJobs(raw: string): number {
const jobs = Number(raw);
if (!Number.isInteger(jobs) || jobs < 1 || jobs > MAX_JOBS) {
throw new Error(`Invalid --jobs value '${raw}'. Expected 1 through ${MAX_JOBS}.`);
}
return jobs;
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
function printUsage(): void {
process.stdout.write(
[
"Usage: node --import tsx scripts/release-prepare.ts --version <version> [mode] [options]",
"",
"Modes:",
" --shadow emit the exact preparation plan and candidate manifest without commands (default)",
" --check verify an already prepared candidate without writing",
" --write align versions and refresh version-owned generated metadata",
"",
"Options:",
" --android prepare shared mobile and Android release metadata",
" --jobs <count> preflight concurrency, 1 through 16 (default: 4)",
" --manifest <path> override the git-local candidate manifest path",
" --json emit machine-readable output",
"",
].join("\n"),
);
}
if (import.meta.main) {
try {
process.exitCode = await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}