mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
## 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.
381 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|