#!/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"; 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. Options: --tag Release tag to validate. --workflow-ref Workflow branch/ref. Default: current branch. --repo GitHub repo. Default: ${DEFAULT_REPO} --full-release-run Reuse successful Full Release Validation run. --npm-preflight-run 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. --provider Full validation provider. Default: ${DEFAULT_PROVIDER} --mode Full validation cross-OS mode. Default: ${DEFAULT_MODE} --release-profile Default: ${DEFAULT_RELEASE_PROFILE} --npm-dist-tag Default: ${DEFAULT_NPM_DIST_TAG} --plugin-publish-scope selected|all-publishable. Default: ${DEFAULT_PLUGIN_SCOPE} --plugins Required when plugin scope is selected. --output-dir Evidence output dir. Default: .artifacts/release-candidate/ `; } 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, 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 "--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"); } 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 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 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) { for (;;) { const info = runInfo(repo, runId); console.log( `${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`, ); 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 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 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"); downloadArtifact( options.repo, options.npmPreflightRunId, `openclaw-npm-preflight-${options.tag}`, npmDir, ); downloadArtifact( options.repo, options.fullReleaseRunId, `full-release-validation-${options.fullReleaseRunId}`, 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 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, tarball: { name: basename(tarballPath), sha256: actualTarballSha, path: tarballPath, }, parallels, pluginNpmPlan, pluginClawHubPlan, publishCommand, }; mkdirSync(options.outputDir, { recursive: true }); const evidencePath = join(options.outputDir, "release-candidate-evidence.json"); writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); console.log(`release candidate evidence: ${evidencePath}`); 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); }); }