diff --git a/.github/workflows/docs-live-smoke.yml b/.github/workflows/docs-live-smoke.yml index a8f2f2f9b1..4a80a63b5e 100644 --- a/.github/workflows/docs-live-smoke.yml +++ b/.github/workflows/docs-live-smoke.yml @@ -22,6 +22,7 @@ jobs: smoke: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 25 steps: - name: Check out uses: actions/checkout@v7.0.1 @@ -116,6 +117,7 @@ jobs: "cache-control": "no-cache", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) { throw new Error(`${path}: HTTP ${response.status}`); @@ -140,6 +142,7 @@ jobs: "cache-control": "no-cache", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`${path}: markdown HTTP ${response.status}`); const text = await response.text(); @@ -158,6 +161,7 @@ jobs: "cache-control": "no-cache", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`${path}: text HTTP ${response.status}`); const text = await response.text(); @@ -178,6 +182,7 @@ jobs: "cache-control": "no-cache", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`/api/search: HTTP ${response.status}`); const contentType = response.headers.get("content-type") ?? ""; @@ -202,6 +207,7 @@ jobs: "content-type": "application/json", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), body: JSON.stringify({ jsonrpc: "2.0", id: 1, @@ -227,6 +233,7 @@ jobs: "cache-control": "no-cache", pragma: "no-cache", }, + signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`${path}: header HTTP ${response.status}`); const value = response.headers.get(name) ?? ""; @@ -266,6 +273,7 @@ jobs: sample: if: github.event_name == 'schedule' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out main uses: actions/checkout@v7.0.1 diff --git a/scripts/docs-site/docs-live-smoke-workflow.test.mjs b/scripts/docs-site/docs-live-smoke-workflow.test.mjs new file mode 100644 index 0000000000..33084402ac --- /dev/null +++ b/scripts/docs-site/docs-live-smoke-workflow.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const workflowPath = fileURLToPath( + new URL("../../.github/workflows/docs-live-smoke.yml", import.meta.url), +); + +function loadWorkflow() { + return fs.readFileSync(workflowPath, "utf8"); +} + +function jobBlocks(source) { + const jobsIdx = source.search(/^jobs:\s*$/m); + assert.ok(jobsIdx >= 0, "workflow must declare jobs"); + const jobsSection = source.slice(jobsIdx); + const matches = [...jobsSection.matchAll(/^ ([A-Za-z][\w-]*)\s*:\s*$/gm)]; + assert.ok(matches.length > 0, "workflow must declare at least one job"); + return matches.map((match, index) => { + const start = match.index; + const end = index + 1 < matches.length ? matches[index + 1].index : jobsSection.length; + return { name: match[1], body: jobsSection.slice(start, end) }; + }); +} + +function extractFetchCalls(source) { + const calls = []; + const needle = "await fetch("; + let from = 0; + while (from < source.length) { + const start = source.indexOf(needle, from); + if (start === -1) { + break; + } + let i = start + needle.length; + let depth = 1; + let quote = null; + while (i < source.length && depth > 0) { + const ch = source[i]; + if (quote) { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === quote) { + quote = null; + } + } else if (ch === '"' || ch === "'" || ch === "`") { + quote = ch; + } else if (ch === "(") { + depth += 1; + } else if (ch === ")") { + depth -= 1; + } + i += 1; + } + calls.push(source.slice(start, i)); + from = i; + } + return calls; +} + +test("every docs-live-smoke job has timeout-minutes", () => { + const jobs = jobBlocks(loadWorkflow()); + assert.ok( + jobs.length >= 2, + `expected smoke and sample jobs, got ${jobs.map((job) => job.name).join(",")}`, + ); + for (const job of jobs) { + const timeout = job.body.match(/^\s+timeout-minutes:\s+(\d+)\s*$/m); + assert.ok(timeout, `${job.name} is missing timeout-minutes`); + assert.ok(Number(timeout[1]) > 0, `${job.name} timeout-minutes must be positive`); + } +}); + +test("every inline live fetch uses AbortSignal.timeout", () => { + const fetches = extractFetchCalls(loadWorkflow()); + assert.ok(fetches.length >= 6, `expected at least 6 live fetches, found ${fetches.length}`); + for (const call of fetches) { + assert.match( + call, + /signal:\s*AbortSignal\.timeout\(\s*\d[\d_]*\s*\)/, + `fetch is missing AbortSignal.timeout:\n${call}`, + ); + } +});