openclaw/scripts/release-prepare.ts
Peter Steinberger 3cb68020b6
refactor(scripts): deslop shared tooling
## What Problem This Solves

Repository scripts retain private copies of shared tooling helpers and an unused translation subprocess runner, increasing maintenance work across sibling scripts.

## User Impact

Internal tooling cleanup: existing flags, diagnostics, generated bytes, JSON formats, exit codes, and cleanup policies stay the same. The build wrapper additionally detects forbidden dynamic-import warnings when compiler output splits the warning marker across chunks.

## Why This Change Was Made

- Remove the retired i18n process runner. Its final production caller moved to the shared formatter in #95534; retain real CLI/privacy/provider tests and run their subprocesses with the existing dependency.
- Share Docker resource, signal-trap, platform, workspace-staging, and plugin-selection helpers while retaining each caller's environment precedence and lifecycle decisions.
- Reuse existing E2E fixture JSON/assertion and package-path helpers; retain distinct write modes, recovery ordering, and mounted/frozen harness contracts.
- Share macOS guest desktop-user/home resolution through its existing owner while preserving per-caller timeout policy.
- Route release/mobile flags through the existing version flag specifications, preserving split-only values, duplicate/mode rules, help timing, and error text. Reuse existing retry sleep and comparator owners and remove an unreachable iOS output branch.
- Reuse existing guard entrypoint, failure-trailer, diagnostic-line, and metadata-normalization owners.

Measured reduction: **732 net production lines**, with test changes counted separately. PR tooling and its protected import closure, CI planners/shards, baselines, and generated artifacts are unchanged.

## Fixes Found Along the Way

The tsdown scanner checked each raw output chunk for its warning marker. It now checks the existing combined-line buffer. The regression exercises every split inside the marker and fails on the original source for the intended missing-warning assertion.

## Evidence

- Blacksmith Testbox `tbx_01m3tp4sabwd0807kj9jkqqmbr`: frozen dependency install and candidate source-byte verification; all nine changed test files and 59 selected sibling files passed, including real CLI, package fixture, recovery, Docker harness, and Parallels transport contracts.
- Generated channel metadata is byte-identical; differential metadata normalization cases passed.
- SDK surface check passed. Independent isolated Codex review completed with no P0–P2 findings.
- Initial changed checks caught an invalid direct source import in a proposed snapshot-distance consolidation; that independent change was withdrawn. No boundary exception was added. The final changed checks pass, including script/test lint and Docker shell/scheduler checks; madge reports 0 cycles and the runtime import check reports 0 cycles.
- Per-file single-worker wall time for changed suites: translation 9.92s; Docker helper 38.88s; live Docker auth 1.71s; mobile ref 2.36s; mobile release 6.23s; release preparation 2.42s; version 2.04s; installer 15.01s; tsdown 2.70s. The existing Docker helper suite executes shell/process cleanup and container-command boundary fixtures; new assertions reuse those fixtures. New parser/scanner cases use no sleeps or polling.

Hosted CI will be verified against this PR's exact pushed head. No live deployment or release was performed.
2026-10-01 05:14:07 +00:00

381 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 { expectDefined } from "../packages/normalization-core/src/expect.js";
import { booleanFlag, parseFlagArgs } from "./lib/arg-utils.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { parseReleaseVersion } from "./lib/release-version.mjs";
import { versionValueFlag } from "./lib/version-script-args.ts";
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 {
const args: ReleasePrepareArgs = {
android: false,
help: false,
jobs: DEFAULT_JOBS,
json: false,
manifestPath: null,
mode: "shadow",
rootDir: path.resolve("."),
version: null,
};
let modeFlag: string | null = null;
return parseFlagArgs(
argv,
args,
[
booleanFlag("--android", "android", true, { repeatable: true }),
booleanFlag("--json", "json", true, { repeatable: true }),
booleanFlag("-h", "help", true, { repeatable: true }),
booleanFlag("--help", "help", true, { repeatable: true }),
versionValueFlag("--jobs", "jobs", parseJobs),
versionValueFlag("--manifest", "manifestPath", path.resolve),
versionValueFlag("--root", "rootDir", path.resolve),
versionValueFlag("--version", "version"),
],
{
onUnhandledArg(arg) {
if (arg === "--check" || arg === "--shadow" || arg === "--write") {
if (modeFlag) {
throw new Error(`Use only one mode flag; received ${modeFlag} and ${arg}.`);
}
modeFlag = arg;
args.mode = arg === "--check" ? "check" : arg === "--shadow" ? "shadow" : "write";
return "handled";
}
throw new Error(`Unknown argument: ${arg}`);
},
},
);
}
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-beta.N, or YYYY.M.PATCH-N.`,
);
}
if (parsedVersion.channel === "alpha") {
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
}
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 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;
}
}