// Plugin Clawhub Release script supports OpenClaw repository automation. import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; import { readBoundedResponseText } from "./bounded-response.ts"; import { collectExtensionPackageJsonCandidates, collectChangedPathsFromGitRange, collectChangedExtensionIdsFromPaths, collectPublishablePluginPackageErrors, assertPluginReleaseVersionFloors, parsePluginReleaseArgs, resolvePublishablePluginVersion, resolveGitCommitSha, resolveChangedPublishablePluginPackages, resolveSelectedPublishablePluginPackages, type GitRangeSelection, type PluginReleaseSelectionMode, } from "./plugin-npm-release.ts"; export { assertPluginReleaseVersionFloors, parsePluginReleaseArgs }; type PluginPackageJson = { name?: string; version?: string; private?: boolean; openclaw?: { extensions?: string[]; install?: { npmSpec?: string; }; compat?: { pluginApi?: string; minGatewayVersion?: string; }; build?: { openclawVersion?: string; pluginSdkVersion?: string; }; release?: { publishToClawHub?: boolean; publishToNpm?: boolean; }; }; }; export type PublishablePluginPackage = { extensionId: string; packageDir: string; packageName: string; version: string; channel: "stable" | "alpha" | "beta"; publishTag: "latest" | "alpha" | "beta"; }; type PluginReleasePlanItem = PublishablePluginPackage & { alreadyPublished: boolean; artifactName: string; }; type PluginReleasePlan = { all: PluginReleasePlanItem[]; candidates: PluginReleasePlanItem[]; bootstrapCandidates: PluginReleasePlanItem[]; missingTrustedPublisher: PluginReleasePlanItem[]; skippedPublished: PluginReleasePlanItem[]; }; type ClawHubTrustedPublisherDetail = { trustedPublisher?: unknown; }; type ClawHubTrustedPublisherConfig = { repository?: unknown; workflowFilename?: unknown; environment?: unknown; }; type PluginReleasePlanItemWithPackageState = PluginReleasePlanItem & { packageExists: boolean; hasTrustedPublisher: boolean; }; type ClawHubPublishablePluginPackageFilters = { extensionIds?: readonly string[]; packageNames?: readonly string[]; }; const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai"; const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000; const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024; const CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; const CLAWHUB_MAX_RETRY_AFTER_MS = 60_000; const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw"; const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml"; const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [ ".github/workflows/plugin-clawhub-release.yml", ".github/actions/setup-node-env", "package.json", "pnpm-lock.yaml", "packages/plugin-package-contract/src/index.ts", "scripts/lib/bounded-response.ts", "scripts/lib/npm-publish-plan.mjs", "scripts/lib/plugin-npm-release.ts", "scripts/lib/plugin-clawhub-release.ts", "scripts/openclaw-npm-release-check.ts", "scripts/plugin-clawhub-publish.sh", "scripts/plugin-clawhub-release-check.ts", "scripts/plugin-clawhub-release-plan.ts", ] as const; function getRegistryBaseUrl(explicit?: string) { return ( explicit?.trim() || process.env.CLAWHUB_REGISTRY?.trim() || process.env.CLAWHUB_SITE?.trim() || CLAWHUB_DEFAULT_REGISTRY ); } type ClawHubRequestOptions = { fetchImpl?: typeof fetch; requestTimeoutMs?: number; }; async function fetchClawHubRequest( url: URL, options: ClawHubRequestOptions = {}, ): Promise<{ clearTimeout: () => void; response: Response; signal: AbortSignal; timeoutPromise: Promise; }> { const timeoutMs = options.requestTimeoutMs ?? CLAWHUB_REQUEST_TIMEOUT_MS; const controller = new AbortController(); const timeoutError = Object.assign( new Error(`ClawHub request timed out after ${timeoutMs}ms: ${url.href}`), { code: "ETIMEDOUT" }, ); let timeout: ReturnType | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timeout = setTimeout(() => { controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); timeout.unref?.(); }); try { const response = await Promise.race([ (options.fetchImpl ?? fetch)(url, { method: "GET", headers: { Accept: "application/json", }, signal: controller.signal, }), timeoutPromise, ]); return { clearTimeout: () => clearTimeout(timeout), response, signal: controller.signal, timeoutPromise, }; } catch (error) { clearTimeout(timeout); throw error; } } async function cancelClawHubResponseBody(response: Response): Promise { await response.body?.cancel().catch(() => undefined); } function formatClawHubPackageArtifactName( plugin: Pick, ) { const safeName = plugin.packageName .replace(/^@/u, "") .replace(/[^A-Za-z0-9_.-]+/gu, "-") .replace(/^-+|-+$/gu, ""); return `clawhub-package-${safeName}-${plugin.version}`; } export function collectClawHubPublishablePluginPackages( rootDir = resolve("."), filters: ClawHubPublishablePluginPackageFilters = {}, ): PublishablePluginPackage[] { const publishable: PublishablePluginPackage[] = []; const validationErrors: string[] = []; const selectedExtensionIds = new Set(filters.extensionIds ?? []); const selectedPackageNames = new Set(filters.packageNames ?? []); const hasSelectedExtensionIds = Array.isArray(filters.extensionIds); const hasSelectedPackageNames = Array.isArray(filters.packageNames); for (const candidate of collectExtensionPackageJsonCandidates(rootDir)) { const { extensionId, packageDir, packageJson } = candidate; if (hasSelectedExtensionIds && !selectedExtensionIds.has(extensionId)) { continue; } const packageName = packageJson.name?.trim() ?? ""; if (hasSelectedPackageNames && !selectedPackageNames.has(packageName)) { continue; } if (packageJson.openclaw?.release?.publishToClawHub !== true) { continue; } if (!SAFE_EXTENSION_ID_RE.test(extensionId)) { validationErrors.push( `${extensionId}: extension directory name must match ^[a-z0-9][a-z0-9._-]*$ for ClawHub publish.`, ); continue; } const errors = collectPublishablePluginPackageErrors(candidate); if (errors.length > 0) { validationErrors.push(...errors.map((error) => `${extensionId}: ${error}`)); continue; } const contractValidation = validateExternalCodePluginPackageJson(packageJson); if (contractValidation.issues.length > 0) { validationErrors.push( ...contractValidation.issues.map((issue) => `${extensionId}: ${issue.message}`), ); continue; } const resolvedVersion = resolvePublishablePluginVersion({ extensionId, packageJson, validationErrors, }); if (!resolvedVersion) { continue; } const { version, parsedVersion } = resolvedVersion; publishable.push({ extensionId, packageDir, packageName, version, channel: parsedVersion.channel, publishTag: parsedVersion.channel === "alpha" ? "alpha" : parsedVersion.channel === "beta" ? "beta" : "latest", }); } if (validationErrors.length > 0) { throw new Error( `Publishable ClawHub plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`, ); } return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName)); } export function collectPluginClawHubReleasePathsFromGitRange(params: { rootDir?: string; gitRange: GitRangeSelection; }): string[] { return collectPluginClawHubReleasePathsFromGitRangeForPathspecs(params, ["extensions"]); } function collectPluginClawHubRelevantPathsFromGitRange(params: { rootDir?: string; gitRange: GitRangeSelection; }): string[] { return collectPluginClawHubReleasePathsFromGitRangeForPathspecs(params, [ "extensions", ...CLAWHUB_SHARED_RELEASE_INPUT_PATHS, ]); } function collectPluginClawHubReleasePathsFromGitRangeForPathspecs( params: { rootDir?: string; gitRange: GitRangeSelection; }, pathspecs: readonly string[], ): string[] { return collectChangedPathsFromGitRange({ rootDir: params.rootDir, gitRange: params.gitRange, pathspecs, }); } function hasSharedClawHubReleaseInputChanges(changedPaths: readonly string[]) { return changedPaths.some((path) => CLAWHUB_SHARED_RELEASE_INPUT_PATHS.some( (sharedPath) => path === sharedPath || path.startsWith(`${sharedPath}/`), ), ); } export function resolveChangedClawHubPublishablePluginPackages(params: { plugins: PublishablePluginPackage[]; changedPaths: readonly string[]; }): PublishablePluginPackage[] { return resolveChangedPublishablePluginPackages({ plugins: params.plugins, changedExtensionIds: collectChangedExtensionIdsFromPaths(params.changedPaths), }); } export function resolveSelectedClawHubPublishablePluginPackages(params: { plugins: PublishablePluginPackage[]; selection?: string[]; selectionMode?: PluginReleaseSelectionMode; gitRange?: GitRangeSelection; rootDir?: string; }): PublishablePluginPackage[] { if (params.selectionMode === "all-publishable") { return params.plugins; } if (params.selection && params.selection.length > 0) { return resolveSelectedPublishablePluginPackages({ plugins: params.plugins, selection: params.selection, }); } if (params.gitRange) { const changedPaths = collectPluginClawHubRelevantPathsFromGitRange({ rootDir: params.rootDir, gitRange: params.gitRange, }); if (hasSharedClawHubReleaseInputChanges(changedPaths)) { return params.plugins; } return resolveChangedClawHubPublishablePluginPackages({ plugins: params.plugins, changedPaths, }); } return params.plugins; } function readPackageManifestAtGitRef(params: { rootDir?: string; ref: string; packageDir: string; }): PluginPackageJson | null { const rootDir = params.rootDir ?? resolve("."); const commitSha = resolveGitCommitSha(rootDir, params.ref, "ref"); try { const raw = execFileSync("git", ["show", `${commitSha}:${params.packageDir}/package.json`], { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); return JSON.parse(raw) as PluginPackageJson; } catch { return null; } } export function collectClawHubVersionGateErrors(params: { plugins: PublishablePluginPackage[]; gitRange: GitRangeSelection; rootDir?: string; }): string[] { const changedPaths = collectPluginClawHubReleasePathsFromGitRange({ rootDir: params.rootDir, gitRange: params.gitRange, }); const changedPlugins = resolveChangedClawHubPublishablePluginPackages({ plugins: params.plugins, changedPaths, }); const errors: string[] = []; for (const plugin of changedPlugins) { const baseManifest = readPackageManifestAtGitRef({ rootDir: params.rootDir, ref: params.gitRange.baseRef, packageDir: plugin.packageDir, }); if (baseManifest?.openclaw?.release?.publishToClawHub !== true) { continue; } const baseVersion = typeof baseManifest.version === "string" && baseManifest.version.trim() ? baseManifest.version.trim() : null; if (baseVersion === null || baseVersion !== plugin.version) { continue; } errors.push( `${plugin.packageName}@${plugin.version}: changed publishable plugin still has the same version in package.json.`, ); } return errors; } async function isPluginVersionPublishedOnClawHub( packageName: string, version: string, options: { fetchImpl?: typeof fetch; registryBaseUrl?: string; requestTimeoutMs?: number; } = {}, ): Promise { const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(version)}`, getRegistryBaseUrl(options.registryBaseUrl), ); const request = await fetchClawHubRequest(url, { fetchImpl: options.fetchImpl, requestTimeoutMs: options.requestTimeoutMs, }); const { response } = request; try { if (response.status === 404) { return false; } if (response.ok) { return true; } throw new Error( `Failed to query ClawHub for ${packageName}@${version}: ${response.status} ${response.statusText}`, ); } finally { await cancelClawHubResponseBody(response); request.clearTimeout(); } } async function doesClawHubPackageExist( packageName: string, options: { fetchImpl?: typeof fetch; registryBaseUrl?: string; requestTimeoutMs?: number; } = {}, ): Promise { const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}`, getRegistryBaseUrl(options.registryBaseUrl), ); const request = await fetchClawHubRequest(url, { fetchImpl: options.fetchImpl, requestTimeoutMs: options.requestTimeoutMs, }); const { response } = request; try { if (response.status === 404) { return false; } if (!response.ok) { throw new Error( `Failed to query ClawHub package ${packageName}: ${response.status} ${response.statusText}`, ); } return true; } finally { await cancelClawHubResponseBody(response); request.clearTimeout(); } } async function hasClawHubTrustedPublisher( packageName: string, options: { fetchImpl?: typeof fetch; registryBaseUrl?: string; requestTimeoutMs?: number; } = {}, ): Promise { const url = new URL( `/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`, getRegistryBaseUrl(options.registryBaseUrl), ); for (let attempt = 0; ; attempt += 1) { const request = await fetchClawHubRequest(url, { fetchImpl: options.fetchImpl, requestTimeoutMs: options.requestTimeoutMs, }); const { response } = request; const retryRateLimit = response.status === 429 && attempt < CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length; try { if (!retryRateLimit) { if (!response.ok) { throw new Error( `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, ); } let trustedPublisherDetail: ClawHubTrustedPublisherDetail; const text = await readBoundedResponseText( response, `ClawHub trusted publisher ${packageName}`, CLAWHUB_RESPONSE_BODY_MAX_BYTES, { signal: request.signal, timeoutPromise: request.timeoutPromise, }, ); try { trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; } catch (error) { throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { cause: error, }); } return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); } } finally { request.clearTimeout(); } await response.body?.cancel().catch(() => undefined); await delay(clawHubRetryDelayMs(response, attempt)); } } function clawHubRetryDelayMs(response: Response, attempt: number): number { const retryAfter = response.headers.get("retry-after")?.trim(); if (retryAfter) { const retryAfterSeconds = Number(retryAfter); if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { const retryAfterMs = Math.round(retryAfterSeconds * 1_000); if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) { return retryAfterMs; } } const retryAfterAt = Date.parse(retryAfter); if (Number.isFinite(retryAfterAt)) { const retryAfterMs = Math.max(0, retryAfterAt - Date.now()); if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) { return retryAfterMs; } } } return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0; } async function delay(ms: number): Promise { await new Promise((resolveDelay) => { setTimeout(resolveDelay, ms); }); } function isOpenClawPluginTrustedPublisher(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } const trustedPublisher = value as ClawHubTrustedPublisherConfig; return ( trustedPublisher.repository === OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY && trustedPublisher.workflowFilename === OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME && trustedPublisher.environment == null ); } function stripPackageReleaseState( item: PluginReleasePlanItemWithPackageState, ): PluginReleasePlanItem { const { packageExists: _packageExists, hasTrustedPublisher: _hasTrustedPublisher, ...planItem } = item; return planItem; } export async function collectPluginClawHubReleasePlan(params?: { rootDir?: string; selection?: string[]; selectionMode?: PluginReleaseSelectionMode; gitRange?: GitRangeSelection; registryBaseUrl?: string; fetchImpl?: typeof fetch; requestTimeoutMs?: number; }): Promise { const rootDir = params?.rootDir; const selection = params?.selection ?? []; const changedPaths = params?.gitRange ? collectPluginClawHubRelevantPathsFromGitRange({ rootDir, gitRange: params.gitRange, }) : []; const sharedInputChanged = hasSharedClawHubReleaseInputChanges(changedPaths); const extensionIds = params?.selectionMode === "all-publishable" || !params?.gitRange || sharedInputChanged ? undefined : collectChangedExtensionIdsFromPaths(changedPaths); const allPublishable = collectClawHubPublishablePluginPackages(rootDir, { extensionIds, packageNames: selection.length > 0 ? selection : undefined, }); const selectedPublishable = resolveSelectedClawHubPublishablePluginPackages({ plugins: allPublishable, selection, selectionMode: params?.selectionMode, gitRange: params?.gitRange, rootDir, }); const explicitPublishSelection = params?.selectionMode !== undefined || selection.length > 0; if (explicitPublishSelection) { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan"); } const planned: PluginReleasePlanItemWithPackageState[] = []; for (const plugin of selectedPublishable) { const packageExists = await doesClawHubPackageExist(plugin.packageName, { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl, requestTimeoutMs: params?.requestTimeoutMs, }); const hasTrustedPublisher = packageExists ? await hasClawHubTrustedPublisher(plugin.packageName, { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl, requestTimeoutMs: params?.requestTimeoutMs, }) : false; const alreadyPublished = packageExists ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl, requestTimeoutMs: params?.requestTimeoutMs, }) : false; planned.push({ extensionId: plugin.extensionId, packageDir: plugin.packageDir, packageName: plugin.packageName, version: plugin.version, channel: plugin.channel, publishTag: plugin.publishTag, packageExists, hasTrustedPublisher, alreadyPublished, artifactName: formatClawHubPackageArtifactName(plugin), }); } const all = planned.map(stripPackageReleaseState); return { all, candidates: planned .filter( (plugin) => plugin.packageExists && plugin.hasTrustedPublisher && !plugin.alreadyPublished, ) .map(stripPackageReleaseState), bootstrapCandidates: planned .filter((plugin) => !plugin.packageExists) .map(stripPackageReleaseState), missingTrustedPublisher: planned .filter((plugin) => plugin.packageExists && !plugin.hasTrustedPublisher) .map(stripPackageReleaseState), skippedPublished: planned .filter((plugin) => plugin.alreadyPublished) .map(stripPackageReleaseState), }; }