From ed4c4afc0f6e6ce455bafcd3a2d4857fd1ebc778 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 3 Jun 2026 08:19:03 +0200 Subject: [PATCH] fix(release): bound candidate GitHub requests --- CHANGELOG.md | 1 + scripts/release-candidate-checklist.mjs | 47 ++++++++++++++---- .../release-candidate-checklist.test.ts | 48 ++++++++++++++++++- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ec056463a..18fea2977eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet. - Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs. - Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts. +- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling. - Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold. - Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`. - Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time. diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 73f4505d565..d38066bcb8d 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -12,6 +12,7 @@ const DEFAULT_RELEASE_PROFILE = "beta"; const DEFAULT_NPM_DIST_TAG = "beta"; const DEFAULT_PLUGIN_SCOPE = "all-publishable"; const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai"; +const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000; function usage() { return `Usage: pnpm release:candidate -- --tag vYYYY.M.D-beta.N [options] @@ -182,15 +183,43 @@ function readJson(path, label) { } } -async function githubApi(path) { - const token = run("gh", ["auth", "token"], { capture: true }).trim(); - const response = await fetch(`https://api.github.com/${path}`, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - }); +function githubApiTimeoutMs() { + const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS; + if (!raw) { + return DEFAULT_GITHUB_API_TIMEOUT_MS; + } + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive number"); + } + return Math.trunc(value); +} + +function githubApiTimedOut(error) { + return ( + error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError") + ); +} + +export async function githubApi(path, options = {}) { + const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim(); + const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs(); + let response; + try { + response = await (options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, { + signal: AbortSignal.timeout(timeoutMs), + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + } catch (error) { + if (githubApiTimedOut(error)) { + throw new Error(`GitHub API ${path} timed out after ${timeoutMs}ms`, { cause: error }); + } + throw error; + } if (!response.ok) { throw new Error(`GitHub API ${path} failed with ${response.status}: ${await response.text()}`); } diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 163e126fc85..15081f0cad9 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildPublishCommand, + githubApi, parseArgs, parseRunIdFromDispatchOutput, resolveArtifactName, @@ -125,4 +126,49 @@ describe("release candidate checklist", () => { ), ).toBe("openclaw-npm-preflight-dba00"); }); + + it("bounds GitHub API requests with a timeout signal", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.headers).toMatchObject({ + Accept: "application/vnd.github+json", + Authorization: "Bearer test-token", + "X-GitHub-Api-Version": "2022-11-28", + }); + return { + ok: true, + json: async () => ({ workflow_runs: [] }), + }; + }); + + await expect( + githubApi("repos/openclaw/openclaw/actions/runs", { + fetchImpl, + timeoutMs: 1234, + token: "test-token", + }), + ).resolves.toEqual({ workflow_runs: [] }); + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.github.com/repos/openclaw/openclaw/actions/runs", + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + it("includes the GitHub API path when a request times out", async () => { + const fetchImpl = vi.fn(async () => { + throw new DOMException("request timed out", "TimeoutError"); + }); + + await expect( + githubApi("repos/openclaw/openclaw/actions/runs/123/jobs", { + fetchImpl, + timeoutMs: 5, + token: "test-token", + }), + ).rejects.toThrow( + "GitHub API repos/openclaw/openclaw/actions/runs/123/jobs timed out after 5ms", + ); + }); });