From ed6a045f6ebe786f61f8d2dc04c69fa2a178b64c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 5 Sep 2026 04:04:18 -0700 Subject: [PATCH] fix: add timeout-minutes to remaining workflows Jobs in update-skills, translate-incremental, stale, and codeql had no timeout-minutes, so a hang could occupy a runner until GitHub's six-hour default. Short jobs use 15. CodeQL uses 30. prepare uses 70 so the default 3600s debounce can finish. GitHub rejects timeout-minutes on reusable-call jobs, so incremental shell-check and finalize are bounded in the callee (15 and 60). Signed-off-by: Sebastien Tardif --- .github/workflows/codeql.yml | 1 + .github/workflows/stale.yml | 1 + .../workflows/translate-finalize-reusable.yml | 1 + .github/workflows/translate-incremental.yml | 3 + .../translate-shell-check-reusable.yml | 1 + .github/workflows/update-skills.yml | 1 + scripts/docs-site/workflow-timeouts.test.mjs | 106 ++++++++++++++++++ 7 files changed, 114 insertions(+) create mode 100644 scripts/docs-site/workflow-timeouts.test.mjs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0e4363e65..ebb16486b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,7 @@ jobs: analyze: name: analyze (${{ matrix.language }}) runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 56e729d0f..e63c1d1d3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,6 +18,7 @@ jobs: issues: write pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Mark stale unassigned issues and pull requests uses: actions/stale@v11.0.0 diff --git a/.github/workflows/translate-finalize-reusable.yml b/.github/workflows/translate-finalize-reusable.yml index 9cbbf9cd9..fc5234bcf 100644 --- a/.github/workflows/translate-finalize-reusable.yml +++ b/.github/workflows/translate-finalize-reusable.yml @@ -29,6 +29,7 @@ jobs: finalize: name: Commit successful locale artifacts runs-on: ubuntu-latest + timeout-minutes: 60 concurrency: group: docs-i18n-finalize cancel-in-progress: false diff --git a/.github/workflows/translate-incremental.yml b/.github/workflows/translate-incremental.yml index cc05bfca2..0f0db7d83 100644 --- a/.github/workflows/translate-incremental.yml +++ b/.github/workflows/translate-incremental.yml @@ -54,6 +54,7 @@ jobs: name: Debounce and pick source needs: validate-workflow-shell runs-on: ubuntu-latest + timeout-minutes: 70 outputs: mode: ${{ steps.prepare.outputs.mode }} publish_ref: ${{ steps.prepare.outputs.publish_ref }} @@ -84,6 +85,7 @@ jobs: needs: prepare if: needs.prepare.outputs.should_translate == 'true' runs-on: ubuntu-latest + timeout-minutes: 15 outputs: locale_count: ${{ steps.plan.outputs.locale_count }} source_doc_count: ${{ steps.plan.outputs.source_doc_count }} @@ -154,6 +156,7 @@ jobs: needs: prepare if: needs.prepare.outputs.should_translate == 'true' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out uses: actions/checkout@v7.0.1 diff --git a/.github/workflows/translate-shell-check-reusable.yml b/.github/workflows/translate-shell-check-reusable.yml index c69375ee9..d2893a1e6 100644 --- a/.github/workflows/translate-shell-check-reusable.yml +++ b/.github/workflows/translate-shell-check-reusable.yml @@ -14,6 +14,7 @@ jobs: check: name: Check workflow shell blocks runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out uses: actions/checkout@v7.0.1 diff --git a/.github/workflows/update-skills.yml b/.github/workflows/update-skills.yml index 147426457..38064424d 100644 --- a/.github/workflows/update-skills.yml +++ b/.github/workflows/update-skills.yml @@ -16,6 +16,7 @@ concurrency: jobs: update: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/scripts/docs-site/workflow-timeouts.test.mjs b/scripts/docs-site/workflow-timeouts.test.mjs new file mode 100644 index 000000000..ccec14d41 --- /dev/null +++ b/scripts/docs-site/workflow-timeouts.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const workflowsDir = fileURLToPath(new URL("../../.github/workflows/", import.meta.url)); + +const TARGETS = { + "update-skills.yml": ["update"], + "translate-incremental.yml": [ + "validate-workflow-shell", + "prepare", + "plan", + "translate", + "provider-preflight", + "finalize", + ], + "stale.yml": ["stale"], + "codeql.yml": ["analyze"], +}; + +// prepare sleeps the default 3600s cooldown. GitHub rejects timeout-minutes on +// jobs that only `uses:` a reusable workflow; those jobs are bounded in the callee. +const MIN_TIMEOUT = { + "translate-incremental.yml": { + prepare: 61, + }, +}; + +function loadWorkflow(name) { + return fs.readFileSync(path.join(workflowsDir, name), "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 jobTimeout(body) { + const timeout = body.match(/^\s+timeout-minutes:\s+(\d+)\s*$/m); + return timeout ? Number(timeout[1]) : null; +} + +test("every job in the four remaining workflows has timeout-minutes", () => { + for (const [name, expected] of Object.entries(TARGETS)) { + const jobs = jobBlocks(loadWorkflow(name)); + assert.deepEqual( + jobs.map((job) => job.name), + expected, + `${name} job list drifted`, + ); + for (const job of jobs) { + const minutes = jobTimeout(job.body); + // Caller jobs have `uses:` and no `runs-on`. Step-level `uses:` on local jobs + // must not be treated as a reusable-workflow call. + const isReusableCall = /^\s{4}uses:\s/m.test(job.body) && !/^\s{4}runs-on:/m.test(job.body); + if (isReusableCall) { + assert.equal( + minutes, + null, + `${name} job ${job.name} is a reusable call; timeout-minutes belongs in the callee`, + ); + continue; + } + assert.ok(minutes, `${name} job ${job.name} is missing timeout-minutes`); + assert.ok(minutes > 0, `${name} job ${job.name} timeout-minutes must be positive`); + const min = MIN_TIMEOUT[name]?.[job.name]; + if (min) { + assert.ok( + minutes >= min, + `${name} job ${job.name} timeout-minutes must be >= ${min}, got ${minutes}`, + ); + } + } + } +}); + +test("rg -L style: none of the four files lack timeout-minutes", () => { + const missing = Object.keys(TARGETS).filter((name) => !loadWorkflow(name).includes("timeout-minutes")); + assert.deepEqual(missing, [], `workflows still missing timeout-minutes: ${missing.join(",")}`); +}); + +test("incremental reusable callees bound their local jobs", () => { + const callees = { + "translate-shell-check-reusable.yml": { check: 15 }, + "translate-finalize-reusable.yml": { finalize: 60 }, + }; + for (const [name, expected] of Object.entries(callees)) { + const jobs = jobBlocks(loadWorkflow(name)); + for (const [jobName, min] of Object.entries(expected)) { + const job = jobs.find((entry) => entry.name === jobName); + assert.ok(job, `${name} is missing job ${jobName}`); + const minutes = jobTimeout(job.body); + assert.ok(minutes >= min, `${name} job ${jobName} timeout-minutes must be >= ${min}, got ${minutes}`); + } + } +});