ci: harden release validation flow

This commit is contained in:
Peter Steinberger
2026-05-16 13:53:08 +01:00
parent a535978352
commit c4d8e0be18
8 changed files with 385 additions and 31 deletions

View File

@@ -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] ?? "<missing>"}.`); 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[@]:-<none>}" >&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

View File

@@ -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[@]:-<none>}" >&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

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./plugin-npm-release.ts";
@@ -12,12 +12,15 @@ export type ReleaseVerifyBetaArgs = {
distTag: string;
repo: string;
registry: string;
evidenceOut?: string;
skipPostpublish: boolean;
rerunFailedClawHub: boolean;
workflowRuns: {
fullReleaseValidation?: string;
openclawNpm?: string;
pluginNpm?: string;
pluginClawHub?: string;
npmTelegram?: string;
};
};
@@ -105,7 +108,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
const version = values.shift();
if (!version || version.startsWith("-")) {
throw new Error(
"Usage: pnpm release:verify-beta -- <version> [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID]",
"Usage: pnpm release:verify-beta -- <version> [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--npm-telegram-run ID]",
);
}
@@ -115,6 +118,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
evidenceOut: undefined,
skipPostpublish: false,
rerunFailedClawHub: false,
workflowRuns: {},
@@ -144,6 +148,12 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--registry":
parsed.registry = next();
break;
case "--evidence-out":
parsed.evidenceOut = next();
break;
case "--full-release-validation-run":
parsed.workflowRuns.fullReleaseValidation = next();
break;
case "--openclaw-npm-run":
parsed.workflowRuns.openclawNpm = next();
break;
@@ -153,6 +163,9 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg
case "--plugin-clawhub-run":
parsed.workflowRuns.pluginClawHub = next();
break;
case "--npm-telegram-run":
parsed.workflowRuns.npmTelegram = next();
break;
case "--skip-postpublish":
parsed.skipPostpublish = true;
break;
@@ -204,7 +217,7 @@ async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promis
return response.status;
}
function verifyNpmPackage(packageName: string, version: string, distTag: string): void {
function verifyNpmPackage(packageName: string, version: string, distTag: string): NpmViewFields {
const raw = runCommand("npm", [
"view",
`${packageName}@${version}`,
@@ -227,6 +240,7 @@ function verifyNpmPackage(packageName: string, version: string, distTag: string)
if (fields.integrity === undefined) {
throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`);
}
return fields;
}
function readClawHubTags(detail: unknown): Record<string, string> {
@@ -391,7 +405,7 @@ export async function verifyBetaRelease(
const releaseUrl = verifyGitHubRelease(args);
lines.push(`GitHub release OK: ${releaseUrl}`);
verifyNpmPackage("openclaw", args.version, args.distTag);
const openclawNpm = verifyNpmPackage("openclaw", args.version, args.distTag);
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
if (!args.skipPostpublish) {
@@ -422,6 +436,16 @@ export async function verifyBetaRelease(
lines.push(`ClawHub OK: ${clawHubPlugins.length}`);
const workflowRuns: WorkflowRunSummary[] = [];
if (args.workflowRuns.fullReleaseValidation !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.fullReleaseValidation,
label: "Full Release Validation",
repo: args.repo,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.pluginNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
@@ -452,11 +476,45 @@ export async function verifyBetaRelease(
}),
);
}
if (args.workflowRuns.npmTelegram !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.npmTelegram,
label: "NPM Telegram Beta E2E",
repo: args.repo,
rerunFailed: false,
}),
);
}
for (const run of workflowRuns) {
lines.push(
`${run.label} OK: ${run.id} (${formatDuration(run.durationSeconds)})${run.url ? ` ${run.url}` : ""}`,
);
}
if (args.evidenceOut !== undefined) {
const evidencePath = resolve(rootDir, args.evidenceOut);
mkdirSync(dirname(evidencePath), { recursive: true });
writeFileSync(
evidencePath,
`${JSON.stringify(
{
version: 1,
releaseVersion: args.version,
releaseTag: args.tag,
npmDistTag: args.distTag,
openclawNpmIntegrity: openclawNpm.integrity,
githubReleaseUrl: releaseUrl,
pluginNpmPackageCount: npmPlugins.length,
clawHubPackageCount: clawHubPlugins.length,
workflowRuns,
},
null,
2,
)}\n`,
);
lines.push(`release evidence written: ${args.evidenceOut}`);
}
return lines;
}

View File

@@ -10,13 +10,14 @@ const DEFAULT_MODE = "both";
const DEFAULT_RELEASE_PROFILE = "beta";
const DEFAULT_NPM_DIST_TAG = "beta";
const DEFAULT_PLUGIN_SCOPE = "all-publishable";
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
function usage() {
return `Usage: pnpm release:candidate -- --tag vYYYY.M.D-beta.N [options]
Dispatches or consumes release validation runs, validates the prepared npm tarball,
builds plugin publish plans, writes a green evidence bundle, then prints the exact
OpenClaw Release Publish command.
OpenClaw Release Publish command only after everything is green.
Options:
--tag <tag> Release tag to validate.
@@ -26,6 +27,8 @@ Options:
--npm-preflight-run <id> Reuse successful OpenClaw NPM Release preflight run.
--skip-dispatch Require both run ids; do not dispatch workflows.
--skip-parallels Do not run local Parallels fresh/update beta smoke.
--skip-telegram Do not run NPM Telegram E2E against the prepared tarball.
--telegram-provider-mode <mode> mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE}
--provider <provider> Full validation provider. Default: ${DEFAULT_PROVIDER}
--mode <fresh|upgrade|both> Full validation cross-OS mode. Default: ${DEFAULT_MODE}
--release-profile <beta|stable|full> Default: ${DEFAULT_RELEASE_PROFILE}
@@ -55,6 +58,8 @@ export function parseArgs(argv) {
plugins: "",
skipDispatch: false,
skipParallels: false,
skipTelegram: false,
telegramProviderMode: DEFAULT_TELEGRAM_PROVIDER_MODE,
tag: "",
workflowRef: "",
fullReleaseRunId: "",
@@ -87,6 +92,12 @@ export function parseArgs(argv) {
case "--skip-parallels":
options.skipParallels = true;
break;
case "--skip-telegram":
options.skipTelegram = true;
break;
case "--telegram-provider-mode":
options.telegramProviderMode = requireValue(argv, ++index, arg);
break;
case "--provider":
options.provider = requireValue(argv, ++index, arg);
break;
@@ -128,6 +139,9 @@ export function parseArgs(argv) {
if (options.pluginPublishScope === "all-publishable" && options.plugins.trim()) {
throw new Error("--plugins is only valid with --plugin-publish-scope selected");
}
if (!["mock-openai", "live-frontier"].includes(options.telegramProviderMode)) {
throw new Error("--telegram-provider-mode must be mock-openai or live-frontier");
}
return options;
}
@@ -179,6 +193,44 @@ function workflowRuns(repo, workflowFile) {
);
}
function runArtifacts(repo, runId) {
return JSON.parse(
run(
"gh",
[
"api",
`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`,
"--jq",
".artifacts | map({name:.name, expired:.expired})",
],
{ capture: true },
),
);
}
export function resolveArtifactName(artifacts, preferredName, prefix) {
const available = artifacts
.filter((artifact) => artifact.expired !== true)
.map((artifact) => artifact.name);
if (available.includes(preferredName)) {
return preferredName;
}
const candidates = available.filter((name) => name.startsWith(prefix));
if (candidates.length === 1) {
console.warn(`artifact ${preferredName} not found; using ${candidates[0]} from the same run`);
return candidates[0];
}
const candidateList =
available.length > 0 ? available.map((name) => `- ${name}`).join("\n") : "- <none>";
throw new Error(
`artifact ${preferredName} not found in run. Expected ${preferredName} or exactly one ${prefix}* fallback.\nAvailable artifacts:\n${candidateList}`,
);
}
function resolveRunArtifactName(repo, runId, preferredName, prefix) {
return resolveArtifactName(runArtifacts(repo, runId), preferredName, prefix);
}
function beforeRunIds(repo, workflowFile) {
return new Set(workflowRuns(repo, workflowFile).map((run) => String(run.databaseId)));
}
@@ -256,6 +308,33 @@ function runInfo(repo, runId) {
);
}
function pendingDeployments(repo, runId) {
try {
return JSON.parse(
run("gh", ["api", "-X", "GET", `repos/${repo}/actions/runs/${runId}/pending_deployments`], {
capture: true,
}),
);
} catch {
return [];
}
}
function summarizePendingDeployments(repo, runId, deployments) {
if (!Array.isArray(deployments) || deployments.length === 0) {
return "";
}
return deployments
.map((deployment) => {
const environment = deployment.environment ?? {};
return [
`- pending approval: env=${environment.name ?? "<unknown>"} canApprove=${String(deployment.current_user_can_approve ?? "<unknown>")}`,
` approve: gh api -X POST repos/${repo}/actions/runs/${runId}/pending_deployments -F 'environment_ids[]=${environment.id ?? "<id>"}' -f state=approved -f comment='Approve release gate'`,
].join("\n");
})
.join("\n");
}
function summarizeFailedRun(info) {
const failedJobs = (info.jobs ?? []).filter(
(job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped",
@@ -267,11 +346,20 @@ function summarizeFailedRun(info) {
}
async function waitForSuccessfulRun(repo, runId, expected) {
let lastState = "";
for (;;) {
const info = runInfo(repo, runId);
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
const state = `${info.status}:${info.conclusion ?? ""}`;
if (state !== lastState) {
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
const pending = summarizePendingDeployments(repo, runId, pendingDeployments(repo, runId));
if (pending) {
console.log(pending);
}
lastState = state;
}
if (info.status === "completed") {
if (info.conclusion !== "success") {
throw new Error(summarizeFailedRun(info));
@@ -298,6 +386,12 @@ function downloadArtifact(repo, runId, name, dir) {
run("gh", ["run", "download", runId, "--repo", repo, "--name", name, "--dir", dir]);
}
function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) {
const name = resolveRunArtifactName(repo, runId, preferredName, prefix);
downloadArtifact(repo, runId, name, dir);
return name;
}
function sha256(path) {
return run("shasum", ["-a", "256", path], { capture: true }).trim().split(/\s+/u)[0] ?? "";
}
@@ -430,6 +524,36 @@ async function runParallelsIfNeeded(options) {
};
}
async function runTelegramIfNeeded(options, artifactName) {
if (options.skipTelegram) {
return { status: "skipped" };
}
const workflowFile = "npm-telegram-beta-e2e.yml";
const before = beforeRunIds(options.repo, workflowFile);
const dispatchedRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`,
package_label: options.tag,
package_artifact_name: artifactName,
package_artifact_run_id: options.npmPreflightRunId,
harness_ref: options.workflowRef,
provider_mode: options.telegramProviderMode,
});
const runId =
dispatchedRunId ||
(await findNewRunId(options.repo, workflowFile, "NPM Telegram Beta E2E", before));
const run = await waitForSuccessfulRun(options.repo, runId, {
workflowName: "NPM Telegram Beta E2E",
workflowRef: options.workflowRef,
});
return {
status: "passed",
runId,
url: run.url,
artifactName,
providerMode: options.telegramProviderMode,
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
options.workflowRef ||= currentBranch();
@@ -481,16 +605,18 @@ async function main() {
const npmDir = join(options.outputDir, "npm-preflight");
const fullDir = join(options.outputDir, "full-release-validation");
downloadArtifact(
const npmArtifactName = downloadResolvedArtifact(
options.repo,
options.npmPreflightRunId,
`openclaw-npm-preflight-${options.tag}`,
"openclaw-npm-preflight-",
npmDir,
);
downloadArtifact(
const fullArtifactName = downloadResolvedArtifact(
options.repo,
options.fullReleaseRunId,
`full-release-validation-${options.fullReleaseRunId}`,
"full-release-validation-",
fullDir,
);
@@ -520,6 +646,7 @@ async function main() {
}
const parallels = await runParallelsIfNeeded(options);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
const pluginNpmPlan = await collectPluginPlanWithRetry(
"scripts/plugin-npm-release-plan.ts",
options,
@@ -539,21 +666,56 @@ async function main() {
npmPreflightRunId: options.npmPreflightRunId,
fullReleaseValidationUrl: fullRun.url,
npmPreflightUrl: npmRun.url,
artifacts: {
npmPreflight: npmArtifactName,
fullReleaseValidation: fullArtifactName,
},
tarball: {
name: basename(tarballPath),
sha256: actualTarballSha,
path: tarballPath,
},
parallels,
npmTelegram,
pluginNpmPlan,
pluginClawHubPlan,
publishCommand,
};
mkdirSync(options.outputDir, { recursive: true });
const evidencePath = join(options.outputDir, "release-candidate-evidence.json");
const evidenceMarkdownPath = join(options.outputDir, "release-candidate-evidence.md");
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
writeFileSync(
evidenceMarkdownPath,
[
`# ${options.tag} release candidate evidence`,
"",
`- target SHA: ${targetSha}`,
`- full release validation: ${options.fullReleaseRunId} ${fullRun.url}`,
`- npm preflight: ${options.npmPreflightRunId} ${npmRun.url}`,
`- npm preflight artifact: ${npmArtifactName}`,
`- full release artifact: ${fullArtifactName}`,
`- tarball: ${basename(tarballPath)}`,
`- tarball sha256: ${actualTarballSha}`,
`- npm dist-tag: ${options.npmDistTag}`,
`- plugin npm plan: ${pluginNpmPlan.packages?.length ?? 0} packages`,
`- ClawHub plan: ${pluginClawHubPlan.packages?.length ?? 0} packages`,
`- Parallels: ${parallels.status}`,
`- NPM Telegram E2E: ${npmTelegram.status}${
npmTelegram.runId ? ` ${npmTelegram.runId} ${npmTelegram.url}` : ""
}`,
"",
"Publish command:",
"",
"```bash",
publishCommand,
"```",
"",
].join("\n"),
);
console.log(`release candidate evidence: ${evidencePath}`);
console.log(`release candidate summary: ${evidenceMarkdownPath}`);
console.log("publish command:");
console.log(publishCommand);
}

View File

@@ -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",
);

View File

@@ -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",
},
});
});

View File

@@ -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");
});
});