Files
orca/.github/scripts/check-pull-request-job-timeouts.mjs
T
8c78f82369 ci: bound every job in pull-request workflows with timeout-minutes (#28784)
* Bound every pull request CI job with timeout-minutes

* ci: bound the PR-triggered jobs added since #28784 with timeout-minutes

Sized from recent run times: containers 1.2 min max, mac-native-owner-smoke 5.5,
contract 1.2. Secret scan (1.8 max) and mobile_web_app (5.4 max) get roomier limits.

* ci: check PR job time limits in code_paths so workflow-only PRs are covered

A PR that changes only mobile.yml or cloud-verify.yml skips the root unit
shards, so the timeout test never ran for it. Run the check from code_paths,
which runs on every PR, as check-required-pr-context already does.

* ci: use a uniform 3h ceiling on newly bounded PR jobs to stop runaway jobs

The goal is only to stop jobs that would otherwise run to GitHub's 6h
default, not to tune per-job limits. The guard keeps requiring a limit
below 360 minutes on every PR job but no longer enforces a lower value.

---------

Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-11-05 19:59:47 -08:00

64 lines
2.3 KiB
JavaScript

import { readdirSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
const WORKFLOWS = '.github/workflows/'
// A job without timeout-minutes runs until GitHub cancels it at 360 minutes, holding the PR's run open.
const GITHUB_DEFAULT_TIMEOUT_MINUTES = 360
function runsOnPullRequests(workflow) {
const on = workflow.on
const events = typeof on === 'string' ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {})
return events.some((event) => event === 'pull_request' || event === 'pull_request_target')
}
function bounded(timeout) {
if (typeof timeout === 'number') {
return timeout > 0 && timeout < GITHUB_DEFAULT_TIMEOUT_MINUTES
}
// ssh-windows-hosts picks its bound per dispatch input; an expression still replaces the default.
return typeof timeout === 'string' && /^\$\{\{.+\}\}$/.test(timeout.trim())
}
export function findUnboundedPullRequestJobs(parseYaml) {
const failures = []
const visited = new Set()
const visit = (path) => {
if (visited.has(path)) {
return
}
visited.add(path)
for (const [id, job] of Object.entries(parseYaml(readFileSync(path, 'utf8')).jobs ?? {})) {
// A job calling a reusable workflow cannot set timeout-minutes, so the called workflow's jobs stand in.
if (job.uses) {
if (job.uses.startsWith(`./${WORKFLOWS}`)) {
visit(job.uses.slice(2))
}
continue
}
const name = `${path.slice(WORKFLOWS.length)} ${id}`
const timeout = job['timeout-minutes']
if (!bounded(timeout)) {
failures.push(`${name}: set timeout-minutes below ${GITHUB_DEFAULT_TIMEOUT_MINUTES}`)
}
}
}
for (const file of readdirSync(WORKFLOWS).filter((name) => /\.ya?ml$/.test(name))) {
if (runsOnPullRequests(parseYaml(readFileSync(`${WORKFLOWS}${file}`, 'utf8')))) {
visit(`${WORKFLOWS}${file}`)
}
}
return failures
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const yaml = globalThis.Bun?.YAML
if (!yaml) {
throw new Error('Run workflow timeout validation with the pinned Bun runtime.')
}
const failures = findUnboundedPullRequestJobs(yaml.parse)
if (failures.length > 0) {
throw new Error(`Pull request jobs need a working time limit:\n${failures.join('\n')}`)
}
console.log('Every pull request job has a time limit.')
}