ci: speed up beta release verification

This commit is contained in:
Peter Steinberger
2026-05-11 05:47:16 +01:00
parent 34ad37afe8
commit 7ca9b58a27
9 changed files with 730 additions and 10 deletions

View File

@@ -288,7 +288,7 @@ jobs:
wait_for_run() {
local workflow="$1"
local run_id="$2"
local status conclusion url updated_at last_state
local status conclusion url updated_at created_at duration_seconds duration_label last_state
last_state=""
while true; do
@@ -307,11 +307,26 @@ jobs:
sleep 30
done
conclusion="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion --jq '.conclusion')"
url="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json url --jq '.url')"
echo "${workflow} finished with ${conclusion}: ${url}"
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion,url,createdAt,updatedAt)"
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion')"
url="$(printf '%s' "$run_json" | jq -r '.url')"
created_at="$(printf '%s' "$run_json" | jq -r '.createdAt')"
updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')"
duration_seconds="$(
CREATED_AT="${created_at}" UPDATED_AT="${updated_at}" node --input-type=module -e '
const created = Date.parse(process.env.CREATED_AT ?? "");
const updated = Date.parse(process.env.UPDATED_AT ?? "");
console.log(Number.isFinite(created) && Number.isFinite(updated) ? Math.max(0, Math.round((updated - created) / 1000)) : "");
'
)"
if [[ -n "${duration_seconds}" ]]; then
duration_label="$((duration_seconds / 60))m$(printf '%02d' $((duration_seconds % 60)))s"
else
duration_label="unknown duration"
fi
echo "${workflow} finished with ${conclusion} in ${duration_label}: ${url}"
{
echo "- ${workflow}: ${conclusion} (${url})"
echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$conclusion" != "success" ]]; then
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
@@ -414,6 +429,10 @@ jobs:
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
{
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
} >> "$GITHUB_STEP_SUMMARY"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
@@ -428,6 +447,7 @@ jobs:
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
else
echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -228,7 +228,20 @@ jobs:
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
@@ -263,7 +276,7 @@ jobs:
id-token: write
strategy:
fail-fast: false
max-parallel: 12
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
@@ -309,7 +322,20 @@ jobs:
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
@@ -392,3 +418,62 @@ jobs:
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
- name: Verify published ClawHub package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub package verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} verified on ClawHub.`);
EOF

View File

@@ -95,8 +95,13 @@ the maintainer-only release runbook.
preview-passing plugins even when one preview cell flakes, and ends with
registry verification for every expected plugin version so partial publishes
remain visible and retryable. After publish, run
the post-publish package
acceptance against the published `openclaw@YYYY.M.D-beta.N` or
`pnpm release:verify-beta -- YYYY.M.D-beta.N --openclaw-npm-run <run-id> --plugin-npm-run <run-id> --plugin-clawhub-run <run-id>`
to verify the GitHub prerelease, npm `beta` dist-tags, npm integrity,
published install path, ClawHub exact versions, ClawHub artifacts, and child
workflow conclusions from one command. Add `--rerun-failed-clawhub` when the
ClawHub sidecar failed only in retryable jobs and should be rerun in place.
Then run the post-publish package acceptance against the published
`openclaw@YYYY.M.D-beta.N` or
`openclaw@beta` package. If a pushed or published prerelease needs a fix,
cut the next matching prerelease number; do not delete or rewrite the old
prerelease.
@@ -224,6 +229,11 @@ Validation` or from the `main`/release workflow ref so workflow logic and
`OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_CACHE_TEST=1 pnpm test:live:cache`
using both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` workflow secrets
- npm release preflight no longer waits on the separate release checks lane
- Before tagging a release candidate locally, run
`RELEASE_TAG=vYYYY.M.D-beta.N pnpm release:fast-pretag-check`. The helper
runs the fast release guardrails, plugin npm/ClawHub release checks, build,
UI build, and `release:openclaw:npm:check` in the order that catches common
approval-blocking mistakes before the GitHub publish workflow starts.
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- After npm publish, run
@@ -655,6 +665,9 @@ OpenClaw package must not be published.
`plugin_publish_scope=selected`
- `publish_openclaw_npm`: defaults to `true`; set `false` only when using the
workflow as a plugin-only repair orchestrator
- `wait_for_clawhub`: defaults to `false` so npm availability is not blocked by
the ClawHub sidecar; set `true` only when workflow completion must include
ClawHub completion
`OpenClaw Release Checks` accepts these operator-controlled inputs:

View File

@@ -1521,6 +1521,7 @@
"release-metadata:check": "node scripts/check-release-metadata-only.mjs",
"release:beta-smoke": "node --import tsx scripts/release-beta-smoke.ts",
"release:check": "pnpm release:generated:check && node --import tsx scripts/release-check.ts",
"release:fast-pretag-check": "bash scripts/release-fast-pretag-check.sh",
"release:generated:check": "node scripts/release-preflight.mjs --check",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
@@ -1528,6 +1529,7 @@
"release:plugins:clawhub:plan": "node --import tsx scripts/plugin-clawhub-release-plan.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"release:verify-beta": "node --import tsx scripts/release-verify-beta.ts",
"release:prep": "node scripts/release-preflight.mjs --fix",
"rtt": "node --import tsx scripts/rtt.ts",
"runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check",

View File

@@ -0,0 +1,462 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./plugin-npm-release.ts";
type JsonRecord = Record<string, unknown>;
export type ReleaseVerifyBetaArgs = {
version: string;
tag: string;
distTag: string;
repo: string;
registry: string;
skipPostpublish: boolean;
rerunFailedClawHub: boolean;
workflowRuns: {
openclawNpm?: string;
pluginNpm?: string;
pluginClawHub?: string;
};
};
export type NpmViewFields = {
version?: string;
distTagVersion?: string;
integrity?: string;
};
type WorkflowRunSummary = {
id: string;
label: string;
url?: string;
durationSeconds?: number;
};
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai";
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function requireString(value: unknown, label: string): string {
const stringValue = readString(value);
if (stringValue === undefined) {
throw new Error(`${label} is missing.`);
}
return stringValue;
}
function runCommand(command: string, args: string[], options: { cwd?: string } = {}): string {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function runCommandInherited(command: string, args: string[]): void {
execFileSync(command, args, {
stdio: "inherit",
});
}
function parseJson(raw: string, label: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${label} returned invalid JSON: ${message}`);
}
}
export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields {
const parsed = parseJson(raw, "npm view");
if (Array.isArray(parsed)) {
return {
version: readString(parsed[0]),
distTagVersion: readString(parsed[1]),
integrity: readString(parsed[2]),
};
}
if (!isRecord(parsed)) {
throw new Error("npm view returned an unsupported JSON shape.");
}
const distTags = isRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined;
const dist = isRecord(parsed.dist) ? parsed.dist : undefined;
return {
version: readString(parsed.version),
distTagVersion: readString(parsed[`dist-tags.${distTag}`]) ?? readString(distTags?.[distTag]),
integrity: readString(parsed["dist.integrity"]) ?? readString(dist?.integrity),
};
}
export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArgs {
const values = [...argv];
if (values[0] === "--") {
values.shift();
}
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]",
);
}
const parsed: ReleaseVerifyBetaArgs = {
version,
tag: `v${version}`,
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
skipPostpublish: false,
rerunFailedClawHub: false,
workflowRuns: {},
};
for (let index = 0; index < values.length; index += 1) {
const arg = values[index];
const next = () => {
const value = values[index + 1];
if (value === undefined || value.startsWith("-")) {
throw new Error(`${arg} requires a value.`);
}
index += 1;
return value;
};
switch (arg) {
case "--tag":
parsed.tag = next();
break;
case "--dist-tag":
parsed.distTag = next();
break;
case "--repo":
parsed.repo = next();
break;
case "--registry":
parsed.registry = next();
break;
case "--openclaw-npm-run":
parsed.workflowRuns.openclawNpm = next();
break;
case "--plugin-npm-run":
parsed.workflowRuns.pluginNpm = next();
break;
case "--plugin-clawhub-run":
parsed.workflowRuns.pluginClawHub = next();
break;
case "--skip-postpublish":
parsed.skipPostpublish = true;
break;
case "--rerun-failed-clawhub":
parsed.rerunFailedClawHub = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
async function fetchWithRetry(
url: string,
options: RequestInit,
attempts: number,
): Promise<Response> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, options);
if (response.status !== 429 && response.status < 500) {
return response;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
if (attempt < attempts) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 1000));
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`${url} did not return a stable response: ${message}`);
}
async function fetchJsonWithRetry(url: string): Promise<unknown> {
const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, 5);
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}.`);
}
return response.json() as Promise<unknown>;
}
async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
const response = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
return response.status;
}
function verifyNpmPackage(packageName: string, version: string, distTag: string): void {
const raw = runCommand("npm", [
"view",
`${packageName}@${version}`,
"version",
`dist-tags.${distTag}`,
"dist.integrity",
"--json",
]);
const fields = parseNpmViewFields(raw, distTag);
if (fields.version !== version) {
throw new Error(
`${packageName}: expected npm version ${version}, got ${fields.version ?? "<missing>"}.`,
);
}
if (fields.distTagVersion !== version) {
throw new Error(
`${packageName}: npm dist-tag ${distTag} points to ${fields.distTagVersion ?? "<missing>"}, expected ${version}.`,
);
}
if (fields.integrity === undefined) {
throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`);
}
}
function readClawHubTags(detail: unknown): Record<string, string> {
if (!isRecord(detail)) {
return {};
}
const packageDetail = isRecord(detail.package) ? detail.package : undefined;
const tags = isRecord(packageDetail?.tags) ? packageDetail.tags : undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(tags ?? {})) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
}
async function verifyClawHubPackage(params: {
registry: string;
packageName: string;
version: string;
distTag: string;
}): Promise<void> {
const base = params.registry.replace(/\/+$/u, "");
const encodedName = encodeURIComponent(params.packageName);
const encodedVersion = encodeURIComponent(params.version);
const detailUrl = `${base}/api/v1/packages/${encodedName}`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
const detail = await fetchJsonWithRetry(detailUrl);
const tags = readClawHubTags(detail);
if (tags[params.distTag] !== params.version) {
throw new Error(
`${params.packageName}: ClawHub tag ${params.distTag} points to ${tags[params.distTag] ?? "<missing>"}, expected ${params.version}.`,
);
}
const versionStatus = await fetchStatusWithRetry(versionUrl, "GET");
if (versionStatus < 200 || versionStatus >= 300) {
throw new Error(`${params.packageName}: ClawHub exact version returned HTTP ${versionStatus}.`);
}
const artifactStatus = await fetchStatusWithRetry(artifactUrl, "HEAD");
if (artifactStatus < 200 || artifactStatus >= 400) {
throw new Error(`${params.packageName}: ClawHub artifact returned HTTP ${artifactStatus}.`);
}
}
function verifyGitHubRelease(params: ReleaseVerifyBetaArgs): string {
const raw = runCommand("gh", [
"release",
"view",
params.tag,
"--repo",
params.repo,
"--json",
"tagName,isPrerelease,url",
]);
const release = parseJson(raw, "gh release view");
if (!isRecord(release)) {
throw new Error("GitHub release returned an unsupported JSON shape.");
}
if (release.tagName !== params.tag) {
throw new Error(
`GitHub release tag mismatch: expected ${params.tag}, got ${String(release.tagName)}.`,
);
}
if (params.version.includes("-beta.") && release.isPrerelease !== true) {
throw new Error(`${params.tag} is not marked as a GitHub prerelease.`);
}
return requireString(release.url, "GitHub release URL");
}
function verifyWorkflowRun(params: {
id: string;
label: string;
repo: string;
rerunFailed: boolean;
}): WorkflowRunSummary {
const raw = runCommand("gh", [
"run",
"view",
params.id,
"--repo",
params.repo,
"--json",
"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 status = readString(run.status);
const conclusion = readString(run.conclusion);
const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isRecord) : [];
const failedJobs = jobs.filter((job) => {
const jobConclusion = readString(job.conclusion);
return (
jobConclusion !== undefined && jobConclusion !== "success" && jobConclusion !== "skipped"
);
});
if (failedJobs.length > 0 && params.rerunFailed) {
runCommandInherited("gh", ["run", "rerun", params.id, "--repo", params.repo, "--failed"]);
throw new Error(
`${params.label}: reran ${failedJobs.length} failed job(s); rerun verifier after it finishes.`,
);
}
if (status !== "completed" || conclusion !== "success" || failedJobs.length > 0) {
const failedNames = failedJobs.map((job) => readString(job.name) ?? "<unnamed>").join(", ");
throw new Error(
`${params.label}: run ${params.id} is ${status ?? "<missing>"}/${conclusion ?? "<missing>"}${failedNames ? `; failed jobs: ${failedNames}` : ""}.`,
);
}
const createdAt = readString(run.createdAt);
const updatedAt = readString(run.updatedAt);
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
const durationSeconds =
Number.isFinite(createdMs) && Number.isFinite(updatedMs)
? Math.max(0, Math.round((updatedMs - createdMs) / 1000))
: undefined;
return {
id: params.id,
label: params.label,
url: readString(run.url),
durationSeconds,
};
}
function readRootPackageVersion(rootDir: string): string {
const packageJson = parseJson(
readFileSync(resolve(rootDir, "package.json"), "utf8"),
"package.json",
);
if (!isRecord(packageJson)) {
throw new Error("package.json returned an unsupported JSON shape.");
}
return requireString(packageJson.version, "package.json version");
}
function formatDuration(seconds: number | undefined): string {
if (seconds === undefined) {
return "unknown";
}
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
}
export async function verifyBetaRelease(
args: ReleaseVerifyBetaArgs,
options: { rootDir?: string } = {},
): Promise<string[]> {
const rootDir = options.rootDir ?? resolve(".");
const rootVersion = readRootPackageVersion(rootDir);
if (rootVersion !== args.version) {
throw new Error(`package.json version is ${rootVersion}; expected ${args.version}.`);
}
const lines: string[] = [];
const releaseUrl = verifyGitHubRelease(args);
lines.push(`GitHub release OK: ${releaseUrl}`);
verifyNpmPackage("openclaw", args.version, args.distTag);
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
if (!args.skipPostpublish) {
runCommandInherited("node", [
"--import",
"tsx",
"scripts/openclaw-npm-postpublish-verify.ts",
args.version,
]);
lines.push("openclaw postpublish verifier OK");
}
const npmPlugins = collectPublishablePluginPackages(rootDir);
for (const plugin of npmPlugins) {
verifyNpmPackage(plugin.packageName, args.version, args.distTag);
}
lines.push(`plugin npm OK: ${npmPlugins.length}`);
const clawHubPlugins = collectClawHubPublishablePluginPackages(rootDir);
for (const plugin of clawHubPlugins) {
await verifyClawHubPackage({
registry: args.registry,
packageName: plugin.packageName,
version: args.version,
distTag: args.distTag,
});
}
lines.push(`ClawHub OK: ${clawHubPlugins.length}`);
const workflowRuns: WorkflowRunSummary[] = [];
if (args.workflowRuns.pluginNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.pluginNpm,
label: "Plugin NPM Release",
repo: args.repo,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.pluginClawHub !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.pluginClawHub,
label: "Plugin ClawHub Release",
repo: args.repo,
rerunFailed: args.rerunFailedClawHub,
}),
);
}
if (args.workflowRuns.openclawNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.openclawNpm,
label: "OpenClaw NPM Release",
repo: args.repo,
rerunFailed: false,
}),
);
}
for (const run of workflowRuns) {
lines.push(
`${run.label} OK: ${run.id} (${formatDuration(run.durationSeconds)})${run.url ? ` ${run.url}` : ""}`,
);
}
return lines;
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
version="$(node -p 'require("./package.json").version')"
release_tag="${RELEASE_TAG:-v${version}}"
export RELEASE_TAG="${release_tag}"
echo "release tag: ${RELEASE_TAG}"
git diff --check
pnpm check:temp-path-guardrails
pnpm release-metadata:check
pnpm plugins:sync:check
pnpm release:generated:check
pnpm release:plugins:npm:check -- --selection-mode all-publishable
pnpm release:plugins:clawhub:check -- --selection-mode all-publishable
pnpm build
pnpm ui:build
pnpm release:openclaw:npm:check

17
scripts/release-verify-beta.ts Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env -S node --import tsx
import { parseReleaseVerifyBetaArgs, verifyBetaRelease } from "./lib/release-beta-verifier.ts";
async function main() {
const args = parseReleaseVerifyBetaArgs(process.argv.slice(2));
const lines = await verifyBetaRelease(args);
for (const line of lines) {
console.log(line);
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
});

View File

@@ -876,6 +876,28 @@ describe("package artifact reuse", () => {
expect(workflow).not.toContain("timeout-minutes: 360");
});
it("keeps beta release verification and ClawHub publish repair hooks wired", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
scripts?: Record<string, string>;
};
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const clawHubWorkflow = readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8");
expect(packageJson.scripts?.["release:verify-beta"]).toBe(
"node --import tsx scripts/release-verify-beta.ts",
);
expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe(
"bash scripts/release-fast-pretag-check.sh",
);
expect(clawHubWorkflow).toContain("Verify published ClawHub package");
expect(clawHubWorkflow).toContain("bun install failed while preparing ClawHub CLI; retrying");
expect(clawHubWorkflow).toContain("max-parallel: 32");
expect(releaseWorkflow).toContain("Plugin npm run ID");
expect(releaseWorkflow).toContain("Plugin ClawHub run ID");
expect(releaseWorkflow).toContain("OpenClaw npm run ID");
expect(releaseWorkflow).toContain("finished with ${conclusion} in ${duration_label}");
});
it("keeps release workflow setup and timeout budgets bounded", () => {
const fullRelease = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW);
const releaseChecks = readWorkflow(RELEASE_CHECKS_WORKFLOW);

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
parseNpmViewFields,
parseReleaseVerifyBetaArgs,
} from "../../scripts/lib/release-beta-verifier.ts";
describe("parseReleaseVerifyBetaArgs", () => {
it("defaults beta verification to the matching tag and repo", () => {
expect(parseReleaseVerifyBetaArgs(["2026.5.10-beta.3"])).toMatchObject({
version: "2026.5.10-beta.3",
tag: "v2026.5.10-beta.3",
distTag: "beta",
repo: "openclaw/openclaw",
registry: "https://clawhub.ai",
skipPostpublish: false,
rerunFailedClawHub: false,
workflowRuns: {},
});
});
it("parses child run IDs and repair flags", () => {
expect(
parseReleaseVerifyBetaArgs([
"--",
"2026.5.10-beta.3",
"--openclaw-npm-run",
"11",
"--plugin-npm-run",
"22",
"--plugin-clawhub-run",
"33",
"--skip-postpublish",
"--rerun-failed-clawhub",
]),
).toMatchObject({
skipPostpublish: true,
rerunFailedClawHub: true,
workflowRuns: {
openclawNpm: "11",
pluginNpm: "22",
pluginClawHub: "33",
},
});
});
});
describe("parseNpmViewFields", () => {
it("accepts keyed npm view JSON", () => {
expect(
parseNpmViewFields(
JSON.stringify({
version: "2026.5.10-beta.3",
"dist-tags.beta": "2026.5.10-beta.3",
"dist.integrity": "sha512-test",
}),
"beta",
),
).toEqual({
version: "2026.5.10-beta.3",
distTagVersion: "2026.5.10-beta.3",
integrity: "sha512-test",
});
});
it("accepts nested npm view JSON", () => {
expect(
parseNpmViewFields(
JSON.stringify({
version: "2026.5.10-beta.3",
"dist-tags": { beta: "2026.5.10-beta.3" },
dist: { integrity: "sha512-test" },
}),
"beta",
),
).toEqual({
version: "2026.5.10-beta.3",
distTagVersion: "2026.5.10-beta.3",
integrity: "sha512-test",
});
});
});