ci: harden release publish evidence

This commit is contained in:
Peter Steinberger
2026-05-17 06:29:27 +01:00
parent c4d8e0be18
commit 1ceebf8a01
9 changed files with 301 additions and 42 deletions

View File

@@ -2,7 +2,10 @@ import { execFileSync } from "node:child_process";
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";
import {
collectPublishablePluginPackages,
parsePluginReleaseSelection,
} from "./plugin-npm-release.ts";
type JsonRecord = Record<string, unknown>;
@@ -12,6 +15,8 @@ export type ReleaseVerifyBetaArgs = {
distTag: string;
repo: string;
registry: string;
workflowRef?: string;
pluginSelection: string[];
evidenceOut?: string;
skipPostpublish: boolean;
rerunFailedClawHub: boolean;
@@ -108,7 +113,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
const version = values.shift();
if (!version || version.startsWith("-")) {
throw new Error(
"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]",
"Usage: pnpm release:verify-beta -- <version> [--workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--npm-telegram-run ID]",
);
}
@@ -118,6 +123,8 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
workflowRef: undefined,
pluginSelection: [],
evidenceOut: undefined,
skipPostpublish: false,
rerunFailedClawHub: false,
@@ -148,6 +155,15 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--registry":
parsed.registry = next();
break;
case "--workflow-ref":
parsed.workflowRef = next();
break;
case "--plugins":
parsed.pluginSelection = parsePluginReleaseSelection(next());
if (parsed.pluginSelection.length === 0) {
throw new Error("--plugins requires at least one plugin package name.");
}
break;
case "--evidence-out":
parsed.evidenceOut = next();
break;
@@ -319,6 +335,8 @@ function verifyWorkflowRun(params: {
id: string;
label: string;
repo: string;
expectedWorkflowName: string;
expectedHeadBranch?: string;
rerunFailed: boolean;
}): WorkflowRunSummary {
const raw = runCommand("gh", [
@@ -328,12 +346,30 @@ function verifyWorkflowRun(params: {
"--repo",
params.repo,
"--json",
"status,conclusion,url,createdAt,updatedAt,jobs",
"workflowName,headBranch,event,status,conclusion,url,createdAt,updatedAt,jobs",
]);
const run = parseJson(raw, `gh run view ${params.id}`);
if (!isRecord(run)) {
throw new Error(`${params.label}: workflow run returned an unsupported JSON shape.`);
}
const workflowName = readString(run.workflowName);
if (workflowName !== params.expectedWorkflowName) {
throw new Error(
`${params.label}: run ${params.id} workflow is ${workflowName ?? "<missing>"}, expected ${params.expectedWorkflowName}.`,
);
}
const event = readString(run.event);
if (event !== "workflow_dispatch") {
throw new Error(
`${params.label}: run ${params.id} event is ${event ?? "<missing>"}, expected workflow_dispatch.`,
);
}
const headBranch = readString(run.headBranch);
if (params.expectedHeadBranch !== undefined && headBranch !== params.expectedHeadBranch) {
throw new Error(
`${params.label}: run ${params.id} branch is ${headBranch ?? "<missing>"}, expected ${params.expectedHeadBranch}.`,
);
}
const status = readString(run.status);
const conclusion = readString(run.conclusion);
const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isRecord) : [];
@@ -391,6 +427,21 @@ function formatDuration(seconds: number | undefined): string {
return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
}
function assertSelectedPackagesResolved(params: {
label: string;
selection: readonly string[];
packages: readonly { packageName: string }[];
}): void {
if (params.selection.length === 0) {
return;
}
const resolved = new Set(params.packages.map((plugin) => plugin.packageName));
const missing = params.selection.filter((packageName) => !resolved.has(packageName));
if (missing.length > 0) {
throw new Error(`Unknown or non-publishable ${params.label} selection: ${missing.join(", ")}.`);
}
}
export async function verifyBetaRelease(
args: ReleaseVerifyBetaArgs,
options: { rootDir?: string } = {},
@@ -418,13 +469,27 @@ export async function verifyBetaRelease(
lines.push("openclaw postpublish verifier OK");
}
const npmPlugins = collectPublishablePluginPackages(rootDir);
const npmPlugins = collectPublishablePluginPackages(rootDir, {
packageNames: args.pluginSelection.length > 0 ? args.pluginSelection : undefined,
});
assertSelectedPackagesResolved({
label: "npm plugin",
selection: args.pluginSelection,
packages: npmPlugins,
});
for (const plugin of npmPlugins) {
verifyNpmPackage(plugin.packageName, args.version, args.distTag);
}
lines.push(`plugin npm OK: ${npmPlugins.length}`);
const clawHubPlugins = collectClawHubPublishablePluginPackages(rootDir);
const clawHubPlugins = collectClawHubPublishablePluginPackages(rootDir, {
packageNames: args.pluginSelection.length > 0 ? args.pluginSelection : undefined,
});
assertSelectedPackagesResolved({
label: "ClawHub plugin",
selection: args.pluginSelection,
packages: clawHubPlugins,
});
for (const plugin of clawHubPlugins) {
await verifyClawHubPackage({
registry: args.registry,
@@ -442,6 +507,8 @@ export async function verifyBetaRelease(
id: args.workflowRuns.fullReleaseValidation,
label: "Full Release Validation",
repo: args.repo,
expectedWorkflowName: "Full Release Validation",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
@@ -452,6 +519,8 @@ export async function verifyBetaRelease(
id: args.workflowRuns.pluginNpm,
label: "Plugin NPM Release",
repo: args.repo,
expectedWorkflowName: "Plugin NPM Release",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
@@ -462,6 +531,8 @@ export async function verifyBetaRelease(
id: args.workflowRuns.pluginClawHub,
label: "Plugin ClawHub Release",
repo: args.repo,
expectedWorkflowName: "Plugin ClawHub Release",
expectedHeadBranch: args.workflowRef,
rerunFailed: args.rerunFailedClawHub,
}),
);
@@ -472,6 +543,8 @@ export async function verifyBetaRelease(
id: args.workflowRuns.openclawNpm,
label: "OpenClaw NPM Release",
repo: args.repo,
expectedWorkflowName: "OpenClaw NPM Release",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
@@ -482,6 +555,8 @@ export async function verifyBetaRelease(
id: args.workflowRuns.npmTelegram,
label: "NPM Telegram Beta E2E",
repo: args.repo,
expectedWorkflowName: "NPM Telegram Beta E2E",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
@@ -503,6 +578,7 @@ export async function verifyBetaRelease(
releaseVersion: args.version,
releaseTag: args.tag,
npmDistTag: args.distTag,
pluginSelection: args.pluginSelection,
openclawNpmIntegrity: openclawNpm.integrity,
githubReleaseUrl: releaseUrl,
pluginNpmPackageCount: npmPlugins.length,

View File

@@ -161,7 +161,7 @@ export function collectInstalledBundledRuntimeSidecarPaths(packageRoot: string):
export function normalizeInstalledBinaryVersion(output: string): string {
const trimmed = output.trim();
const versionMatch = /\b\d{4}\.\d{1,2}\.\d{1,2}(?:-\d+|-beta\.\d+)?\b/u.exec(trimmed);
const versionMatch = /\b\d{4}\.\d{1,2}\.\d{1,2}(?:-\d+|-(?:alpha|beta)\.\d+)?\b/u.exec(trimmed);
return versionMatch?.[0] ?? trimmed;
}

View File

@@ -26,6 +26,7 @@ Options:
--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-local-generated-check Do not run local generated release baseline checks before dispatch.
--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}
@@ -57,6 +58,7 @@ export function parseArgs(argv) {
pluginPublishScope: DEFAULT_PLUGIN_SCOPE,
plugins: "",
skipDispatch: false,
skipLocalGeneratedCheck: false,
skipParallels: false,
skipTelegram: false,
telegramProviderMode: DEFAULT_TELEGRAM_PROVIDER_MODE,
@@ -89,6 +91,9 @@ export function parseArgs(argv) {
case "--skip-dispatch":
options.skipDispatch = true;
break;
case "--skip-local-generated-check":
options.skipLocalGeneratedCheck = true;
break;
case "--skip-parallels":
options.skipParallels = true;
break;
@@ -256,6 +261,14 @@ function runAndEcho(command, args) {
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
}
function runLocalGeneratedCheckIfNeeded(options) {
if (options.skipLocalGeneratedCheck) {
return { status: "skipped", reason: "operator skipped --skip-local-generated-check" };
}
run("pnpm", ["release:generated:check"]);
return { status: "passed", command: "pnpm release:generated:check" };
}
export function parseRunIdFromDispatchOutput(output) {
return output.match(/actions\/runs\/([0-9]+)/u)?.[1] ?? "";
}
@@ -446,6 +459,9 @@ export function buildPublishCommand(options) {
["release_profile", options.releaseProfile],
["wait_for_clawhub", "false"],
];
if (options.npmTelegramRunId) {
fields.push(["npm_telegram_run_id", options.npmTelegramRunId]);
}
if (options.plugins.trim()) {
fields.push(["plugins", options.plugins]);
}
@@ -506,7 +522,7 @@ function validateFullManifest(manifest, params) {
async function runParallelsIfNeeded(options) {
if (options.skipParallels) {
return { status: "skipped" };
return { status: "skipped", reason: "operator skipped --skip-parallels" };
}
const version = options.tag.replace(/^v/u, "");
run("pnpm", [
@@ -559,6 +575,7 @@ async function main() {
options.workflowRef ||= currentBranch();
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`);
const localGeneratedCheck = runLocalGeneratedCheckIfNeeded(options);
if (!options.fullReleaseRunId && !options.skipDispatch) {
const workflowFile = "full-release-validation.yml";
@@ -647,6 +664,7 @@ async function main() {
const parallels = await runParallelsIfNeeded(options);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
options.npmTelegramRunId = npmTelegram.runId ?? "";
const pluginNpmPlan = await collectPluginPlanWithRetry(
"scripts/plugin-npm-release-plan.ts",
options,
@@ -670,6 +688,7 @@ async function main() {
npmPreflight: npmArtifactName,
fullReleaseValidation: fullArtifactName,
},
localGeneratedCheck,
tarball: {
name: basename(tarballPath),
sha256: actualTarballSha,
@@ -695,12 +714,15 @@ async function main() {
`- npm preflight: ${options.npmPreflightRunId} ${npmRun.url}`,
`- npm preflight artifact: ${npmArtifactName}`,
`- full release artifact: ${fullArtifactName}`,
`- local generated release checks: ${localGeneratedCheck.status}${
localGeneratedCheck.reason ? ` (${localGeneratedCheck.reason})` : ""
}`,
`- 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}`,
`- Parallels: ${parallels.status}${parallels.reason ? ` (${parallels.reason})` : ""}`,
`- NPM Telegram E2E: ${npmTelegram.status}${
npmTelegram.runId ? ` ${npmTelegram.runId} ${npmTelegram.url}` : ""
}`,