Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
analyze:
name: analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/translate-finalize-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/translate-incremental.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/translate-shell-check-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/update-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ concurrency:
jobs:
update:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down
106 changes: 106 additions & 0 deletions scripts/docs-site/workflow-timeouts.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
});