ci: harden release validation flow

This commit is contained in:
Peter Steinberger
2026-05-16 13:53:08 +01:00
parent a535978352
commit c4d8e0be18
8 changed files with 385 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./plugin-npm-release.ts";
@@ -12,12 +12,15 @@ export type ReleaseVerifyBetaArgs = {
distTag: string;
repo: string;
registry: string;
evidenceOut?: string;
skipPostpublish: boolean;
rerunFailedClawHub: boolean;
workflowRuns: {
fullReleaseValidation?: string;
openclawNpm?: string;
pluginNpm?: string;
pluginClawHub?: string;
npmTelegram?: string;
};
};
@@ -105,7 +108,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
const version = values.shift();
if (!version || version.startsWith("-")) {
throw new Error(
"Usage: pnpm release:verify-beta -- <version> [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID]",
"Usage: pnpm release:verify-beta -- <version> [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--npm-telegram-run ID]",
);
}
@@ -115,6 +118,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
evidenceOut: undefined,
skipPostpublish: false,
rerunFailedClawHub: false,
workflowRuns: {},
@@ -144,6 +148,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--registry":
parsed.registry = next();
break;
case "--evidence-out":
parsed.evidenceOut = next();
break;
case "--full-release-validation-run":
parsed.workflowRuns.fullReleaseValidation = next();
break;
case "--openclaw-npm-run":
parsed.workflowRuns.openclawNpm = next();
break;
@@ -153,6 +163,9 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--plugin-clawhub-run":
parsed.workflowRuns.pluginClawHub = next();
break;
case "--npm-telegram-run":
parsed.workflowRuns.npmTelegram = next();
break;
case "--skip-postpublish":
parsed.skipPostpublish = true;
break;
@@ -204,7 +217,7 @@ async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promis
return response.status;
}
function verifyNpmPackage(packageName: string, version: string, distTag: string): void {
function verifyNpmPackage(packageName: string, version: string, distTag: string): NpmViewFields {
const raw = runCommand("npm", [
"view",
`${packageName}@${version}`,
@@ -227,6 +240,7 @@ function verifyNpmPackage(packageName: string, version: string, distTag: string)
if (fields.integrity === undefined) {
throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`);
}
return fields;
}
function readClawHubTags(detail: unknown): Record<string, string> {
@@ -391,7 +405,7 @@ export async function verifyBetaRelease(
const releaseUrl = verifyGitHubRelease(args);
lines.push(`GitHub release OK: ${releaseUrl}`);
verifyNpmPackage("openclaw", args.version, args.distTag);
const openclawNpm = verifyNpmPackage("openclaw", args.version, args.distTag);
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
if (!args.skipPostpublish) {
@@ -422,6 +436,16 @@ export async function verifyBetaRelease(
lines.push(`ClawHub OK: ${clawHubPlugins.length}`);
const workflowRuns: WorkflowRunSummary[] = [];
if (args.workflowRuns.fullReleaseValidation !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.fullReleaseValidation,
label: "Full Release Validation",
repo: args.repo,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.pluginNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
@@ -452,11 +476,45 @@ export async function verifyBetaRelease(
}),
);
}
if (args.workflowRuns.npmTelegram !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.npmTelegram,
label: "NPM Telegram Beta E2E",
repo: args.repo,
rerunFailed: false,
}),
);
}
for (const run of workflowRuns) {
lines.push(
`${run.label} OK: ${run.id} (${formatDuration(run.durationSeconds)})${run.url ? ` ${run.url}` : ""}`,
);
}
if (args.evidenceOut !== undefined) {
const evidencePath = resolve(rootDir, args.evidenceOut);
mkdirSync(dirname(evidencePath), { recursive: true });
writeFileSync(
evidencePath,
`${JSON.stringify(
{
version: 1,
releaseVersion: args.version,
releaseTag: args.tag,
npmDistTag: args.distTag,
openclawNpmIntegrity: openclawNpm.integrity,
githubReleaseUrl: releaseUrl,
pluginNpmPackageCount: npmPlugins.length,
clawHubPackageCount: clawHubPlugins.length,
workflowRuns,
},
null,
2,
)}\n`,
);
lines.push(`release evidence written: ${args.evidenceOut}`);
}
return lines;
}

View File

@@ -10,13 +10,14 @@ 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.
OpenClaw Release Publish command only after everything is green.
Options:
--tag <tag> Release tag to validate.
@@ -26,6 +27,8 @@ Options:
--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}
@@ -55,6 +58,8 @@ export function parseArgs(argv) {
plugins: "",
skipDispatch: false,
skipParallels: false,
skipTelegram: false,
telegramProviderMode: DEFAULT_TELEGRAM_PROVIDER_MODE,
tag: "",
workflowRef: "",
fullReleaseRunId: "",
@@ -87,6 +92,12 @@ export function parseArgs(argv) {
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;
@@ -128,6 +139,9 @@ export function parseArgs(argv) {
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;
}
@@ -179,6 +193,44 @@ function workflowRuns(repo, workflowFile) {
);
}
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)));
}
@@ -256,6 +308,33 @@ function runInfo(repo, runId) {
);
}
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",
@@ -267,11 +346,20 @@ function summarizeFailedRun(info) {
}
async function waitForSuccessfulRun(repo, runId, expected) {
let lastState = "";
for (;;) {
const info = runInfo(repo, runId);
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
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));
@@ -298,6 +386,12 @@ function downloadArtifact(repo, runId, name, dir) {
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] ?? "";
}
@@ -430,6 +524,36 @@ async function runParallelsIfNeeded(options) {
};
}
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();
@@ -481,16 +605,18 @@ async function main() {
const npmDir = join(options.outputDir, "npm-preflight");
const fullDir = join(options.outputDir, "full-release-validation");
downloadArtifact(
const npmArtifactName = downloadResolvedArtifact(
options.repo,
options.npmPreflightRunId,
`openclaw-npm-preflight-${options.tag}`,
"openclaw-npm-preflight-",
npmDir,
);
downloadArtifact(
const fullArtifactName = downloadResolvedArtifact(
options.repo,
options.fullReleaseRunId,
`full-release-validation-${options.fullReleaseRunId}`,
"full-release-validation-",
fullDir,
);
@@ -520,6 +646,7 @@ async function main() {
}
const parallels = await runParallelsIfNeeded(options);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
const pluginNpmPlan = await collectPluginPlanWithRetry(
"scripts/plugin-npm-release-plan.ts",
options,
@@ -539,21 +666,56 @@ async function main() {
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);
}