Files
openclaw/scripts/lib/release-beta-verifier.ts
Peter Steinberger 2e2366b6d3 chore(tooling): typecheck scripts/** with a dedicated tsgo lane (#104348)
* feat(tooling): add tsgo typecheck lane for scripts/**

* fix(scripts): burn down scripts type debt surfaced by the new lane

Typing-only except bugs the lane surfaced: gh-read timeout race,
Discord Headers spread dropping entries, undefined allowedHeadBranches
match, plugin-boundary matchAll crash. Deletes retired config keys from
fixtures/benches (prompt snapshots regenerated, config dump only) and
the orphaned non-runnable sync-moonshot-docs script. Adds full-surface
.d.mts declarations for existing .mjs boundaries.
2026-07-11 02:31:17 -07:00

1437 lines
48 KiB
TypeScript

import { execFileSync } from "node:child_process";
// Release Beta Verifier script supports OpenClaw repository automation.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs";
import { readBoundedResponseText } from "./bounded-response.ts";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
import {
collectPublishablePluginPackages,
parsePluginReleaseSelection,
} from "./plugin-npm-release.ts";
type JsonRecord = Record<string, unknown>;
type ReleaseVerifyBetaArgs = {
version: string;
tag: string;
distTag: string;
repo: string;
registry: string;
releaseSha?: string;
workflowRef?: string;
clawHubWorkflowRef?: string;
pluginSelection: string[];
clawHubBootstrapPlugins: string[];
evidenceOut?: string;
skipPostpublish: boolean;
skipGitHubRelease: boolean;
skipClawHub: boolean;
rerunFailedClawHub: boolean;
workflowRuns: {
fullReleaseValidation?: string;
openclawNpm?: string;
pluginNpm?: string;
pluginClawHub?: string;
pluginClawHubBootstrap?: string;
npmTelegram?: string;
};
};
type NpmViewFields = {
version?: string;
distTagVersion?: string;
integrity?: string;
tarball?: string;
};
type FetchWithRetryResult = {
response: Response;
signal: AbortSignal;
};
type WorkflowRunSummary = {
id: string;
label: string;
url?: string;
durationSeconds?: number;
bootstrapEvidence?: {
targetSha: string;
workflowSha: string;
workflowPath: string;
producerRunAttempt: string;
terminalRunAttempt: string;
readbackArtifactId: string;
readbackArtifactDigest: string;
packageArtifactId: string;
packageArtifactDigest: string;
packageCount: number;
clawhubToolchainIntegrity: string;
clawhubToolchainSha256: string;
clawhubToolchainVersion: string;
};
};
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai";
const CLAWHUB_BOOTSTRAP_WORKFLOW_PATH = ".github/workflows/plugin-clawhub-new.yml";
const CLAWHUB_BOOTSTRAP_READBACK_FILE = "clawhub-bootstrap-readback.json";
const CLAWHUB_REQUEST_TIMEOUT_MS = 20_000;
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
const CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES = 2 * 1024 * 1024;
const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/u;
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
const SHA512_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u;
const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
// Trusted publish can finish before npm registry metadata converges. Keep the
// verifier on the same release train instead of forcing a republish/correction.
const NPM_VIEW_ATTEMPTS = 30;
const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000;
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
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 readTrustedClawHubToolchainIdentity(): {
clawhubToolchainIntegrity: string;
clawhubToolchainSha256: string;
clawhubToolchainVersion: string;
} {
const lockPath = resolve(TRUSTED_TOOLING_ROOT, ".github/release/clawhub-cli/package-lock.json");
const lockBytes = readFileSync(lockPath);
const lock = parseJson(lockBytes.toString("utf8"), "trusted ClawHub CLI package-lock.json");
if (!isRecord(lock) || !isRecord(lock.packages)) {
throw new Error("Trusted ClawHub CLI package-lock.json is invalid.");
}
const clawhub = lock.packages["node_modules/clawhub"];
if (!isRecord(clawhub)) {
throw new Error("Trusted ClawHub CLI package-lock.json is missing clawhub.");
}
const clawhubToolchainIntegrity = requireString(
clawhub.integrity,
"trusted ClawHub CLI integrity",
);
if (!SHA512_INTEGRITY_PATTERN.test(clawhubToolchainIntegrity)) {
throw new Error("Trusted ClawHub CLI integrity is invalid.");
}
return {
clawhubToolchainIntegrity,
clawhubToolchainSha256: createHash("sha256").update(lockBytes).digest("hex"),
clawhubToolchainVersion: requireString(clawhub.version, "trusted ClawHub CLI version"),
};
}
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",
});
}
export async function runNpmViewWithRetry(
args: string[],
options: {
attempts?: number;
delay?: (delayMs: number) => Promise<void>;
run?: (args: string[]) => string;
} = {},
): Promise<string> {
const attempts = options.attempts ?? NPM_VIEW_ATTEMPTS;
const delay =
options.delay ??
((delayMs: number) =>
new Promise((resolveDelay) => {
setTimeout(resolveDelay, delayMs);
}));
const run = options.run ?? ((npmArgs: string[]) => runCommand("npm", npmArgs));
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return run([...args, "--prefer-online"]);
} catch (error) {
lastError = error;
}
if (attempt < attempts) {
await delay(Math.min(attempt * 1000, NPM_VIEW_RETRY_MAX_DELAY_MS));
}
}
throw lastError;
}
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}`, { cause: error });
}
}
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]),
tarball: readString(parsed[3]),
};
}
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),
tarball: readString(parsed["dist.tarball"]) ?? readString(dist?.tarball),
};
}
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> [--release-sha SHA] [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID --clawhub-bootstrap-plugins NAMES] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]",
);
}
const parsed: ReleaseVerifyBetaArgs = {
version,
tag: `v${version}`,
distTag: "beta",
repo: DEFAULT_REPO,
registry: DEFAULT_CLAWHUB_REGISTRY,
releaseSha: undefined,
workflowRef: undefined,
clawHubWorkflowRef: undefined,
pluginSelection: [],
clawHubBootstrapPlugins: [],
evidenceOut: undefined,
skipPostpublish: false,
skipGitHubRelease: false,
skipClawHub: 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 "--release-sha":
parsed.releaseSha = next();
if (!COMMIT_SHA_PATTERN.test(parsed.releaseSha)) {
throw new Error("--release-sha must be a full 40-character lowercase commit SHA.");
}
break;
case "--workflow-ref":
parsed.workflowRef = next();
break;
case "--clawhub-workflow-ref":
parsed.clawHubWorkflowRef = next();
break;
case "--plugins":
parsed.pluginSelection = parsePluginReleaseSelection(next());
if (parsed.pluginSelection.length === 0) {
throw new Error("--plugins requires at least one plugin package name.");
}
break;
case "--clawhub-bootstrap-plugins":
parsed.clawHubBootstrapPlugins = parsePluginReleaseSelection(next());
if (parsed.clawHubBootstrapPlugins.length === 0) {
throw new Error("--clawhub-bootstrap-plugins requires at least one package name.");
}
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;
case "--plugin-npm-run":
parsed.workflowRuns.pluginNpm = next();
break;
case "--plugin-clawhub-run":
parsed.workflowRuns.pluginClawHub = next();
break;
case "--plugin-clawhub-bootstrap-run":
parsed.workflowRuns.pluginClawHubBootstrap = next();
break;
case "--npm-telegram-run":
parsed.workflowRuns.npmTelegram = next();
break;
case "--skip-postpublish":
parsed.skipPostpublish = true;
break;
case "--skip-github-release":
parsed.skipGitHubRelease = true;
break;
case "--skip-clawhub":
parsed.skipClawHub = true;
break;
case "--rerun-failed-clawhub":
parsed.rerunFailedClawHub = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (parsed.workflowRuns.pluginClawHubBootstrap !== undefined) {
if (parsed.releaseSha === undefined) {
throw new Error("--plugin-clawhub-bootstrap-run requires --release-sha.");
}
if (parsed.clawHubBootstrapPlugins.length === 0) {
throw new Error("--plugin-clawhub-bootstrap-run requires --clawhub-bootstrap-plugins.");
}
} else if (parsed.clawHubBootstrapPlugins.length > 0) {
throw new Error("--clawhub-bootstrap-plugins requires --plugin-clawhub-bootstrap-run.");
}
return parsed;
}
async function fetchWithRetry(
url: string,
options: RequestInit,
attempts: number,
): Promise<FetchWithRetryResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const signal = AbortSignal.timeout(CLAWHUB_REQUEST_TIMEOUT_MS);
const response = await fetch(url, {
...options,
signal,
});
if (response.status !== 429 && response.status < 500) {
return { response, signal };
}
await cancelResponseBody(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 cancelResponseBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => undefined);
}
export async function fetchJsonWithRetry(
url: string,
options: {
attempts?: number;
delay?: (delayMs: number) => Promise<void>;
fetchImpl?: typeof fetch;
timeoutMs?: number;
} = {},
): Promise<unknown> {
const attempts = options.attempts ?? 5;
const delay =
options.delay ??
((delayMs: number) =>
new Promise((resolveDelay) => {
setTimeout(resolveDelay, delayMs);
}));
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS;
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let response: Response | undefined;
let attemptError: unknown;
try {
const signal = AbortSignal.timeout(timeoutMs);
response = await fetchImpl(url, {
headers: { accept: "application/json" },
signal,
});
if (response.status !== 429 && response.status < 500) {
if (!response.ok) {
await cancelResponseBody(response);
throw new Error(`${url} returned HTTP ${response.status}.`);
}
return await readBoundedJsonResponse(response, url, undefined, { signal });
}
attemptError = new Error(`HTTP ${response.status}`);
lastError = attemptError;
} catch (error) {
if (
response !== undefined &&
response.status !== 429 &&
response.status < 500 &&
!response.ok
) {
throw error;
}
attemptError = error;
lastError = error;
} finally {
if (response !== undefined && attemptError !== undefined) {
await cancelResponseBody(response);
}
}
if (attempt < attempts) {
await delay(attempt * 1000);
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`${url} did not return stable JSON: ${message}`);
}
export async function readBoundedJsonResponse(
response: Response,
label: string,
maxBytes = CLAWHUB_RESPONSE_BODY_MAX_BYTES,
options: { signal?: AbortSignal } = {},
): Promise<unknown> {
return parseJson(await readBoundedResponseText(response, label, maxBytes, options), label);
}
export async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
try {
return response.status;
} finally {
await cancelResponseBody(response);
}
}
async function verifyNpmPackage(
packageName: string,
version: string,
distTag: string,
): Promise<NpmViewFields> {
const raw = await runNpmViewWithRetry([
"view",
`${packageName}@${version}`,
"version",
`dist-tags.${distTag}`,
"dist.integrity",
"dist.tarball",
"--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}.`);
}
if (fields.tarball === undefined) {
throw new Error(`${packageName}: npm dist.tarball missing for ${version}.`);
}
return fields;
}
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;
expectedWorkflowName: string;
expectedHeadBranch?: string;
allowedHeadBranches?: string[];
rerunFailed: boolean;
}): WorkflowRunSummary {
const raw = runCommand("gh", [
"run",
"view",
params.id,
"--repo",
params.repo,
"--json",
"workflowName,headBranch,event,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 workflowName = readString(run.workflowName);
if (workflowName !== params.expectedWorkflowName) {
throw new Error(
`${params.label}: run ${params.id} workflow is ${workflowName ?? "<missing>"}, expected ${params.expectedWorkflowName}.`,
);
}
const event = readString(run.event);
if (event !== "workflow_dispatch") {
throw new Error(
`${params.label}: run ${params.id} event is ${event ?? "<missing>"}, expected workflow_dispatch.`,
);
}
const headBranch = readString(run.headBranch);
const allowedHeadBranches =
params.allowedHeadBranches ??
(params.expectedHeadBranch !== undefined ? [params.expectedHeadBranch] : []);
if (allowedHeadBranches.length > 0 && !allowedHeadBranches.includes(headBranch ?? "")) {
throw new Error(
`${params.label}: run ${params.id} branch is ${headBranch ?? "<missing>"}, expected ${allowedHeadBranches.join(" or ")}.`,
);
}
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 requirePositiveIntegerString(value: unknown, label: string): string {
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
return String(value);
}
const stringValue = readString(value);
if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) {
throw new Error(`${label} must be a positive integer.`);
}
return stringValue;
}
function requirePositiveSafeInteger(value: unknown, label: string): number {
const stringValue = requirePositiveIntegerString(value, label);
const numberValue = Number(stringValue);
if (!Number.isSafeInteger(numberValue)) {
throw new Error(`${label} must be a positive safe integer.`);
}
return numberValue;
}
function requireCommitSha(value: unknown, label: string): string {
const sha = requireString(value, label);
if (!COMMIT_SHA_PATTERN.test(sha)) {
throw new Error(`${label} must be a full 40-character lowercase commit SHA.`);
}
return sha;
}
function requireSha256(value: unknown, label: string): string {
const sha = requireString(value, label);
if (!SHA256_PATTERN.test(sha)) {
throw new Error(`${label} must be a lowercase SHA-256 digest.`);
}
return sha;
}
function requireArtifactDigest(value: unknown, label: string): string {
const digest = requireString(value, label);
const match = /^sha256:([a-f0-9]{64})$/u.exec(digest);
if (!match?.[1]) {
throw new Error(`${label} must be a sha256 artifact digest.`);
}
return match[1];
}
function requireStringArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
throw new Error(`${label} must be a string array.`);
}
return value
.map((entry) => entry.trim())
.filter(Boolean)
.toSorted(compareCodeUnits);
}
function requireArtifactWorkflowRun(
artifact: JsonRecord,
params: { label: string; runId: string; headSha: string },
): void {
if (artifact.expired !== false) {
throw new Error(`${params.label} is expired or missing its immutable state.`);
}
const workflowRun = artifact.workflow_run;
if (!isRecord(workflowRun)) {
throw new Error(`${params.label} is missing workflow_run metadata.`);
}
if (
requirePositiveIntegerString(workflowRun.id, `${params.label} workflow run id`) !== params.runId
) {
throw new Error(`${params.label} belongs to a different workflow run.`);
}
if (
requireCommitSha(workflowRun.head_sha, `${params.label} workflow head SHA`) !== params.headSha
) {
throw new Error(`${params.label} belongs to a different workflow head SHA.`);
}
if (requireString(workflowRun.head_branch, `${params.label} workflow head branch`) !== "main") {
throw new Error(`${params.label} was not produced by trusted main.`);
}
}
function requireClawHubBootstrapRunBinding(
run: unknown,
expectedRunId: string,
): {
headSha: string;
run: JsonRecord;
runAttempt: number;
runId: number;
terminalRunAttempt: string;
workflowPath: string;
} {
if (!isRecord(run)) {
throw new Error("Plugin ClawHub New run metadata is invalid.");
}
const runId = requirePositiveSafeInteger(run.id, "Plugin ClawHub New run id");
if (String(runId) !== expectedRunId) {
throw new Error(`Plugin ClawHub New run id is ${runId}, expected ${expectedRunId}.`);
}
if (run.name !== "Plugin ClawHub New") {
throw new Error("Plugin ClawHub New run has an unexpected workflow name.");
}
if (run.event !== "workflow_dispatch") {
throw new Error("Plugin ClawHub New run was not workflow_dispatch.");
}
if (run.head_branch !== "main") {
throw new Error("Plugin ClawHub New run was not dispatched from trusted main.");
}
const headSha = requireCommitSha(run.head_sha, "Plugin ClawHub New head SHA");
const runAttempt = requirePositiveSafeInteger(run.run_attempt, "Plugin ClawHub New run attempt");
const workflowPath = requireString(run.path, "Plugin ClawHub New workflow path").replace(
/@.*$/u,
"",
);
if (workflowPath !== CLAWHUB_BOOTSTRAP_WORKFLOW_PATH) {
throw new Error("Plugin ClawHub New run has an unexpected workflow path.");
}
if (run.status !== "completed" || run.conclusion !== "success") {
throw new Error("Plugin ClawHub New run is not completed/success.");
}
return {
headSha,
run,
runAttempt,
runId,
terminalRunAttempt: String(runAttempt),
workflowPath,
};
}
function requireClawHubReadbackArtifactBinding(
artifact: unknown,
run: ReturnType<typeof requireClawHubBootstrapRunBinding>,
): {
artifactDigest: string;
artifactId: number;
artifactName: string;
artifactSizeBytes: number;
} {
if (!isRecord(artifact)) {
throw new Error("Plugin ClawHub New readback artifact metadata is invalid.");
}
const artifactName = `clawhub-bootstrap-readback-${run.runId}-${run.terminalRunAttempt}`;
if (artifact.name !== artifactName) {
throw new Error("Plugin ClawHub New readback artifact name does not bind the run attempt.");
}
requireArtifactWorkflowRun(artifact, {
label: "Plugin ClawHub New readback artifact",
runId: String(run.runId),
headSha: run.headSha,
});
return {
artifactDigest: `sha256:${requireArtifactDigest(
artifact.digest,
"Plugin ClawHub New readback artifact digest",
)}`,
artifactId: requirePositiveSafeInteger(artifact.id, "Plugin ClawHub New readback artifact id"),
artifactName,
artifactSizeBytes: requirePositiveSafeInteger(
artifact.size_in_bytes,
"Plugin ClawHub New readback artifact size",
),
};
}
function validateBootstrapPackageEvidence(
value: unknown,
params: { packageName: string; version: string },
): void {
if (!isRecord(value)) {
throw new Error(`${params.packageName} bootstrap evidence is invalid.`);
}
if (
requireString(value.packageName, `${params.packageName} package name`) !== params.packageName
) {
throw new Error(`${params.packageName} bootstrap evidence package mismatch.`);
}
if (requireString(value.version, `${params.packageName} version`) !== params.version) {
throw new Error(`${params.packageName} bootstrap evidence version mismatch.`);
}
const expectedSha256 = requireSha256(
value.expectedSha256,
`${params.packageName} expected sha256`,
);
if (
requireSha256(value.registrySha256, `${params.packageName} registry sha256`) !== expectedSha256
) {
throw new Error(
`${params.packageName} registry artifact digest differs from the packed artifact.`,
);
}
const expectedSize = value.expectedSize;
const registrySize = value.registrySize;
if (
typeof expectedSize !== "number" ||
!Number.isSafeInteger(expectedSize) ||
expectedSize <= 0 ||
registrySize !== expectedSize
) {
throw new Error(
`${params.packageName} registry artifact size differs from the packed artifact.`,
);
}
const npmIntegrity = requireString(value.npmIntegrity, `${params.packageName} npm integrity`);
const npmShasum = requireString(value.npmShasum, `${params.packageName} npm shasum`);
const metadata = value.artifactMetadata;
if (!isRecord(metadata)) {
throw new Error(`${params.packageName} artifact metadata evidence is invalid.`);
}
if (metadata.kind !== "npm-pack") {
throw new Error(`${params.packageName} artifact metadata is not npm-pack.`);
}
if (
requireString(metadata.packageName, `${params.packageName} metadata package name`) !==
params.packageName ||
requireString(metadata.version, `${params.packageName} metadata version`) !== params.version
) {
throw new Error(`${params.packageName} artifact metadata identity mismatch.`);
}
if (
requireSha256(metadata.sha256, `${params.packageName} metadata sha256`) !== expectedSha256 ||
metadata.size !== expectedSize ||
metadata.npmIntegrity !== npmIntegrity ||
metadata.npmShasum !== npmShasum
) {
throw new Error(`${params.packageName} artifact metadata does not match downloaded bytes.`);
}
}
export function validateClawHubBootstrapEvidence(params: {
repo: string;
runId: string;
releaseSha: string;
expectedVersion: string;
expectedPackages: string[];
run: unknown;
readbackArtifact: unknown;
readbackArchiveSha256: string;
packageArtifact: unknown;
evidence: unknown;
}): WorkflowRunSummary {
const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId);
const { headSha, terminalRunAttempt, workflowPath } = runBinding;
const runId = String(runBinding.runId);
const readbackBinding = requireClawHubReadbackArtifactBinding(
params.readbackArtifact,
runBinding,
);
const readbackArtifactId = String(readbackBinding.artifactId);
const readbackArtifactDigest = requireArtifactDigest(
readbackBinding.artifactDigest,
"Plugin ClawHub New readback artifact digest",
);
if (
requireSha256(params.readbackArchiveSha256, "Downloaded readback artifact sha256") !==
readbackArtifactDigest
) {
throw new Error("Downloaded Plugin ClawHub New readback artifact digest mismatch.");
}
if (!isRecord(params.evidence)) {
throw new Error("Plugin ClawHub New readback evidence is invalid.");
}
if (params.evidence.schemaVersion !== 2 || params.evidence.verificationMode !== "postpublish") {
throw new Error("Plugin ClawHub New readback evidence schema or mode is invalid.");
}
if (params.evidence.repository !== params.repo) {
throw new Error("Plugin ClawHub New readback evidence repository mismatch.");
}
if (
requireCommitSha(params.evidence.targetSha, "Plugin ClawHub New target SHA") !==
params.releaseSha
) {
throw new Error("Plugin ClawHub New readback evidence target SHA mismatch.");
}
if (
requireCommitSha(params.evidence.workflowSha, "Plugin ClawHub New workflow SHA") !== headSha
) {
throw new Error("Plugin ClawHub New readback evidence workflow SHA mismatch.");
}
const evidenceRunId = requirePositiveIntegerString(
params.evidence.runId,
"Plugin ClawHub New evidence run id",
);
const producerRunAttempt = requirePositiveIntegerString(
params.evidence.producerRunAttempt,
"Plugin ClawHub New evidence producer run attempt",
);
const evidenceTerminalRunAttempt = requirePositiveIntegerString(
params.evidence.terminalRunAttempt,
"Plugin ClawHub New evidence terminal run attempt",
);
if (evidenceRunId !== runId || evidenceTerminalRunAttempt !== terminalRunAttempt) {
throw new Error("Plugin ClawHub New readback evidence run tuple mismatch.");
}
if (BigInt(producerRunAttempt) > BigInt(terminalRunAttempt)) {
throw new Error("Plugin ClawHub New producer attempt is newer than its terminal attempt.");
}
const expectedToolchain = readTrustedClawHubToolchainIdentity();
for (const [key, expected] of Object.entries(expectedToolchain)) {
if (params.evidence[key] !== expected) {
throw new Error(`Plugin ClawHub New ${key} mismatch.`);
}
}
const expectedPackages = [...new Set(params.expectedPackages)].toSorted(compareCodeUnits);
if (expectedPackages.length === 0) {
throw new Error("Plugin ClawHub New expected package set is empty.");
}
if (
JSON.stringify(
requireStringArray(params.evidence.requestedPlugins, "requested bootstrap plugins"),
) !== JSON.stringify(expectedPackages)
) {
throw new Error("Plugin ClawHub New requested package set mismatch.");
}
if (!Array.isArray(params.evidence.packages)) {
throw new Error("Plugin ClawHub New package evidence is invalid.");
}
const evidencePackages = params.evidence.packages
.map((entry) =>
isRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "",
)
.toSorted(compareCodeUnits);
if (JSON.stringify(evidencePackages) !== JSON.stringify(expectedPackages)) {
throw new Error("Plugin ClawHub New terminal package set mismatch.");
}
for (const packageName of expectedPackages) {
validateBootstrapPackageEvidence(
params.evidence.packages.find(
(entry) => isRecord(entry) && entry.packageName === packageName,
),
{ packageName, version: params.expectedVersion },
);
}
if (!isRecord(params.packageArtifact)) {
throw new Error("Plugin ClawHub New package artifact metadata is invalid.");
}
const packageArtifactId = requirePositiveIntegerString(
params.packageArtifact.id,
"Plugin ClawHub New package artifact id",
);
if (
packageArtifactId !==
requirePositiveIntegerString(
params.evidence.artifactId,
"Plugin ClawHub New evidence package artifact id",
)
) {
throw new Error("Plugin ClawHub New package artifact id mismatch.");
}
if (
params.packageArtifact.name !==
requireString(params.evidence.artifactName, "Plugin ClawHub New package artifact name")
) {
throw new Error("Plugin ClawHub New package artifact name mismatch.");
}
requireArtifactWorkflowRun(params.packageArtifact, {
label: "Plugin ClawHub New package artifact",
runId,
headSha,
});
const packageArtifactDigest = requireArtifactDigest(
params.packageArtifact.digest,
"Plugin ClawHub New package artifact digest",
);
if (
packageArtifactDigest !==
requireSha256(
params.evidence.artifactDigest,
"Plugin ClawHub New evidence package artifact digest",
)
) {
throw new Error("Plugin ClawHub New package artifact digest mismatch.");
}
const expectedPackageArtifactName = `clawhub-bootstrap-${params.releaseSha.slice(0, 12)}-${runId}-${producerRunAttempt}`;
if (params.packageArtifact.name !== expectedPackageArtifactName) {
throw new Error(
"Plugin ClawHub New package artifact name does not bind the target and attempt.",
);
}
const createdAt = readString(runBinding.run.created_at);
const updatedAt = readString(runBinding.run.updated_at);
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
return {
id: runId,
label: "Plugin ClawHub New",
url: readString(runBinding.run.html_url),
durationSeconds:
Number.isFinite(createdMs) && Number.isFinite(updatedMs)
? Math.max(0, Math.round((updatedMs - createdMs) / 1000))
: undefined,
bootstrapEvidence: {
targetSha: params.releaseSha,
workflowSha: headSha,
workflowPath,
producerRunAttempt,
terminalRunAttempt,
readbackArtifactId,
readbackArtifactDigest,
packageArtifactId,
packageArtifactDigest,
packageCount: expectedPackages.length,
...expectedToolchain,
},
};
}
function readGitHubApiJson(repo: string, endpoint: string, label: string): unknown {
return parseJson(runCommand("gh", ["api", `repos/${repo}/${endpoint}`]), label);
}
function readGitHubToken(): string {
return requireString(
process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? runCommand("gh", ["auth", "token"]),
"GitHub token",
);
}
function decodeUtf8Exact(bytes: Uint8Array, label: string): string {
let value: string;
try {
value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new Error(`${label} is not valid UTF-8.`, { cause: error });
}
if (!Buffer.from(value, "utf8").equals(bytes)) {
throw new Error(`${label} is not canonically encoded UTF-8.`);
}
return value;
}
export async function downloadClawHubBootstrapReadback(params: {
repo: string;
runId: string;
run: unknown;
readbackArtifact: unknown;
token: string;
fetchImpl?: typeof fetch;
retryAttempts?: number;
retryDelayMs?: number;
timeoutMs?: number;
}): Promise<{ value: unknown; archiveSha256: string }> {
const runBinding = requireClawHubBootstrapRunBinding(params.run, params.runId);
const artifactBinding = requireClawHubReadbackArtifactBinding(
params.readbackArtifact,
runBinding,
);
const downloaded = await readPublicationArtifactArchive({
archivePolicy: {
expectedEntries: [CLAWHUB_BOOTSTRAP_READBACK_FILE],
maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
maxExpandedBytes: CLAWHUB_RESPONSE_BODY_MAX_BYTES,
maxCompressedEntryBytes: () => CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
maxEntryBytes: () => CLAWHUB_RESPONSE_BODY_MAX_BYTES,
},
expected: {
artifactDigest: artifactBinding.artifactDigest,
artifactId: artifactBinding.artifactId,
artifactName: artifactBinding.artifactName,
artifactSizeBytes: artifactBinding.artifactSizeBytes,
repository: params.repo,
runStatePolicy: "completed-success",
runAttempt: runBinding.runAttempt,
runId: runBinding.runId,
workflowEvent: "workflow_dispatch",
workflowHeadBranch: "main",
workflowPath: runBinding.workflowPath,
workflowSha: runBinding.headSha,
},
fetchImpl: params.fetchImpl,
maxArchiveBytes: CLAWHUB_BOOTSTRAP_READBACK_ARCHIVE_MAX_BYTES,
retryAttempts: params.retryAttempts,
retryDelayMs: params.retryDelayMs,
timeoutMs: params.timeoutMs,
token: params.token,
});
const bytes = downloaded.files.get(CLAWHUB_BOOTSTRAP_READBACK_FILE);
if (!bytes) {
throw new Error("Plugin ClawHub New readback artifact is missing its evidence file.");
}
return {
value: parseJson(
decodeUtf8Exact(bytes, "Plugin ClawHub New readback artifact"),
"Plugin ClawHub New readback artifact",
),
archiveSha256: requireArtifactDigest(
sha256Digest(downloaded.archiveBytes),
"Downloaded Plugin ClawHub New readback artifact digest",
),
};
}
async function verifyClawHubBootstrapRun(params: {
repo: string;
runId: string;
releaseSha: string;
version: string;
expectedPackages: string[];
}): Promise<WorkflowRunSummary> {
const run = readGitHubApiJson(
params.repo,
`actions/runs/${params.runId}`,
"Plugin ClawHub New run",
);
const runBinding = requireClawHubBootstrapRunBinding(run, params.runId);
const terminalRunAttempt = runBinding.terminalRunAttempt;
const readbackName = `clawhub-bootstrap-readback-${params.runId}-${terminalRunAttempt}`;
const artifactList = readGitHubApiJson(
params.repo,
`actions/runs/${params.runId}/artifacts?per_page=100&name=${encodeURIComponent(readbackName)}`,
"Plugin ClawHub New readback artifact list",
);
if (!isRecord(artifactList) || !Array.isArray(artifactList.artifacts)) {
throw new Error("Plugin ClawHub New readback artifact list is invalid.");
}
const readbackArtifacts = artifactList.artifacts.filter(
(artifact) => isRecord(artifact) && artifact.name === readbackName,
);
if (readbackArtifacts.length !== 1 || !isRecord(readbackArtifacts[0])) {
throw new Error(
`Plugin ClawHub New run must have exactly one ${readbackName} artifact; found ${readbackArtifacts.length}.`,
);
}
const readbackArtifact = readbackArtifacts[0];
const downloaded = await downloadClawHubBootstrapReadback({
repo: params.repo,
runId: params.runId,
run,
readbackArtifact,
token: readGitHubToken(),
});
if (!isRecord(downloaded.value)) {
throw new Error("Plugin ClawHub New readback evidence is invalid.");
}
const packageArtifactId = requirePositiveIntegerString(
downloaded.value.artifactId,
"Plugin ClawHub New package artifact id",
);
const packageArtifact = readGitHubApiJson(
params.repo,
`actions/artifacts/${packageArtifactId}`,
"Plugin ClawHub New package artifact",
);
return validateClawHubBootstrapEvidence({
repo: params.repo,
runId: params.runId,
releaseSha: params.releaseSha,
expectedVersion: params.version,
expectedPackages: params.expectedPackages,
run,
readbackArtifact,
readbackArchiveSha256: downloaded.archiveSha256,
packageArtifact,
evidence: downloaded.value,
});
}
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`;
}
function assertSelectedPackagesResolved(params: {
label: string;
selection: readonly string[];
packages: readonly { packageName: string }[];
}): void {
if (params.selection.length === 0) {
return;
}
const resolved = new Set(params.packages.map((plugin) => plugin.packageName));
const missing = params.selection.filter((packageName) => !resolved.has(packageName));
if (missing.length > 0) {
throw new Error(`Unknown or non-publishable ${params.label} selection: ${missing.join(", ")}.`);
}
}
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}.`);
}
if (args.releaseSha !== undefined) {
const checkedOutSha = runCommand("git", ["rev-parse", "HEAD"], { cwd: rootDir });
if (checkedOutSha !== args.releaseSha) {
throw new Error(`release checkout SHA is ${checkedOutSha}; expected ${args.releaseSha}.`);
}
}
const lines: string[] = [];
const releaseUrl = args.skipGitHubRelease ? undefined : verifyGitHubRelease(args);
if (releaseUrl === undefined) {
lines.push("GitHub release skipped: final release page is created after verification");
} else {
lines.push(`GitHub release OK: ${releaseUrl}`);
}
const openclawNpm = await 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, {
packageNames: args.pluginSelection.length > 0 ? args.pluginSelection : undefined,
});
assertSelectedPackagesResolved({
label: "npm plugin",
selection: args.pluginSelection,
packages: npmPlugins,
});
for (const plugin of npmPlugins) {
await verifyNpmPackage(plugin.packageName, args.version, args.distTag);
}
lines.push(`plugin npm OK: ${npmPlugins.length}`);
const clawHubPlugins = args.skipClawHub
? []
: collectClawHubPublishablePluginPackages(rootDir, {
packageNames: args.pluginSelection.length > 0 ? args.pluginSelection : undefined,
});
if (args.skipClawHub) {
lines.push("ClawHub skipped");
} else {
assertSelectedPackagesResolved({
label: "ClawHub plugin",
selection: args.pluginSelection,
packages: clawHubPlugins,
});
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[] = [];
const allowedReleaseWorkflowHeadBranches = args.workflowRef
? ["main", args.workflowRef]
: ["main"];
if (args.workflowRuns.fullReleaseValidation !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.fullReleaseValidation,
label: "Full Release Validation",
repo: args.repo,
expectedWorkflowName: "Full Release Validation",
allowedHeadBranches: allowedReleaseWorkflowHeadBranches,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.pluginNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.pluginNpm,
label: "Plugin NPM Release",
repo: args.repo,
expectedWorkflowName: "Plugin NPM Release",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.pluginClawHub !== undefined) {
const clawHubWorkflowRef = args.clawHubWorkflowRef ?? args.workflowRef;
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.pluginClawHub,
label: "Plugin ClawHub Release",
repo: args.repo,
expectedWorkflowName: "Plugin ClawHub Release",
expectedHeadBranch: clawHubWorkflowRef,
rerunFailed: args.rerunFailedClawHub,
}),
);
}
if (args.workflowRuns.pluginClawHubBootstrap !== undefined) {
workflowRuns.push(
await verifyClawHubBootstrapRun({
repo: args.repo,
runId: args.workflowRuns.pluginClawHubBootstrap,
releaseSha: requireCommitSha(args.releaseSha, "release SHA"),
version: args.version,
expectedPackages: args.clawHubBootstrapPlugins,
}),
);
}
if (args.workflowRuns.openclawNpm !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.openclawNpm,
label: "OpenClaw NPM Release",
repo: args.repo,
expectedWorkflowName: "OpenClaw NPM Release",
expectedHeadBranch: args.workflowRef,
rerunFailed: false,
}),
);
}
if (args.workflowRuns.npmTelegram !== undefined) {
workflowRuns.push(
verifyWorkflowRun({
id: args.workflowRuns.npmTelegram,
label: "NPM Telegram Beta E2E",
repo: args.repo,
expectedWorkflowName: "NPM Telegram Beta E2E",
allowedHeadBranches: allowedReleaseWorkflowHeadBranches,
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,
pluginSelection: args.pluginSelection,
openclawNpmIntegrity: openclawNpm.integrity,
openclawNpmTarball: openclawNpm.tarball,
npmRegistrySignaturesVerified: args.skipPostpublish ? null : true,
npmProvenanceAttestationMatched: args.skipPostpublish ? null : true,
githubReleaseUrl: releaseUrl ?? null,
pluginNpmPackageCount: npmPlugins.length,
clawHubPackageCount: clawHubPlugins.length,
workflowRuns,
clawHubBootstrapEvidence:
workflowRuns.find((run) => run.bootstrapEvidence)?.bootstrapEvidence ?? null,
},
null,
2,
)}\n`,
);
lines.push(`release evidence written: ${args.evidenceOut}`);
}
return lines;
}