mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
fix(release): retry ClawHub release planning
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user