diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 16459d7b4a4..a77d3a62778 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -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 diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index dede7387a0c..cbee904e88d 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -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] ?? ""}, 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 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index f81abd35982..ea322ec2f37 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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 --plugin-npm-run --plugin-clawhub-run ` + 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: diff --git a/package.json b/package.json index c455e8ccadc..900f441e339 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts new file mode 100644 index 00000000000..6cfc4ab3664 --- /dev/null +++ b/scripts/lib/release-beta-verifier.ts @@ -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; + +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 -- [--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 { + 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 { + 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; +} + +async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise { + 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 ?? ""}.`, + ); + } + if (fields.distTagVersion !== version) { + throw new Error( + `${packageName}: npm dist-tag ${distTag} points to ${fields.distTagVersion ?? ""}, expected ${version}.`, + ); + } + if (fields.integrity === undefined) { + throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`); + } +} + +function readClawHubTags(detail: unknown): Record { + if (!isRecord(detail)) { + return {}; + } + const packageDetail = isRecord(detail.package) ? detail.package : undefined; + const tags = isRecord(packageDetail?.tags) ? packageDetail.tags : undefined; + const result: Record = {}; + 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 { + 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] ?? ""}, 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) ?? "").join(", "); + throw new Error( + `${params.label}: run ${params.id} is ${status ?? ""}/${conclusion ?? ""}${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 { + 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; +} diff --git a/scripts/release-fast-pretag-check.sh b/scripts/release-fast-pretag-check.sh new file mode 100755 index 00000000000..b378f7756d5 --- /dev/null +++ b/scripts/release-fast-pretag-check.sh @@ -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 diff --git a/scripts/release-verify-beta.ts b/scripts/release-verify-beta.ts new file mode 100755 index 00000000000..32b210c3f21 --- /dev/null +++ b/scripts/release-verify-beta.ts @@ -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; +}); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 96e85740546..84bd43f994f 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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; + }; + 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); diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts new file mode 100644 index 00000000000..012c583d23b --- /dev/null +++ b/test/scripts/release-beta-verifier.test.ts @@ -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", + }); + }); +});