fix(release): retry ClawHub release planning

This commit is contained in:
Vincent Koc
2026-06-19 12:38:15 +08:00
parent e172f64f3f
commit 37b2770071
2 changed files with 196 additions and 66 deletions

View File

@@ -89,6 +89,7 @@ type ClawHubPublishablePluginPackageFilters = {
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 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._-]*$/;
@@ -482,43 +483,70 @@ async function hasClawHubTrustedPublisher(
`/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`,
getRegistryBaseUrl(options.registryBaseUrl),
);
const request = await fetchClawHubRequest(url, {
fetchImpl: options.fetchImpl,
requestTimeoutMs: options.requestTimeoutMs,
});
const { response } = request;
for (let attempt = 0; ; attempt += 1) {
const request = await fetchClawHubRequest(url, {
fetchImpl: options.fetchImpl,
requestTimeoutMs: options.requestTimeoutMs,
});
const { response } = request;
try {
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,
});
if (response.status !== 429 || attempt >= CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length) {
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();
}
return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher);
} finally {
request.clearTimeout();
await delay(clawHubRetryDelayMs(response, attempt));
}
}
function clawHubRetryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter !== null) {
const retryAfterSeconds = Number(retryAfter);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
return Math.round(retryAfterSeconds * 1_000);
}
const retryAfterAt = Date.parse(retryAfter);
if (Number.isFinite(retryAfterAt)) {
return Math.max(0, retryAfterAt - Date.now());
}
}
return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0;
}
async function delay(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
function isOpenClawPluginTrustedPublisher(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
@@ -581,42 +609,41 @@ export async function collectPluginClawHubReleasePlan(params?: {
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan");
}
const planned = await Promise.all(
selectedPublishable.map(async (plugin): Promise<PluginReleasePlanItemWithPackageState> => {
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;
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;
return {
extensionId: plugin.extensionId,
packageDir: plugin.packageDir,
packageName: plugin.packageName,
version: plugin.version,
channel: plugin.channel,
publishTag: plugin.publishTag,
packageExists,
hasTrustedPublisher,
alreadyPublished,
artifactName: formatClawHubPackageArtifactName(plugin),
};
}),
);
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 {

View File

@@ -9,7 +9,7 @@ import {
writeFileSync,
} from "node:fs";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOpenClawReleaseClawHubPlan,
buildOpenClawReleaseClawHubRuntimeState,
@@ -452,6 +452,109 @@ describe("collectPluginClawHubReleasePlan", () => {
expect(canceled).toEqual(["package", "version"]);
});
it("retries a rate-limited trusted publisher lookup", async () => {
const repoDir = createTempPluginRepo();
let trustedPublisherRequests = 0;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = Date.now();
return new Response("", { status: 429 });
}
retryTrustedPublisherRequestAt = Date.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(trustedPublisherRequests).toBe(2);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
});
it("honors an HTTP-date Retry-After header", async () => {
const repoDir = createTempPluginRepo();
const retryAfter = "Wed, 21 Oct 2030 07:28:00 GMT";
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(retryAfter) - 1_000);
let trustedPublisherRequests = 0;
let firstTrustedPublisherRequestAt: number | undefined;
let retryTrustedPublisherRequestAt: number | undefined;
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const pathname = new URL(requestUrl).pathname;
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") {
return new Response("{}", { status: 200 });
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") {
trustedPublisherRequests += 1;
if (trustedPublisherRequests === 1) {
firstTrustedPublisherRequestAt = performance.now();
return new Response("", { status: 429, headers: { "retry-after": retryAfter } });
}
retryTrustedPublisherRequestAt = performance.now();
return new Response(
JSON.stringify({
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
}),
{ status: 200 },
);
}
if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") {
return new Response("", { status: 404 });
}
throw new Error(`Unexpected ClawHub request to ${pathname}`);
};
try {
await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
} finally {
nowSpy.mockRestore();
}
expect(trustedPublisherRequests).toBe(2);
expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual(
(firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900,
);
});
it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({