fix(release): bound beta smoke waits

This commit is contained in:
Vincent Koc
2026-05-28 23:34:43 +02:00
parent fc6fd9aa36
commit b05aefa3cf
2 changed files with 136 additions and 12 deletions

View File

@@ -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<void>;
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<void> {
export async function pollRun(
repo: string,
runId: string,
options: PollRunOptions = {},
): Promise<void> {
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<void>((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<void> {
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));
}
}

View File

@@ -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();
});
});