mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
ci: speed up beta release verification
This commit is contained in:
30
.github/workflows/openclaw-release-publish.yml
vendored
30
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
|
||||
|
||||
91
.github/workflows/plugin-clawhub-release.yml
vendored
91
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -228,7 +228,20 @@ jobs:
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
@@ -263,7 +276,7 @@ jobs:
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
@@ -309,7 +322,20 @@ jobs:
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
@@ -392,3 +418,62 @@ jobs:
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
|
||||
- name: Verify published ClawHub package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'EOF'
|
||||
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
|
||||
const packageName = process.env.PACKAGE_NAME;
|
||||
const packageVersion = process.env.PACKAGE_VERSION;
|
||||
const packageTag = process.env.PACKAGE_TAG;
|
||||
if (!packageName || !packageVersion || !packageTag) {
|
||||
throw new Error("Missing ClawHub package verification env.");
|
||||
}
|
||||
const encodedName = encodeURIComponent(packageName);
|
||||
const encodedVersion = encodeURIComponent(packageVersion);
|
||||
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
|
||||
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
|
||||
const artifactUrl = `${versionUrl}/artifact/download`;
|
||||
|
||||
async function fetchWithRetry(url, options = {}) {
|
||||
let lastStatus = "unknown";
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
const response = await fetch(url, { redirect: "manual", ...options });
|
||||
lastStatus = response.status;
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
|
||||
}
|
||||
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
|
||||
}
|
||||
|
||||
const detailResponse = await fetchWithRetry(detailUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
|
||||
}
|
||||
const detail = await detailResponse.json();
|
||||
const tags = detail?.package?.tags ?? {};
|
||||
if (tags[packageTag] !== packageVersion) {
|
||||
throw new Error(
|
||||
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
const versionResponse = await fetchWithRetry(versionUrl);
|
||||
if (!versionResponse.ok) {
|
||||
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
|
||||
}
|
||||
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
|
||||
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
|
||||
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
|
||||
}
|
||||
console.log(`${packageName}@${packageVersion} verified on ClawHub.`);
|
||||
EOF
|
||||
|
||||
@@ -95,8 +95,13 @@ the maintainer-only release runbook.
|
||||
preview-passing plugins even when one preview cell flakes, and ends with
|
||||
registry verification for every expected plugin version so partial publishes
|
||||
remain visible and retryable. After publish, run
|
||||
the post-publish package
|
||||
acceptance against the published `openclaw@YYYY.M.D-beta.N` or
|
||||
`pnpm release:verify-beta -- YYYY.M.D-beta.N --openclaw-npm-run <run-id> --plugin-npm-run <run-id> --plugin-clawhub-run <run-id>`
|
||||
to verify the GitHub prerelease, npm `beta` dist-tags, npm integrity,
|
||||
published install path, ClawHub exact versions, ClawHub artifacts, and child
|
||||
workflow conclusions from one command. Add `--rerun-failed-clawhub` when the
|
||||
ClawHub sidecar failed only in retryable jobs and should be rerun in place.
|
||||
Then run the post-publish package acceptance against the published
|
||||
`openclaw@YYYY.M.D-beta.N` or
|
||||
`openclaw@beta` package. If a pushed or published prerelease needs a fix,
|
||||
cut the next matching prerelease number; do not delete or rewrite the old
|
||||
prerelease.
|
||||
@@ -224,6 +229,11 @@ Validation` or from the `main`/release workflow ref so workflow logic and
|
||||
`OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_CACHE_TEST=1 pnpm test:live:cache`
|
||||
using both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` workflow secrets
|
||||
- npm release preflight no longer waits on the separate release checks lane
|
||||
- Before tagging a release candidate locally, run
|
||||
`RELEASE_TAG=vYYYY.M.D-beta.N pnpm release:fast-pretag-check`. The helper
|
||||
runs the fast release guardrails, plugin npm/ClawHub release checks, build,
|
||||
UI build, and `release:openclaw:npm:check` in the order that catches common
|
||||
approval-blocking mistakes before the GitHub publish workflow starts.
|
||||
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
|
||||
(or the matching beta/correction tag) before approval
|
||||
- After npm publish, run
|
||||
@@ -655,6 +665,9 @@ OpenClaw package must not be published.
|
||||
`plugin_publish_scope=selected`
|
||||
- `publish_openclaw_npm`: defaults to `true`; set `false` only when using the
|
||||
workflow as a plugin-only repair orchestrator
|
||||
- `wait_for_clawhub`: defaults to `false` so npm availability is not blocked by
|
||||
the ClawHub sidecar; set `true` only when workflow completion must include
|
||||
ClawHub completion
|
||||
|
||||
`OpenClaw Release Checks` accepts these operator-controlled inputs:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
462
scripts/lib/release-beta-verifier.ts
Normal file
462
scripts/lib/release-beta-verifier.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
|
||||
import { collectPublishablePluginPackages } from "./plugin-npm-release.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type ReleaseVerifyBetaArgs = {
|
||||
version: string;
|
||||
tag: string;
|
||||
distTag: string;
|
||||
repo: string;
|
||||
registry: string;
|
||||
skipPostpublish: boolean;
|
||||
rerunFailedClawHub: boolean;
|
||||
workflowRuns: {
|
||||
openclawNpm?: string;
|
||||
pluginNpm?: string;
|
||||
pluginClawHub?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type NpmViewFields = {
|
||||
version?: string;
|
||||
distTagVersion?: string;
|
||||
integrity?: string;
|
||||
};
|
||||
|
||||
type WorkflowRunSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
url?: string;
|
||||
durationSeconds?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_REPO = "openclaw/openclaw";
|
||||
const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
const stringValue = readString(value);
|
||||
if (stringValue === undefined) {
|
||||
throw new Error(`${label} is missing.`);
|
||||
}
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], options: { cwd?: string } = {}): string {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runCommandInherited(command: string, args: string[]): void {
|
||||
execFileSync(command, args, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(raw: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${label} returned invalid JSON: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields {
|
||||
const parsed = parseJson(raw, "npm view");
|
||||
if (Array.isArray(parsed)) {
|
||||
return {
|
||||
version: readString(parsed[0]),
|
||||
distTagVersion: readString(parsed[1]),
|
||||
integrity: readString(parsed[2]),
|
||||
};
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("npm view returned an unsupported JSON shape.");
|
||||
}
|
||||
const distTags = isRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined;
|
||||
const dist = isRecord(parsed.dist) ? parsed.dist : undefined;
|
||||
return {
|
||||
version: readString(parsed.version),
|
||||
distTagVersion: readString(parsed[`dist-tags.${distTag}`]) ?? readString(distTags?.[distTag]),
|
||||
integrity: readString(parsed["dist.integrity"]) ?? readString(dist?.integrity),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArgs {
|
||||
const values = [...argv];
|
||||
if (values[0] === "--") {
|
||||
values.shift();
|
||||
}
|
||||
const version = values.shift();
|
||||
if (!version || version.startsWith("-")) {
|
||||
throw new Error(
|
||||
"Usage: pnpm release:verify-beta -- <version> [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID]",
|
||||
);
|
||||
}
|
||||
|
||||
const parsed: ReleaseVerifyBetaArgs = {
|
||||
version,
|
||||
tag: `v${version}`,
|
||||
distTag: "beta",
|
||||
repo: DEFAULT_REPO,
|
||||
registry: DEFAULT_CLAWHUB_REGISTRY,
|
||||
skipPostpublish: false,
|
||||
rerunFailedClawHub: false,
|
||||
workflowRuns: {},
|
||||
};
|
||||
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const arg = values[index];
|
||||
const next = () => {
|
||||
const value = values[index + 1];
|
||||
if (value === undefined || value.startsWith("-")) {
|
||||
throw new Error(`${arg} requires a value.`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--tag":
|
||||
parsed.tag = next();
|
||||
break;
|
||||
case "--dist-tag":
|
||||
parsed.distTag = next();
|
||||
break;
|
||||
case "--repo":
|
||||
parsed.repo = next();
|
||||
break;
|
||||
case "--registry":
|
||||
parsed.registry = next();
|
||||
break;
|
||||
case "--openclaw-npm-run":
|
||||
parsed.workflowRuns.openclawNpm = next();
|
||||
break;
|
||||
case "--plugin-npm-run":
|
||||
parsed.workflowRuns.pluginNpm = next();
|
||||
break;
|
||||
case "--plugin-clawhub-run":
|
||||
parsed.workflowRuns.pluginClawHub = next();
|
||||
break;
|
||||
case "--skip-postpublish":
|
||||
parsed.skipPostpublish = true;
|
||||
break;
|
||||
case "--rerun-failed-clawhub":
|
||||
parsed.rerunFailedClawHub = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
attempts: number,
|
||||
): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
lastError = new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 1000));
|
||||
}
|
||||
}
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
throw new Error(`${url} did not return a stable response: ${message}`);
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry(url: string): Promise<unknown> {
|
||||
const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, 5);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned HTTP ${response.status}.`);
|
||||
}
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
|
||||
const response = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
|
||||
return response.status;
|
||||
}
|
||||
|
||||
function verifyNpmPackage(packageName: string, version: string, distTag: string): void {
|
||||
const raw = runCommand("npm", [
|
||||
"view",
|
||||
`${packageName}@${version}`,
|
||||
"version",
|
||||
`dist-tags.${distTag}`,
|
||||
"dist.integrity",
|
||||
"--json",
|
||||
]);
|
||||
const fields = parseNpmViewFields(raw, distTag);
|
||||
if (fields.version !== version) {
|
||||
throw new Error(
|
||||
`${packageName}: expected npm version ${version}, got ${fields.version ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
if (fields.distTagVersion !== version) {
|
||||
throw new Error(
|
||||
`${packageName}: npm dist-tag ${distTag} points to ${fields.distTagVersion ?? "<missing>"}, expected ${version}.`,
|
||||
);
|
||||
}
|
||||
if (fields.integrity === undefined) {
|
||||
throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function readClawHubTags(detail: unknown): Record<string, string> {
|
||||
if (!isRecord(detail)) {
|
||||
return {};
|
||||
}
|
||||
const packageDetail = isRecord(detail.package) ? detail.package : undefined;
|
||||
const tags = isRecord(packageDetail?.tags) ? packageDetail.tags : undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(tags ?? {})) {
|
||||
if (typeof value === "string") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function verifyClawHubPackage(params: {
|
||||
registry: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
distTag: string;
|
||||
}): Promise<void> {
|
||||
const base = params.registry.replace(/\/+$/u, "");
|
||||
const encodedName = encodeURIComponent(params.packageName);
|
||||
const encodedVersion = encodeURIComponent(params.version);
|
||||
const detailUrl = `${base}/api/v1/packages/${encodedName}`;
|
||||
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
|
||||
const artifactUrl = `${versionUrl}/artifact/download`;
|
||||
|
||||
const detail = await fetchJsonWithRetry(detailUrl);
|
||||
const tags = readClawHubTags(detail);
|
||||
if (tags[params.distTag] !== params.version) {
|
||||
throw new Error(
|
||||
`${params.packageName}: ClawHub tag ${params.distTag} points to ${tags[params.distTag] ?? "<missing>"}, expected ${params.version}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionStatus = await fetchStatusWithRetry(versionUrl, "GET");
|
||||
if (versionStatus < 200 || versionStatus >= 300) {
|
||||
throw new Error(`${params.packageName}: ClawHub exact version returned HTTP ${versionStatus}.`);
|
||||
}
|
||||
|
||||
const artifactStatus = await fetchStatusWithRetry(artifactUrl, "HEAD");
|
||||
if (artifactStatus < 200 || artifactStatus >= 400) {
|
||||
throw new Error(`${params.packageName}: ClawHub artifact returned HTTP ${artifactStatus}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyGitHubRelease(params: ReleaseVerifyBetaArgs): string {
|
||||
const raw = runCommand("gh", [
|
||||
"release",
|
||||
"view",
|
||||
params.tag,
|
||||
"--repo",
|
||||
params.repo,
|
||||
"--json",
|
||||
"tagName,isPrerelease,url",
|
||||
]);
|
||||
const release = parseJson(raw, "gh release view");
|
||||
if (!isRecord(release)) {
|
||||
throw new Error("GitHub release returned an unsupported JSON shape.");
|
||||
}
|
||||
if (release.tagName !== params.tag) {
|
||||
throw new Error(
|
||||
`GitHub release tag mismatch: expected ${params.tag}, got ${String(release.tagName)}.`,
|
||||
);
|
||||
}
|
||||
if (params.version.includes("-beta.") && release.isPrerelease !== true) {
|
||||
throw new Error(`${params.tag} is not marked as a GitHub prerelease.`);
|
||||
}
|
||||
return requireString(release.url, "GitHub release URL");
|
||||
}
|
||||
|
||||
function verifyWorkflowRun(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
repo: string;
|
||||
rerunFailed: boolean;
|
||||
}): WorkflowRunSummary {
|
||||
const raw = runCommand("gh", [
|
||||
"run",
|
||||
"view",
|
||||
params.id,
|
||||
"--repo",
|
||||
params.repo,
|
||||
"--json",
|
||||
"status,conclusion,url,createdAt,updatedAt,jobs",
|
||||
]);
|
||||
const run = parseJson(raw, `gh run view ${params.id}`);
|
||||
if (!isRecord(run)) {
|
||||
throw new Error(`${params.label}: workflow run returned an unsupported JSON shape.`);
|
||||
}
|
||||
const status = readString(run.status);
|
||||
const conclusion = readString(run.conclusion);
|
||||
const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isRecord) : [];
|
||||
const failedJobs = jobs.filter((job) => {
|
||||
const jobConclusion = readString(job.conclusion);
|
||||
return (
|
||||
jobConclusion !== undefined && jobConclusion !== "success" && jobConclusion !== "skipped"
|
||||
);
|
||||
});
|
||||
if (failedJobs.length > 0 && params.rerunFailed) {
|
||||
runCommandInherited("gh", ["run", "rerun", params.id, "--repo", params.repo, "--failed"]);
|
||||
throw new Error(
|
||||
`${params.label}: reran ${failedJobs.length} failed job(s); rerun verifier after it finishes.`,
|
||||
);
|
||||
}
|
||||
if (status !== "completed" || conclusion !== "success" || failedJobs.length > 0) {
|
||||
const failedNames = failedJobs.map((job) => readString(job.name) ?? "<unnamed>").join(", ");
|
||||
throw new Error(
|
||||
`${params.label}: run ${params.id} is ${status ?? "<missing>"}/${conclusion ?? "<missing>"}${failedNames ? `; failed jobs: ${failedNames}` : ""}.`,
|
||||
);
|
||||
}
|
||||
const createdAt = readString(run.createdAt);
|
||||
const updatedAt = readString(run.updatedAt);
|
||||
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
|
||||
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
|
||||
const durationSeconds =
|
||||
Number.isFinite(createdMs) && Number.isFinite(updatedMs)
|
||||
? Math.max(0, Math.round((updatedMs - createdMs) / 1000))
|
||||
: undefined;
|
||||
return {
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
url: readString(run.url),
|
||||
durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function readRootPackageVersion(rootDir: string): string {
|
||||
const packageJson = parseJson(
|
||||
readFileSync(resolve(rootDir, "package.json"), "utf8"),
|
||||
"package.json",
|
||||
);
|
||||
if (!isRecord(packageJson)) {
|
||||
throw new Error("package.json returned an unsupported JSON shape.");
|
||||
}
|
||||
return requireString(packageJson.version, "package.json version");
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | undefined): string {
|
||||
if (seconds === undefined) {
|
||||
return "unknown";
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export async function verifyBetaRelease(
|
||||
args: ReleaseVerifyBetaArgs,
|
||||
options: { rootDir?: string } = {},
|
||||
): Promise<string[]> {
|
||||
const rootDir = options.rootDir ?? resolve(".");
|
||||
const rootVersion = readRootPackageVersion(rootDir);
|
||||
if (rootVersion !== args.version) {
|
||||
throw new Error(`package.json version is ${rootVersion}; expected ${args.version}.`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const releaseUrl = verifyGitHubRelease(args);
|
||||
lines.push(`GitHub release OK: ${releaseUrl}`);
|
||||
|
||||
verifyNpmPackage("openclaw", args.version, args.distTag);
|
||||
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
|
||||
|
||||
if (!args.skipPostpublish) {
|
||||
runCommandInherited("node", [
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/openclaw-npm-postpublish-verify.ts",
|
||||
args.version,
|
||||
]);
|
||||
lines.push("openclaw postpublish verifier OK");
|
||||
}
|
||||
|
||||
const npmPlugins = collectPublishablePluginPackages(rootDir);
|
||||
for (const plugin of npmPlugins) {
|
||||
verifyNpmPackage(plugin.packageName, args.version, args.distTag);
|
||||
}
|
||||
lines.push(`plugin npm OK: ${npmPlugins.length}`);
|
||||
|
||||
const clawHubPlugins = collectClawHubPublishablePluginPackages(rootDir);
|
||||
for (const plugin of clawHubPlugins) {
|
||||
await verifyClawHubPackage({
|
||||
registry: args.registry,
|
||||
packageName: plugin.packageName,
|
||||
version: args.version,
|
||||
distTag: args.distTag,
|
||||
});
|
||||
}
|
||||
lines.push(`ClawHub OK: ${clawHubPlugins.length}`);
|
||||
|
||||
const workflowRuns: WorkflowRunSummary[] = [];
|
||||
if (args.workflowRuns.pluginNpm !== undefined) {
|
||||
workflowRuns.push(
|
||||
verifyWorkflowRun({
|
||||
id: args.workflowRuns.pluginNpm,
|
||||
label: "Plugin NPM Release",
|
||||
repo: args.repo,
|
||||
rerunFailed: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (args.workflowRuns.pluginClawHub !== undefined) {
|
||||
workflowRuns.push(
|
||||
verifyWorkflowRun({
|
||||
id: args.workflowRuns.pluginClawHub,
|
||||
label: "Plugin ClawHub Release",
|
||||
repo: args.repo,
|
||||
rerunFailed: args.rerunFailedClawHub,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (args.workflowRuns.openclawNpm !== undefined) {
|
||||
workflowRuns.push(
|
||||
verifyWorkflowRun({
|
||||
id: args.workflowRuns.openclawNpm,
|
||||
label: "OpenClaw NPM Release",
|
||||
repo: args.repo,
|
||||
rerunFailed: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const run of workflowRuns) {
|
||||
lines.push(
|
||||
`${run.label} OK: ${run.id} (${formatDuration(run.durationSeconds)})${run.url ? ` ${run.url}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
18
scripts/release-fast-pretag-check.sh
Executable file
18
scripts/release-fast-pretag-check.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version="$(node -p 'require("./package.json").version')"
|
||||
release_tag="${RELEASE_TAG:-v${version}}"
|
||||
export RELEASE_TAG="${release_tag}"
|
||||
|
||||
echo "release tag: ${RELEASE_TAG}"
|
||||
git diff --check
|
||||
pnpm check:temp-path-guardrails
|
||||
pnpm release-metadata:check
|
||||
pnpm plugins:sync:check
|
||||
pnpm release:generated:check
|
||||
pnpm release:plugins:npm:check -- --selection-mode all-publishable
|
||||
pnpm release:plugins:clawhub:check -- --selection-mode all-publishable
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm release:openclaw:npm:check
|
||||
17
scripts/release-verify-beta.ts
Executable file
17
scripts/release-verify-beta.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
|
||||
import { parseReleaseVerifyBetaArgs, verifyBetaRelease } from "./lib/release-beta-verifier.ts";
|
||||
|
||||
async function main() {
|
||||
const args = parseReleaseVerifyBetaArgs(process.argv.slice(2));
|
||||
const lines = await verifyBetaRelease(args);
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -876,6 +876,28 @@ describe("package artifact reuse", () => {
|
||||
expect(workflow).not.toContain("timeout-minutes: 360");
|
||||
});
|
||||
|
||||
it("keeps beta release verification and ClawHub publish repair hooks wired", () => {
|
||||
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
|
||||
const clawHubWorkflow = readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8");
|
||||
|
||||
expect(packageJson.scripts?.["release:verify-beta"]).toBe(
|
||||
"node --import tsx scripts/release-verify-beta.ts",
|
||||
);
|
||||
expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe(
|
||||
"bash scripts/release-fast-pretag-check.sh",
|
||||
);
|
||||
expect(clawHubWorkflow).toContain("Verify published ClawHub package");
|
||||
expect(clawHubWorkflow).toContain("bun install failed while preparing ClawHub CLI; retrying");
|
||||
expect(clawHubWorkflow).toContain("max-parallel: 32");
|
||||
expect(releaseWorkflow).toContain("Plugin npm run ID");
|
||||
expect(releaseWorkflow).toContain("Plugin ClawHub run ID");
|
||||
expect(releaseWorkflow).toContain("OpenClaw npm run ID");
|
||||
expect(releaseWorkflow).toContain("finished with ${conclusion} in ${duration_label}");
|
||||
});
|
||||
|
||||
it("keeps release workflow setup and timeout budgets bounded", () => {
|
||||
const fullRelease = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW);
|
||||
const releaseChecks = readWorkflow(RELEASE_CHECKS_WORKFLOW);
|
||||
|
||||
81
test/scripts/release-beta-verifier.test.ts
Normal file
81
test/scripts/release-beta-verifier.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseNpmViewFields,
|
||||
parseReleaseVerifyBetaArgs,
|
||||
} from "../../scripts/lib/release-beta-verifier.ts";
|
||||
|
||||
describe("parseReleaseVerifyBetaArgs", () => {
|
||||
it("defaults beta verification to the matching tag and repo", () => {
|
||||
expect(parseReleaseVerifyBetaArgs(["2026.5.10-beta.3"])).toMatchObject({
|
||||
version: "2026.5.10-beta.3",
|
||||
tag: "v2026.5.10-beta.3",
|
||||
distTag: "beta",
|
||||
repo: "openclaw/openclaw",
|
||||
registry: "https://clawhub.ai",
|
||||
skipPostpublish: false,
|
||||
rerunFailedClawHub: false,
|
||||
workflowRuns: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses child run IDs and repair flags", () => {
|
||||
expect(
|
||||
parseReleaseVerifyBetaArgs([
|
||||
"--",
|
||||
"2026.5.10-beta.3",
|
||||
"--openclaw-npm-run",
|
||||
"11",
|
||||
"--plugin-npm-run",
|
||||
"22",
|
||||
"--plugin-clawhub-run",
|
||||
"33",
|
||||
"--skip-postpublish",
|
||||
"--rerun-failed-clawhub",
|
||||
]),
|
||||
).toMatchObject({
|
||||
skipPostpublish: true,
|
||||
rerunFailedClawHub: true,
|
||||
workflowRuns: {
|
||||
openclawNpm: "11",
|
||||
pluginNpm: "22",
|
||||
pluginClawHub: "33",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNpmViewFields", () => {
|
||||
it("accepts keyed npm view JSON", () => {
|
||||
expect(
|
||||
parseNpmViewFields(
|
||||
JSON.stringify({
|
||||
version: "2026.5.10-beta.3",
|
||||
"dist-tags.beta": "2026.5.10-beta.3",
|
||||
"dist.integrity": "sha512-test",
|
||||
}),
|
||||
"beta",
|
||||
),
|
||||
).toEqual({
|
||||
version: "2026.5.10-beta.3",
|
||||
distTagVersion: "2026.5.10-beta.3",
|
||||
integrity: "sha512-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts nested npm view JSON", () => {
|
||||
expect(
|
||||
parseNpmViewFields(
|
||||
JSON.stringify({
|
||||
version: "2026.5.10-beta.3",
|
||||
"dist-tags": { beta: "2026.5.10-beta.3" },
|
||||
dist: { integrity: "sha512-test" },
|
||||
}),
|
||||
"beta",
|
||||
),
|
||||
).toEqual({
|
||||
version: "2026.5.10-beta.3",
|
||||
distTagVersion: "2026.5.10-beta.3",
|
||||
integrity: "sha512-test",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user