Files
openclaw/scripts/release-preflight.mjs
2026-07-10 17:47:32 -07:00

424 lines
14 KiB
JavaScript

#!/usr/bin/env node
// Checks or refreshes generated release artifacts before a release publish.
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runManagedCommand } from "./lib/managed-child-process.mjs";
import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs";
const parsedArgs = parseArgs(process.argv.slice(2));
const fix = parsedArgs.fix;
const macosInfoPlistPath = "apps/macos/Sources/OpenClaw/Resources/Info.plist";
const nodeCommand = (...args) => ({ args, bin: "node" });
const pnpmCommand = (...args) => ({ args, bin: "pnpm" });
const releaseTasks = [
{
id: "root-dependency-ownership",
name: "root dependency ownership",
scopes: ["dependencies"],
check: pnpmCommand("deps:root-ownership:check"),
},
{
id: "plugin-versions",
name: "plugin versions",
scopes: ["plugins", "version"],
fix: nodeCommand("--import", "tsx", "scripts/sync-plugin-versions.ts"),
check: nodeCommand("--import", "tsx", "scripts/sync-plugin-versions.ts", "--check"),
},
{
id: "npm-shrinkwraps",
name: "npm shrinkwraps",
scopes: ["dependencies", "plugins", "version"],
fix: nodeCommand("scripts/generate-npm-shrinkwrap.mjs", "--changed"),
fixAfter: ["plugin-versions", "plugin-sdk-exports"],
check: nodeCommand("scripts/generate-npm-shrinkwrap.mjs", "--all", "--check"),
},
{
id: "plugin-inventory",
name: "plugin inventory",
scopes: ["plugins", "version"],
fix: nodeCommand("scripts/generate-plugin-inventory-doc.mjs", "--write"),
fixAfter: ["plugin-versions", "plugin-sdk-exports"],
check: nodeCommand("scripts/generate-plugin-inventory-doc.mjs", "--check"),
},
{
id: "config-schema",
name: "base config schema",
scopes: ["config"],
fix: pnpmCommand("config:schema:gen"),
check: pnpmCommand("config:schema:check"),
},
{
id: "channel-config",
name: "bundled channel config metadata",
scopes: ["config"],
fix: pnpmCommand("config:channels:gen"),
check: pnpmCommand("config:channels:check"),
},
{
id: "config-docs",
name: "config docs baseline",
scopes: ["config"],
fix: pnpmCommand("config:docs:gen"),
fixAfter: ["config-schema", "channel-config"],
check: pnpmCommand("config:docs:check"),
},
{
id: "plugin-sdk-exports",
name: "plugin SDK exports",
scopes: ["plugin-sdk"],
fix: pnpmCommand("plugin-sdk:sync-exports"),
fixAfter: ["plugin-versions"],
check: pnpmCommand("plugin-sdk:check-exports"),
},
{
id: "plugin-sdk-api",
name: "plugin SDK API baseline",
scopes: ["plugin-sdk"],
fix: pnpmCommand("plugin-sdk:api:gen"),
fixAfter: ["plugin-sdk-exports"],
check: pnpmCommand("plugin-sdk:api:check"),
},
{
id: "plugin-sdk-surface",
name: "plugin SDK surface budget",
scopes: ["plugin-sdk"],
check: pnpmCommand("plugin-sdk:surface:check"),
},
];
const selectedTasks = releaseTasks.filter((task) => taskMatchesScopes(task, parsedArgs.scopes));
const shouldCheckMacosVersions = parsedArgs.scopes.has("all") || parsedArgs.scopes.has("version");
// Release-evidence reuse validates version-stamp targets without running any
// package-manager commands; keep this mode dependency-free file reads only.
if (parsedArgs.macosVersionsOnly) {
const errors = collectMacosVersionErrors();
if (errors.length !== 0) {
for (const error of errors) {
console.error(`[release-preflight] macOS app version metadata: ${error}`);
}
process.exit(1);
}
console.log("[release-preflight] macOS app version metadata OK");
process.exit(0);
}
if (fix) {
console.log(
`[release-preflight] refreshing generated release artifacts (${formatScopes(parsedArgs.scopes)}, jobs=${parsedArgs.jobs})`,
);
const fixResult = await runTaskGraph({
commandKey: "fix",
jobs: parsedArgs.jobs,
tasks: selectedTasks,
});
if (fixResult.failed.length !== 0 || fixResult.skipped.length !== 0) {
printFailures("release preflight refresh failed", fixResult.failed);
printSkipped(fixResult.skipped);
process.exit(1);
}
}
console.log(
`[release-preflight] checking release generated artifacts and manifests (${formatScopes(parsedArgs.scopes)}, jobs=${parsedArgs.jobs})`,
);
const macosVersionErrors = [];
if (shouldCheckMacosVersions) {
console.log("\n[release-preflight] macOS app version metadata");
macosVersionErrors.push(...collectMacosVersionErrors());
if (macosVersionErrors.length === 0) {
console.log("[release-preflight] macOS app version metadata OK");
}
}
const { failed: checkFailures } = await runTaskGraph({
commandKey: "check",
jobs: parsedArgs.jobs,
tasks: selectedTasks,
});
if (macosVersionErrors.length !== 0 || checkFailures.length !== 0) {
console.error("\nrelease preflight found drift:");
for (const error of macosVersionErrors) {
console.error(`- macOS app version metadata: ${error}`);
}
printCommandFailures(checkFailures);
console.error(
"\nCorrect manual version metadata first. Run `pnpm release:prep` for intentional generated version/config/API changes, then commit the resulting files.",
);
process.exit(1);
}
console.log("[release-preflight] OK");
function collectMacosVersionErrors(rootDir = resolve(".")) {
const packageJsonPath = resolve(rootDir, "package.json");
const infoPlistPath = resolve(rootDir, macosInfoPlistPath);
let packageVersion;
let infoPlist;
try {
const parsedPackage = JSON.parse(readFileSync(packageJsonPath, "utf8"));
packageVersion = typeof parsedPackage.version === "string" ? parsedPackage.version.trim() : "";
} catch (error) {
return [`unable to read package.json: ${formatError(error)}`];
}
const releaseVersion = parseReleaseVersion(packageVersion);
if (!releaseVersion) {
return [`package.json has invalid release version ${JSON.stringify(packageVersion)}`];
}
try {
infoPlist = readFileSync(infoPlistPath, "utf8");
} catch (error) {
return [`unable to read ${macosInfoPlistPath}: ${formatError(error)}`];
}
const errors = [];
// The source plist tracks native base metadata. Packaging stamps the exact
// prerelease version and canonical Sparkle build into the copied app bundle.
const expectedShortVersion = releaseVersion.baseVersion;
const expectedBuildVersion = [
String(releaseVersion.year),
String(releaseVersion.month).padStart(2, "0"),
String(releaseVersion.patch).padStart(2, "0"),
"00",
].join("");
const shortVersion = readPlistString(infoPlist, "CFBundleShortVersionString");
const buildVersion = readPlistString(infoPlist, "CFBundleVersion");
if (shortVersion.error) {
errors.push(shortVersion.error);
} else if (shortVersion.value !== expectedShortVersion) {
errors.push(
`${macosInfoPlistPath} CFBundleShortVersionString is ${JSON.stringify(shortVersion.value)}; expected ${JSON.stringify(expectedShortVersion)} from package.json base version`,
);
}
if (buildVersion.error) {
errors.push(buildVersion.error);
} else if (buildVersion.value !== expectedBuildVersion) {
errors.push(
`${macosInfoPlistPath} CFBundleVersion is ${JSON.stringify(buildVersion.value)}; expected ${JSON.stringify(expectedBuildVersion)} for ${expectedShortVersion}`,
);
}
return errors;
}
function readPlistString(infoPlist, key) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`<key>\\s*${escapedKey}\\s*</key>\\s*<string>([^<]*)</string>`, "gu");
const matches = [...infoPlist.matchAll(pattern)];
if (matches.length !== 1) {
return {
error: `${macosInfoPlistPath} must contain exactly one string value for ${key}; found ${matches.length}`,
};
}
return { value: matches[0][1]?.trim() ?? "" };
}
function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
async function runTaskGraph({ commandKey, jobs, tasks }) {
const runnableTasks = tasks
.filter((task) => task[commandKey])
.map((task) => {
const command = task[commandKey];
return {
id: task.id,
name: task.name,
args: command.args,
bin: command.bin,
after: commandKey === "fix" ? (task.fixAfter ?? []) : [],
};
});
const selectedIds = new Set(runnableTasks.map((task) => task.id));
const pending = new Map(runnableTasks.map((task) => [task.id, task]));
const completed = new Set();
const failedIds = new Set();
const taskFailures = [];
const skipped = [];
while (pending.size > 0) {
for (const [taskId, task] of pending) {
const failedDependency = task.after.find(
(dependencyId) => selectedIds.has(dependencyId) && failedIds.has(dependencyId),
);
if (!failedDependency) {
continue;
}
skipped.push({ ...task, dependencyId: failedDependency });
failedIds.add(taskId);
pending.delete(taskId);
}
const ready = [...pending.values()].filter((task) =>
task.after.every(
(dependencyId) => !selectedIds.has(dependencyId) || completed.has(dependencyId),
),
);
if (ready.length === 0) {
if (pending.size === 0) {
break;
}
throw new Error(`release preflight task graph is blocked: ${[...pending.keys()].join(", ")}`);
}
for (let index = 0; index < ready.length; index += jobs) {
const batch = ready.slice(index, index + jobs);
const results = await Promise.all(
batch.map(async (task) => ({ task, status: await runCommand(task) })),
);
for (const { task, status } of results) {
pending.delete(task.id);
if (status === 0) {
completed.add(task.id);
} else {
failedIds.add(task.id);
taskFailures.push({ ...task, status });
}
}
}
}
return { failed: taskFailures, skipped };
}
async function runCommand(command) {
console.log(`\n[release-preflight] ${command.name}: ${formatCommand(command)}`);
try {
return await runManagedCommand({
args: command.args,
bin: command.bin,
});
} catch (error) {
console.error(error);
return 1;
}
}
function printFailures(title, failures) {
console.error(`\n${title}:`);
printCommandFailures(failures);
}
function printCommandFailures(failures) {
for (const failure of failures) {
console.error(`- ${failure.name}: exit ${failure.status} (${formatCommand(failure)})`);
}
}
function formatCommand(command) {
return [command.bin, ...command.args].join(" ");
}
function printSkipped(skipped) {
for (const task of skipped) {
console.error(`- ${task.name}: skipped because ${task.dependencyId} failed`);
}
}
function parseArgs(argv) {
let check = false;
let jobs = parseJobs(process.env.OPENCLAW_RELEASE_PREFLIGHT_JOBS ?? "4");
let wantsFix = false;
let macosVersionsOnly = false;
const scopes = new Set();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help") {
printUsage(console.log);
process.exit(0);
}
if (arg === "--check") {
check = true;
continue;
}
if (arg === "--fix") {
wantsFix = true;
continue;
}
if (arg === "--macos-versions-only") {
macosVersionsOnly = true;
continue;
}
if (arg === "--jobs") {
jobs = parseJobs(readOptionValue(argv, index, arg));
index += 1;
continue;
}
if (arg === "--scope") {
const scope = readOptionValue(argv, index, arg);
if (!["all", "config", "dependencies", "plugin-sdk", "plugins", "version"].includes(scope)) {
console.error(`Unknown release preflight scope: ${scope}`);
printUsage(console.error);
process.exit(1);
}
scopes.add(scope);
index += 1;
continue;
}
console.error(`Unknown release preflight argument: ${arg}`);
printUsage(console.error);
process.exit(1);
}
if (wantsFix && check) {
console.error("Use either --fix or --check, not both.");
process.exit(1);
}
if (macosVersionsOnly && (wantsFix || check)) {
console.error("Use --macos-versions-only without --fix or --check.");
process.exit(1);
}
if (macosVersionsOnly && scopes.size !== 0) {
console.error("Use --macos-versions-only without --scope.");
process.exit(1);
}
if (scopes.size === 0) {
scopes.add("all");
}
return { fix: wantsFix, jobs, macosVersionsOnly, scopes };
}
function printUsage(writeLine) {
writeLine(
"Usage: node scripts/release-preflight.mjs [--check|--fix] [--scope name] [--jobs count]",
);
writeLine(" node scripts/release-preflight.mjs --macos-versions-only");
writeLine("");
writeLine(" --check verify generated release artifacts without writing changes (default)");
writeLine(" --fix refresh generated release artifacts, then verify them");
writeLine(
" --scope name all, version, dependencies, plugins, config, or plugin-sdk; repeatable",
);
writeLine(" --jobs count maximum concurrent commands (default: 4)");
writeLine(" --macos-versions-only verify macOS source version metadata only, no commands");
}
function readOptionValue(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
console.error(`Missing value for ${flag}.`);
printUsage(console.error);
process.exit(1);
}
return value;
}
function parseJobs(raw) {
const jobs = Number(raw);
if (!Number.isInteger(jobs) || jobs < 1 || jobs > 16) {
console.error(`Invalid release preflight jobs value: ${raw}; expected 1 through 16.`);
process.exit(1);
}
return jobs;
}
function taskMatchesScopes(task, scopes) {
return scopes.has("all") || task.scopes.some((scope) => scopes.has(scope));
}
function formatScopes(scopes) {
return [...scopes].toSorted((left, right) => left.localeCompare(right)).join(",");
}