From c4d8e0be18159380d2b5834f3cd32ae2b464cce3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 13:53:08 +0100 Subject: [PATCH] ci: harden release validation flow --- .github/workflows/openclaw-npm-release.yml | 70 ++++++- .../workflows/openclaw-release-publish.yml | 79 ++++++-- package.json | 1 + scripts/lib/release-beta-verifier.ts | 68 ++++++- scripts/release-candidate-checklist.mjs | 174 +++++++++++++++++- .../package-acceptance-workflow.test.ts | 3 + test/scripts/release-beta-verifier.test.ts | 10 + .../release-candidate-checklist.test.ts | 11 ++ 8 files changed, 385 insertions(+), 31 deletions(-) diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 4b6b73214bf..33ba72ce9af 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -191,7 +191,7 @@ jobs: id: packed_tarball env: OPENCLAW_PREPACK_PREPARED: "1" - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_REF: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }} run: | @@ -259,6 +259,11 @@ jobs: fi RELEASE_SHA="$(git rev-parse HEAD)" PACKAGE_VERSION="$(node -p "require('./package.json').version")" + if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then + RELEASE_TAG="v${PACKAGE_VERSION}" + else + RELEASE_TAG="${RELEASE_REF}" + fi TARBALL_NAME="$(basename "$PACK_PATH")" TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')" ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" @@ -290,6 +295,7 @@ jobs: ); NODE echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" + echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" - name: Verify prepared npm tarball install env: @@ -312,6 +318,14 @@ jobs: path: ${{ steps.dependency_evidence.outputs.dir }} if-no-files-found: error + - name: Upload dependency release evidence tag alias + if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }} + uses: actions/upload-artifact@v7 + with: + name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }} + path: ${{ steps.dependency_evidence.outputs.dir }} + if-no-files-found: error + - name: Upload prepared npm publish bundle uses: actions/upload-artifact@v7 with: @@ -319,6 +333,14 @@ jobs: path: ${{ steps.packed_tarball.outputs.dir }} if-no-files-found: error + - name: Upload prepared npm publish bundle tag alias + if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }} + uses: actions/upload-artifact@v7 + with: + name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }} + path: ${{ steps.packed_tarball.outputs.dir }} + if-no-files-found: error + validate_publish_request: if: ${{ !inputs.preflight_only }} runs-on: ubuntu-latest @@ -427,13 +449,45 @@ jobs: printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);' - name: Download prepared npm tarball - uses: actions/download-artifact@v8 - with: - name: openclaw-npm-preflight-${{ inputs.tag }} - path: preflight-tarball - repository: ${{ github.repository }} - run-id: ${{ inputs.preflight_run_id }} - github-token: ${{ github.token }} + env: + GH_TOKEN: ${{ github.token }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + + download_preflight_artifact() { + local preferred_name fallback_name + preferred_name="openclaw-npm-preflight-${RELEASE_TAG}" + rm -rf preflight-tarball + mkdir -p preflight-tarball + if gh run download "${PREFLIGHT_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${preferred_name}" \ + --dir preflight-tarball; then + echo "Downloaded ${preferred_name}." + return 0 + fi + + echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact." + mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \ + --jq '.artifacts[] | select(.expired != true) | .name' | + grep '^openclaw-npm-preflight-' || true) + if [[ "${#matches[@]}" != "1" ]]; then + echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2 + printf 'Available preflight candidates:\n' >&2 + printf -- '- %s\n' "${matches[@]:-}" >&2 + exit 1 + fi + fallback_name="${matches[0]}" + gh run download "${PREFLIGHT_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${fallback_name}" \ + --dir preflight-tarball + echo "Downloaded fallback preflight artifact ${fallback_name}." + } + + download_preflight_artifact - name: Download full release validation manifest uses: actions/download-artifact@v8 diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 6f1568a479e..529b2d19e75 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -76,6 +76,7 @@ jobs: timeout-minutes: 20 outputs: sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }} + preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }} steps: - name: Validate inputs env: @@ -131,14 +132,43 @@ jobs: esac - name: Download OpenClaw npm preflight manifest + id: preflight_artifact if: ${{ inputs.publish_openclaw_npm }} - uses: actions/download-artifact@v8 - with: - name: openclaw-npm-preflight-${{ inputs.tag }} - path: ${{ runner.temp }}/openclaw-npm-preflight-manifest - repository: ${{ github.repository }} - run-id: ${{ inputs.preflight_run_id }} - github-token: ${{ github.token }} + env: + GH_TOKEN: ${{ github.token }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + + preferred_name="openclaw-npm-preflight-${RELEASE_TAG}" + preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest" + rm -rf "${preflight_dir}" + mkdir -p "${preflight_dir}" + if gh run download "${PREFLIGHT_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${preferred_name}" \ + --dir "${preflight_dir}"; then + echo "name=${preferred_name}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact." + mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \ + --jq '.artifacts[] | select(.expired != true) | .name' | + grep '^openclaw-npm-preflight-' || true) + if [[ "${#matches[@]}" != "1" ]]; then + echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2 + printf 'Available preflight candidates:\n' >&2 + printf -- '- %s\n' "${matches[@]:-}" >&2 + exit 1 + fi + fallback_name="${matches[0]}" + gh run download "${PREFLIGHT_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${fallback_name}" \ + --dir "${preflight_dir}" + echo "name=${fallback_name}" >> "$GITHUB_OUTPUT" - name: Download full release validation manifest if: ${{ inputs.publish_openclaw_npm }} @@ -306,6 +336,7 @@ jobs: PLUGINS: ${{ inputs.plugins }} PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }} WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }} + PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }} run: | set -euo pipefail @@ -314,7 +345,10 @@ jobs: shift local before_json dispatch_output run_id - before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" + before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -F event=workflow_dispatch \ + -F per_page=100 \ + --jq '[.workflow_runs[].id]')" dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" printf '%s\n' "$dispatch_output" >&2 @@ -327,8 +361,10 @@ jobs: if [[ -z "$run_id" ]]; then for _ in $(seq 1 60); do run_id="$( - BEFORE_IDS="$before_json" gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \ - --jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty' + BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -F event=workflow_dispatch \ + -F per_page=50 \ + --jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty' )" if [[ -n "$run_id" ]]; then break @@ -349,6 +385,23 @@ jobs: printf '%s\n' "${run_id}" } + print_pending_deployments() { + local workflow="$1" + local run_id="$2" + local pending_json + + pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)" + if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then + return 0 + fi + + echo "${workflow} pending environment approval:" + while IFS=$'\t' read -r env_id env_name can_approve; do + echo "- env=${env_name} canApprove=${can_approve}" + echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'" + done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv') + } + wait_for_run() { local workflow="$1" local run_id="$2" @@ -366,6 +419,7 @@ jobs: state="${status}:${updated_at}" if [[ "$state" != "$last_state" ]]; then echo "${workflow} still ${status} (updated ${updated_at}): ${url}" + print_pending_deployments "${workflow}" "${run_id}" last_state="$state" fi sleep 30 @@ -466,17 +520,18 @@ jobs: } upload_dependency_evidence_release_asset() { - local release_version download_dir asset_path asset_name + local release_version download_dir asset_path asset_name artifact_name release_version="${RELEASE_TAG#v}" download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset" asset_name="openclaw-${release_version}-dependency-evidence.zip" asset_path="${RUNNER_TEMP}/${asset_name}" + artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}" rm -rf "${download_dir}" "${asset_path}" mkdir -p "${download_dir}" gh run download "${PREFLIGHT_RUN_ID}" \ --repo "${GITHUB_REPOSITORY}" \ - --name "openclaw-npm-preflight-${RELEASE_TAG}" \ + --name "${artifact_name}" \ --dir "${download_dir}" if [[ ! -d "${download_dir}/dependency-evidence" ]]; then diff --git a/package.json b/package.json index f3bb8e10ca9..36b8937eebe 100644 --- a/package.json +++ b/package.json @@ -1553,6 +1553,7 @@ "qa:otel:smoke": "node --import tsx scripts/qa-otel-smoke.ts", "qa:telegram-user:crabbox": "node --import tsx scripts/e2e/telegram-user-crabbox-proof.ts", "release-metadata:check": "node scripts/check-release-metadata-only.mjs", + "release:beta": "node scripts/release-candidate-checklist.mjs", "release:beta-smoke": "node --import tsx scripts/release-beta-smoke.ts", "release:candidate": "node scripts/release-candidate-checklist.mjs", "release:check": "pnpm release:generated:check && node --import tsx scripts/release-check.ts", diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 1ed937c362d..c02efa3bf36 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -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 -- [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID]", + "Usage: pnpm release:verify-beta -- [--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 { @@ -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; } diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 4fc80b2586f..88f6cb0ccf0 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -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 Release tag to validate. @@ -26,6 +27,8 @@ Options: --npm-preflight-run 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 mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE} --provider Full validation provider. Default: ${DEFAULT_PROVIDER} --mode Full validation cross-OS mode. Default: ${DEFAULT_MODE} --release-profile 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") : "- "; + 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 ?? ""} canApprove=${String(deployment.current_user_can_approve ?? "")}`, + ` approve: gh api -X POST repos/${repo}/actions/runs/${runId}/pending_deployments -F 'environment_ids[]=${environment.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); } diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 7b3d3ebc0a8..fa2ea9c6da2 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -921,6 +921,9 @@ describe("package artifact reuse", () => { expect(packageJson.scripts?.["release:candidate"]).toBe( "node scripts/release-candidate-checklist.mjs", ); + expect(packageJson.scripts?.["release:beta"]).toBe( + "node scripts/release-candidate-checklist.mjs", + ); expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe( "bash scripts/release-fast-pretag-check.sh", ); diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index 8738d8edff5..ef1c53583d9 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -12,6 +12,7 @@ describe("parseReleaseVerifyBetaArgs", () => { distTag: "beta", repo: "openclaw/openclaw", registry: "https://clawhub.ai", + evidenceOut: undefined, skipPostpublish: false, rerunFailedClawHub: false, workflowRuns: {}, @@ -23,12 +24,18 @@ describe("parseReleaseVerifyBetaArgs", () => { parseReleaseVerifyBetaArgs([ "--", "2026.5.10-beta.3", + "--full-release-validation-run", + "10", "--openclaw-npm-run", "11", "--plugin-npm-run", "22", "--plugin-clawhub-run", "33", + "--npm-telegram-run", + "44", + "--evidence-out", + ".artifacts/release-evidence.json", "--skip-postpublish", "--rerun-failed-clawhub", ]), @@ -38,12 +45,15 @@ describe("parseReleaseVerifyBetaArgs", () => { distTag: "beta", repo: "openclaw/openclaw", registry: "https://clawhub.ai", + evidenceOut: ".artifacts/release-evidence.json", skipPostpublish: true, rerunFailedClawHub: true, workflowRuns: { + fullReleaseValidation: "10", openclawNpm: "11", pluginNpm: "22", pluginClawHub: "33", + npmTelegram: "44", }, }); }); diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 6f2301c4a1f..eb7524b5f71 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -3,6 +3,7 @@ import { buildPublishCommand, parseArgs, parseRunIdFromDispatchOutput, + resolveArtifactName, } from "../../scripts/release-candidate-checklist.mjs"; describe("release candidate checklist", () => { @@ -47,4 +48,14 @@ describe("release candidate checklist", () => { ), ).toBe("25922042055"); }); + + it("falls back to a single compatible artifact from the same run", () => { + expect( + resolveArtifactName( + [{ name: "openclaw-npm-preflight-dba00", expired: false }], + "openclaw-npm-preflight-v2026.5.16-beta.2", + "openclaw-npm-preflight-", + ), + ).toBe("openclaw-npm-preflight-dba00"); + }); });