diff --git a/scripts/release-beta-smoke.ts b/scripts/release-beta-smoke.ts index ae080edc51e..1ce32f82f78 100644 --- a/scripts/release-beta-smoke.ts +++ b/scripts/release-beta-smoke.ts @@ -14,6 +14,26 @@ interface Options { skipTelegram: boolean; } +export type RunOptions = { + capture?: boolean; + timeoutMs?: number; +}; + +export type WorkflowRunInfo = { + conclusion: string | null; + html_url: string; + status: string; + updated_at: string; +}; + +export type PollRunOptions = { + pollIntervalMs?: number; + readRun?: (repo: string, runId: string) => WorkflowRunInfo; + sleep?: (ms: number) => Promise; + timeoutMs?: number; + now?: () => number; +}; + function usage(): string { return `Usage: pnpm release:beta-smoke -- --beta beta4 [options] @@ -88,15 +108,43 @@ function requireValue(argv: string[], index: number, flag: string): string { } const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024; +const DEFAULT_COMMAND_TIMEOUT_MS = readPositiveInt( + process.env.OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS, + 10 * 60_000, +); +const TELEGRAM_POLL_INTERVAL_MS = readPositiveInt( + process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS, + 30_000, +); +const TELEGRAM_POLL_TIMEOUT_MS = readPositiveInt( + process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS, + 4 * 60 * 60_000, +); -function run(command: string, args: string[], input?: { capture?: boolean }): string { +function readPositiveInt(raw: string | undefined, fallback: number): number { + const text = (raw ?? "").trim(); + if (!/^\d+$/u.test(text)) { + return fallback; + } + const parsed = Number(text); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export function run(command: string, args: string[], input?: RunOptions): string { + const timeoutMs = input?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; const result = spawnSync(command, args, { encoding: "utf8", + killSignal: "SIGKILL", maxBuffer: CAPTURE_MAX_BUFFER_BYTES, stdio: input?.capture ? ["ignore", "pipe", "pipe"] : "inherit", + timeout: timeoutMs, }); - if (result.status !== 0) { - const reason = result.status ?? result.signal ?? result.error?.message ?? "unknown"; + if (result.error || result.status !== 0) { + const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; + const reason = + errorCode === "ETIMEDOUT" + ? `timed out after ${timeoutMs}ms` + : (result.status ?? result.signal ?? result.error?.message ?? "unknown"); const stderr = result.stderr ? `\n${result.stderr}` : ""; throw new Error(`${command} ${args.join(" ")} failed with ${reason}${stderr}`); } @@ -161,7 +209,7 @@ function runParallels(beta: string, model: string): void { "150m", ...forwarded.map(shellQuote), ].join(" "); - run("bash", ["-lc", command]); + run("bash", ["-lc", command], { timeoutMs: 155 * 60_000 }); } function ghJson(repo: string, pathSuffix: string): unknown { @@ -268,14 +316,22 @@ async function dispatchTelegram(options: Options, packageSpec: string): Promise< }); } -async function pollRun(repo: string, runId: string): Promise { +export async function pollRun( + repo: string, + runId: string, + options: PollRunOptions = {}, +): Promise { + const started = (options.now ?? Date.now)(); + const timeoutMs = Math.max(1, options.timeoutMs ?? TELEGRAM_POLL_TIMEOUT_MS); + const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? TELEGRAM_POLL_INTERVAL_MS); + const sleep = + options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const readRun = + options.readRun ?? + ((currentRepo: string, currentRunId: string) => + ghJson(currentRepo, `actions/runs/${currentRunId}`) as WorkflowRunInfo); for (;;) { - const info = ghJson(repo, `actions/runs/${runId}`) as { - conclusion: string | null; - html_url: string; - status: string; - updated_at: string; - }; + const info = readRun(repo, runId); console.log( `Telegram workflow ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} updated=${info.updated_at}`, ); @@ -288,7 +344,11 @@ async function pollRun(repo: string, runId: string): Promise { console.log(info.html_url); return; } - await new Promise((resolve) => setTimeout(resolve, 30_000)); + const elapsedMs = (options.now ?? Date.now)() - started; + if (elapsedMs >= timeoutMs) { + throw new Error(`Telegram workflow ${runId} did not complete within ${timeoutMs}ms`); + } + await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs)); } } diff --git a/test/scripts/release-beta-smoke.test.ts b/test/scripts/release-beta-smoke.test.ts index 1c59e2bc7ac..ef902cdc23a 100644 --- a/test/scripts/release-beta-smoke.test.ts +++ b/test/scripts/release-beta-smoke.test.ts @@ -3,6 +3,8 @@ import { mergeTelegramProofIntoReleaseBody, parseArgs, parseWorkflowRunIdFromOutput, + pollRun, + run, selectNewestDispatchedRunId, } from "../../scripts/release-beta-smoke.ts"; @@ -97,4 +99,66 @@ describe("release-beta-smoke", () => { expect(merged.indexOf("actions/runs/123")).toBeLessThan(merged.indexOf("### Assets")); }); + + it("bounds child command hangs", () => { + expect(() => + run(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + capture: true, + timeoutMs: 50, + }), + ).toThrow(/timed out after 50ms/u); + }); + + it("uses a non-ignorable timeout signal for trapped children", () => { + expect(() => + run( + process.execPath, + ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], + { + capture: true, + timeoutMs: 50, + }, + ), + ).toThrow(/timed out after 50ms/u); + }); + + it("stops polling Telegram workflow runs after the timeout budget", async () => { + let now = 0; + const sleeps: number[] = []; + + await expect( + pollRun("openclaw/openclaw", "123", { + now: () => now, + pollIntervalMs: 400, + readRun: () => ({ + conclusion: null, + html_url: "https://github.com/openclaw/openclaw/actions/runs/123", + status: "queued", + updated_at: "2026-05-28T12:00:00Z", + }), + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + timeoutMs: 1000, + }), + ).rejects.toThrow("Telegram workflow 123 did not complete within 1000ms"); + expect(sleeps).toEqual([400, 400, 200]); + }); + + it("returns when the Telegram workflow succeeds", async () => { + await expect( + pollRun("openclaw/openclaw", "123", { + readRun: () => ({ + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/123", + status: "completed", + updated_at: "2026-05-28T12:00:00Z", + }), + sleep: async () => { + throw new Error("sleep should not run after completion"); + }, + }), + ).resolves.toBeUndefined(); + }); });