Files
openclaw/scripts/release-candidate-checklist.mjs
2026-05-17 06:34:58 +01:00

729 lines
24 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_PROVIDER = "openai";
const DEFAULT_MODE = "both";
const DEFAULT_RELEASE_PROFILE = "beta";
const DEFAULT_NPM_DIST_TAG = "beta";
const DEFAULT_PLUGIN_SCOPE = "all-publishable";
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
function usage() {
return `Usage: pnpm release:candidate -- --tag vYYYY.M.D-beta.N [options]
Dispatches or consumes release validation runs, validates the prepared npm tarball,
builds plugin publish plans, writes a green evidence bundle, then prints the exact
OpenClaw Release Publish command only after everything is green.
Options:
--tag <tag> Release tag to validate.
--workflow-ref <ref> Workflow branch/ref. Default: current branch.
--repo <owner/repo> GitHub repo. Default: ${DEFAULT_REPO}
--full-release-run <id> Reuse successful Full Release Validation run.
--npm-preflight-run <id> Reuse successful OpenClaw NPM Release preflight run.
--skip-dispatch Require both run ids; do not dispatch workflows.
--skip-parallels Do not run local Parallels fresh/update beta smoke.
--skip-telegram Do not run NPM Telegram E2E against the prepared tarball.
--telegram-provider-mode <mode> mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE}
--provider <provider> Full validation provider. Default: ${DEFAULT_PROVIDER}
--mode <fresh|upgrade|both> Full validation cross-OS mode. Default: ${DEFAULT_MODE}
--release-profile <beta|stable|full> Default: ${DEFAULT_RELEASE_PROFILE}
--npm-dist-tag <alpha|beta|latest> Default: ${DEFAULT_NPM_DIST_TAG}
--plugin-publish-scope <scope> selected|all-publishable. Default: ${DEFAULT_PLUGIN_SCOPE}
--plugins <names> Required when plugin scope is selected.
--output-dir <dir> Evidence output dir. Default: .artifacts/release-candidate/<tag>
`;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith("-")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
export function parseArgs(argv) {
const options = {
repo: DEFAULT_REPO,
provider: DEFAULT_PROVIDER,
mode: DEFAULT_MODE,
releaseProfile: DEFAULT_RELEASE_PROFILE,
npmDistTag: DEFAULT_NPM_DIST_TAG,
pluginPublishScope: DEFAULT_PLUGIN_SCOPE,
plugins: "",
skipDispatch: false,
skipParallels: false,
skipTelegram: false,
telegramProviderMode: DEFAULT_TELEGRAM_PROVIDER_MODE,
tag: "",
workflowRef: "",
fullReleaseRunId: "",
npmPreflightRunId: "",
outputDir: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--":
break;
case "--tag":
options.tag = requireValue(argv, ++index, arg);
break;
case "--workflow-ref":
options.workflowRef = requireValue(argv, ++index, arg);
break;
case "--repo":
options.repo = requireValue(argv, ++index, arg);
break;
case "--full-release-run":
options.fullReleaseRunId = requireValue(argv, ++index, arg);
break;
case "--npm-preflight-run":
options.npmPreflightRunId = requireValue(argv, ++index, arg);
break;
case "--skip-dispatch":
options.skipDispatch = true;
break;
case "--skip-parallels":
options.skipParallels = true;
break;
case "--skip-telegram":
options.skipTelegram = true;
break;
case "--telegram-provider-mode":
options.telegramProviderMode = requireValue(argv, ++index, arg);
break;
case "--provider":
options.provider = requireValue(argv, ++index, arg);
break;
case "--mode":
options.mode = requireValue(argv, ++index, arg);
break;
case "--release-profile":
options.releaseProfile = requireValue(argv, ++index, arg);
break;
case "--npm-dist-tag":
options.npmDistTag = requireValue(argv, ++index, arg);
break;
case "--plugin-publish-scope":
options.pluginPublishScope = requireValue(argv, ++index, arg);
break;
case "--plugins":
options.plugins = requireValue(argv, ++index, arg);
break;
case "--output-dir":
options.outputDir = requireValue(argv, ++index, arg);
break;
case "-h":
case "--help":
process.stdout.write(usage());
process.exit(0);
default:
throw new Error(`unknown option: ${arg}`);
}
}
if (!options.tag) {
throw new Error("--tag is required");
}
if (options.skipDispatch && (!options.fullReleaseRunId || !options.npmPreflightRunId)) {
throw new Error("--skip-dispatch requires --full-release-run and --npm-preflight-run");
}
if (options.pluginPublishScope === "selected" && !options.plugins.trim()) {
throw new Error("--plugin-publish-scope selected requires --plugins");
}
if (options.pluginPublishScope === "all-publishable" && options.plugins.trim()) {
throw new Error("--plugins is only valid with --plugin-publish-scope selected");
}
if (!["mock-openai", "live-frontier"].includes(options.telegramProviderMode)) {
throw new Error("--telegram-provider-mode must be mock-openai or live-frontier");
}
return options;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: "utf8",
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed with ${result.status ?? result.signal}\n${result.stderr ?? ""}`,
);
}
return result.stdout ?? "";
}
function readJson(path, label) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
throw new Error(
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
function currentBranch() {
return run("git", ["branch", "--show-current"], { capture: true }).trim();
}
function gitRevParse(ref) {
return run("git", ["rev-parse", ref], { capture: true }).trim();
}
function workflowRuns(repo, workflowFile) {
return JSON.parse(
run(
"gh",
[
"api",
`repos/${repo}/actions/workflows/${workflowFile}/runs?event=workflow_dispatch&per_page=100`,
"--jq",
".workflow_runs | map({databaseId:.id, workflowName:.name, event:.event, createdAt:.created_at})",
],
{ capture: true },
),
);
}
function runArtifacts(repo, runId) {
return JSON.parse(
run(
"gh",
[
"api",
`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`,
"--jq",
".artifacts | map({name:.name, expired:.expired})",
],
{ capture: true },
),
);
}
export function resolveArtifactName(artifacts, preferredName, prefix) {
const available = artifacts
.filter((artifact) => artifact.expired !== true)
.map((artifact) => artifact.name);
if (available.includes(preferredName)) {
return preferredName;
}
const candidates = available.filter((name) => name.startsWith(prefix));
if (candidates.length === 1) {
console.warn(`artifact ${preferredName} not found; using ${candidates[0]} from the same run`);
return candidates[0];
}
const candidateList =
available.length > 0 ? available.map((name) => `- ${name}`).join("\n") : "- <none>";
throw new Error(
`artifact ${preferredName} not found in run. Expected ${preferredName} or exactly one ${prefix}* fallback.\nAvailable artifacts:\n${candidateList}`,
);
}
function resolveRunArtifactName(repo, runId, preferredName, prefix) {
return resolveArtifactName(runArtifacts(repo, runId), preferredName, prefix);
}
function beforeRunIds(repo, workflowFile) {
return new Set(workflowRuns(repo, workflowFile).map((run) => String(run.databaseId)));
}
function runAndEcho(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed with ${result.status ?? result.signal}\n${
result.stderr ?? ""
}`,
);
}
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
}
export function parseRunIdFromDispatchOutput(output) {
return output.match(/actions\/runs\/([0-9]+)/u)?.[1] ?? "";
}
async function wait(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function findNewRunId(repo, workflowFile, workflowName, beforeIds) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const match = workflowRuns(repo, workflowFile)
.filter(
(run) =>
run.workflowName === workflowName &&
run.event === "workflow_dispatch" &&
!beforeIds.has(String(run.databaseId)),
)
.toSorted((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))[0];
if (match?.databaseId) {
return String(match.databaseId);
}
await wait(5_000);
}
throw new Error(`could not find dispatched ${workflowName} run`);
}
function dispatchWorkflow(repo, workflowFile, workflowRef, fields) {
const args = ["workflow", "run", workflowFile, "--repo", repo, "--ref", workflowRef];
for (const [key, value] of Object.entries(fields)) {
args.push("-f", `${key}=${String(value)}`);
}
return parseRunIdFromDispatchOutput(runAndEcho("gh", args));
}
function runInfo(repo, runId) {
return JSON.parse(
run(
"gh",
[
"run",
"view",
runId,
"--repo",
repo,
"--json",
"databaseId,workflowName,headBranch,headSha,event,status,conclusion,url,jobs",
],
{ capture: true },
),
);
}
function pendingDeployments(repo, runId) {
try {
return JSON.parse(
run("gh", ["api", "-X", "GET", `repos/${repo}/actions/runs/${runId}/pending_deployments`], {
capture: true,
}),
);
} catch {
return [];
}
}
function summarizePendingDeployments(repo, runId, deployments) {
if (!Array.isArray(deployments) || deployments.length === 0) {
return "";
}
return deployments
.map((deployment) => {
const environment = deployment.environment ?? {};
return [
`- pending approval: env=${environment.name ?? "<unknown>"} canApprove=${String(deployment.current_user_can_approve ?? "<unknown>")}`,
` approve: gh api -X POST repos/${repo}/actions/runs/${runId}/pending_deployments -F 'environment_ids[]=${environment.id ?? "<id>"}' -f state=approved -f comment='Approve release gate'`,
].join("\n");
})
.join("\n");
}
function summarizeFailedRun(info) {
const failedJobs = (info.jobs ?? []).filter(
(job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped",
);
return [
`${info.workflowName} ${info.databaseId} ended ${info.status}/${info.conclusion}: ${info.url}`,
...failedJobs.map((job) => `- ${job.name}: ${job.conclusion} ${job.url ?? ""}`),
].join("\n");
}
async function waitForSuccessfulRun(repo, runId, expected) {
let lastState = "";
for (;;) {
const info = runInfo(repo, runId);
const state = `${info.status}:${info.conclusion ?? ""}`;
if (state !== lastState) {
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
const pending = summarizePendingDeployments(repo, runId, pendingDeployments(repo, runId));
if (pending) {
console.log(pending);
}
lastState = state;
}
if (info.status === "completed") {
if (info.conclusion !== "success") {
throw new Error(summarizeFailedRun(info));
}
if (info.workflowName !== expected.workflowName) {
throw new Error(
`run ${runId} workflow mismatch: expected ${expected.workflowName}, got ${info.workflowName}`,
);
}
if (info.headBranch !== expected.workflowRef) {
throw new Error(
`run ${runId} branch mismatch: expected ${expected.workflowRef}, got ${info.headBranch}`,
);
}
return info;
}
await wait(30_000);
}
}
function downloadArtifact(repo, runId, name, dir) {
rmSync(dir, { force: true, recursive: true });
mkdirSync(dir, { recursive: true });
run("gh", ["run", "download", runId, "--repo", repo, "--name", name, "--dir", dir]);
}
function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) {
const name = resolveRunArtifactName(repo, runId, preferredName, prefix);
downloadArtifact(repo, runId, name, dir);
return name;
}
function sha256(path) {
return run("shasum", ["-a", "256", path], { capture: true }).trim().split(/\s+/u)[0] ?? "";
}
function pluginPlanArgs(options) {
const args = ["--selection-mode", options.pluginPublishScope];
if (options.pluginPublishScope === "selected") {
args.push("--plugins", options.plugins);
}
return args;
}
function collectPluginPlan(script, options) {
return JSON.parse(
run("node", ["--import", "tsx", script, ...pluginPlanArgs(options)], { capture: true }),
);
}
async function collectPluginPlanWithRetry(script, options) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return collectPluginPlan(script, options);
} catch (error) {
lastError = error;
if (attempt === 3) {
break;
}
console.warn(
`${script} failed on attempt ${attempt}; retrying: ${
error instanceof Error ? error.message : String(error)
}`,
);
await wait(5_000 * attempt);
}
}
throw lastError;
}
function shellQuote(value) {
return `'${String(value).replace(/'/gu, "'\\''")}'`;
}
export function buildPublishCommand(options) {
const fields = [
["tag", options.tag],
["preflight_run_id", options.npmPreflightRunId],
["full_release_validation_run_id", options.fullReleaseRunId],
["npm_dist_tag", options.npmDistTag],
["plugin_publish_scope", options.pluginPublishScope],
["publish_openclaw_npm", "true"],
["release_profile", options.releaseProfile],
["wait_for_clawhub", "false"],
];
if (options.plugins.trim()) {
fields.push(["plugins", options.plugins]);
}
return [
"gh",
"workflow",
"run",
"openclaw-release-publish.yml",
"--repo",
options.repo,
"--ref",
options.workflowRef,
...fields.flatMap(([key, value]) => ["-f", `${key}=${value}`]),
]
.map(shellQuote)
.join(" ");
}
function validatePreflightManifest(manifest, params) {
if (manifest.releaseTag !== params.tag) {
throw new Error(
`npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`,
);
}
if (manifest.releaseSha !== params.targetSha) {
throw new Error(
`npm preflight SHA mismatch: expected ${params.targetSha}, got ${manifest.releaseSha}`,
);
}
if (manifest.npmDistTag !== params.npmDistTag) {
throw new Error(
`npm preflight dist-tag mismatch: expected ${params.npmDistTag}, got ${manifest.npmDistTag}`,
);
}
if (!manifest.tarballName || !manifest.tarballSha256) {
throw new Error("npm preflight manifest missing tarball metadata");
}
}
function validateFullManifest(manifest, params) {
if (manifest.workflowName !== "Full Release Validation") {
throw new Error(`full validation workflow mismatch: ${manifest.workflowName}`);
}
if (manifest.targetSha !== params.targetSha) {
throw new Error(
`full validation SHA mismatch: expected ${params.targetSha}, got ${manifest.targetSha}`,
);
}
if (manifest.releaseProfile !== params.releaseProfile) {
throw new Error(
`full validation profile mismatch: expected ${params.releaseProfile}, got ${manifest.releaseProfile}`,
);
}
if (manifest.rerunGroup !== "all") {
throw new Error(`full validation must use rerun_group=all, got ${manifest.rerunGroup}`);
}
}
async function runParallelsIfNeeded(options) {
if (options.skipParallels) {
return { status: "skipped" };
}
const version = options.tag.replace(/^v/u, "");
run("pnpm", [
"release:beta-smoke",
"--",
"--beta",
version,
"--ref",
options.workflowRef,
"--skip-telegram",
]);
return {
status: "passed",
command: `pnpm release:beta-smoke -- --beta ${version} --ref ${options.workflowRef} --skip-telegram`,
};
}
async function runTelegramIfNeeded(options, artifactName) {
if (options.skipTelegram) {
return { status: "skipped" };
}
const workflowFile = "npm-telegram-beta-e2e.yml";
const before = beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`,
package_label: options.tag,
package_artifact_name: artifactName,
package_artifact_run_id: options.npmPreflightRunId,
harness_ref: options.workflowRef,
provider_mode: options.telegramProviderMode,
});
const runId =
dispatchedRunId ||
(await findNewRunId(options.repo, workflowFile, "NPM Telegram Beta E2E", before));
const run = await waitForSuccessfulRun(options.repo, runId, {
workflowName: "NPM Telegram Beta E2E",
workflowRef: options.workflowRef,
});
return {
status: "passed",
runId,
url: run.url,
artifactName,
providerMode: options.telegramProviderMode,
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
options.workflowRef ||= currentBranch();
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`);
if (!options.fullReleaseRunId && !options.skipDispatch) {
const workflowFile = "full-release-validation.yml";
const before = beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
ref: options.tag,
provider: options.provider,
mode: options.mode,
release_profile: options.releaseProfile,
run_release_soak: options.releaseProfile === "full" ? "true" : "false",
rerun_group: "all",
});
options.fullReleaseRunId =
dispatchedRunId ||
(await findNewRunId(options.repo, workflowFile, "Full Release Validation", before));
}
if (!options.npmPreflightRunId && !options.skipDispatch) {
const workflowFile = "openclaw-npm-release.yml";
const before = beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
tag: options.tag,
preflight_only: "true",
npm_dist_tag: options.npmDistTag,
});
options.npmPreflightRunId =
dispatchedRunId ||
(await findNewRunId(options.repo, workflowFile, "OpenClaw NPM Release", before));
}
const fullRun = await waitForSuccessfulRun(options.repo, options.fullReleaseRunId, {
workflowName: "Full Release Validation",
workflowRef: options.workflowRef,
});
const npmRun = await waitForSuccessfulRun(options.repo, options.npmPreflightRunId, {
workflowName: "OpenClaw NPM Release",
workflowRef: options.workflowRef,
});
if (fullRun.headSha !== targetSha || npmRun.headSha !== targetSha) {
throw new Error(
`run SHA mismatch: tag=${targetSha} full=${fullRun.headSha} npm=${npmRun.headSha}`,
);
}
const npmDir = join(options.outputDir, "npm-preflight");
const fullDir = join(options.outputDir, "full-release-validation");
const npmArtifactName = downloadResolvedArtifact(
options.repo,
options.npmPreflightRunId,
`openclaw-npm-preflight-${options.tag}`,
"openclaw-npm-preflight-",
npmDir,
);
const fullArtifactName = downloadResolvedArtifact(
options.repo,
options.fullReleaseRunId,
`full-release-validation-${options.fullReleaseRunId}`,
"full-release-validation-",
fullDir,
);
const npmManifest = readJson(join(npmDir, "preflight-manifest.json"), "npm preflight manifest");
const fullManifest = readJson(
join(fullDir, "full-release-validation-manifest.json"),
"full validation manifest",
);
validatePreflightManifest(npmManifest, {
tag: options.tag,
targetSha,
npmDistTag: options.npmDistTag,
});
validateFullManifest(fullManifest, {
targetSha,
releaseProfile: options.releaseProfile,
});
const tarballPath = join(npmDir, npmManifest.tarballName);
if (!existsSync(tarballPath)) {
throw new Error(`prepared tarball missing: ${tarballPath}`);
}
const actualTarballSha = sha256(tarballPath);
if (actualTarballSha !== npmManifest.tarballSha256) {
throw new Error(
`prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`,
);
}
const parallels = await runParallelsIfNeeded(options);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
const pluginNpmPlan = await collectPluginPlanWithRetry(
"scripts/plugin-npm-release-plan.ts",
options,
);
const pluginClawHubPlan = await collectPluginPlanWithRetry(
"scripts/plugin-clawhub-release-plan.ts",
options,
);
const publishCommand = buildPublishCommand(options);
const evidence = {
version: 1,
tag: options.tag,
targetSha,
workflowRef: options.workflowRef,
npmDistTag: options.npmDistTag,
fullReleaseValidationRunId: options.fullReleaseRunId,
npmPreflightRunId: options.npmPreflightRunId,
fullReleaseValidationUrl: fullRun.url,
npmPreflightUrl: npmRun.url,
artifacts: {
npmPreflight: npmArtifactName,
fullReleaseValidation: fullArtifactName,
},
tarball: {
name: basename(tarballPath),
sha256: actualTarballSha,
path: tarballPath,
},
parallels,
npmTelegram,
pluginNpmPlan,
pluginClawHubPlan,
publishCommand,
};
mkdirSync(options.outputDir, { recursive: true });
const evidencePath = join(options.outputDir, "release-candidate-evidence.json");
const evidenceMarkdownPath = join(options.outputDir, "release-candidate-evidence.md");
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
writeFileSync(
evidenceMarkdownPath,
[
`# ${options.tag} release candidate evidence`,
"",
`- target SHA: ${targetSha}`,
`- full release validation: ${options.fullReleaseRunId} ${fullRun.url}`,
`- npm preflight: ${options.npmPreflightRunId} ${npmRun.url}`,
`- npm preflight artifact: ${npmArtifactName}`,
`- full release artifact: ${fullArtifactName}`,
`- tarball: ${basename(tarballPath)}`,
`- tarball sha256: ${actualTarballSha}`,
`- npm dist-tag: ${options.npmDistTag}`,
`- plugin npm plan: ${pluginNpmPlan.packages?.length ?? 0} packages`,
`- ClawHub plan: ${pluginClawHubPlan.packages?.length ?? 0} packages`,
`- Parallels: ${parallels.status}`,
`- NPM Telegram E2E: ${npmTelegram.status}${
npmTelegram.runId ? ` ${npmTelegram.runId} ${npmTelegram.url}` : ""
}`,
"",
"Publish command:",
"",
"```bash",
publishCommand,
"```",
"",
].join("\n"),
);
console.log(`release candidate evidence: ${evidencePath}`);
console.log(`release candidate summary: ${evidenceMarkdownPath}`);
console.log("publish command:");
console.log(publishCommand);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}