mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 07:40:44 +00:00
ci: harden release validation flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user