mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 05:26:05 +00:00
400 lines
11 KiB
TypeScript
400 lines
11 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 { parseReleaseVersion } from "./lib/npm-publish-plan.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;
|
|
|
|
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 function main(argv = process.argv.slice(2)): number {
|
|
const args = parseReleasePrepareArgs(argv);
|
|
if (args.help) {
|
|
printUsage();
|
|
return 0;
|
|
}
|
|
const steps = createReleasePrepareSteps(args);
|
|
const before = readWorktreeState(args.rootDir);
|
|
const results = runReleasePrepareSteps({
|
|
cwd: args.rootDir,
|
|
json: args.json,
|
|
mode: args.mode,
|
|
steps,
|
|
});
|
|
const after = 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,
|
|
encoding: json ? "utf8" : undefined,
|
|
env: process.env,
|
|
stdio: json ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (json) {
|
|
if (typeof result.stdout === "string" && result.stdout) {
|
|
process.stderr.write(result.stdout);
|
|
}
|
|
if (typeof result.stderr === "string" && result.stderr) {
|
|
process.stderr.write(result.stderr);
|
|
}
|
|
}
|
|
return result.status ?? 1;
|
|
}
|
|
|
|
function readWorktreeState(rootDir: string): WorktreeState {
|
|
const head = git(rootDir, ["rev-parse", "HEAD"]);
|
|
const status = git(rootDir, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
const diff = git(rootDir, ["diff", "--binary", "HEAD"]);
|
|
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: crypto.createHash("sha256").update(`${head}\0${status}\0${diff}`).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,
|
|
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 include the independently pinned Android release train",
|
|
" --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 = main();
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|