mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 09:34:46 +00:00
ci: harden release validation flow
This commit is contained in:
70
.github/workflows/openclaw-npm-release.yml
vendored
70
.github/workflows/openclaw-npm-release.yml
vendored
@@ -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
|
||||
|
||||
79
.github/workflows/openclaw-release-publish.yml
vendored
79
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user