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
63 changes: 63 additions & 0 deletions .github/scripts/check-pull-request-job-timeouts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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.')
}
4 changes: 4 additions & 0 deletions .github/workflows/cloud-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
name: Secret scan
runs-on: ubuntu-22.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -84,6 +85,7 @@ jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ubuntu-22.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -112,6 +114,7 @@ jobs:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
name: Container security (${{ matrix.app }})
runs-on: ubuntu-22.04
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -170,6 +173,7 @@ jobs:
test:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ubuntu-22.04
timeout-minutes: 180
services:
postgres:
image: postgres:16-alpine
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/computer-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ jobs:
matrix:
os: [ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 180
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -131,6 +132,7 @@ jobs:
linux:
if: github.event_name != 'pull_request'
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
Expand All @@ -150,6 +152,7 @@ jobs:
windows:
if: github.event_name != 'pull_request'
runs-on: windows-latest
timeout-minutes: 180
env:
ORCA_COMPUTER_E2E: '1'
steps:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/computer-macos-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
mac-native-owner-smoke:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: macos-15
timeout-minutes: 180
permissions:
contents: read
steps:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/computer-task-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
contract:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ jobs:
if: github.event_name == 'pull_request' && github.event.pull_request.draft != true
# Use ARM for pure Vitest; no x86 native toolchain is needed and Gemfile.lock’s ruby platform supports frozen install.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180

env:
# Freeze bundler to reject Gemfile drift before release jobs resolve different fastlane versions.
Expand Down Expand Up @@ -346,6 +347,7 @@ jobs:
if: github.event_name == 'push'
# Why ARM: the same pure-Node Vitest run `verify` makes on a pull request.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180

defaults:
run:
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
/docs/readme/
/.github/scripts/check-root-directory-entries.mjs
/.github/scripts/check-required-pr-context.mjs
/.github/scripts/check-pull-request-job-timeouts.mjs
/.github/workflows/
/config/.bun-version
/config/scripts/static-analysis/repository-layout/check-readme-local-links.mjs
Expand Down Expand Up @@ -112,6 +113,9 @@ jobs:
- name: Check required PR context ownership
run: bun .github/scripts/check-required-pr-context.mjs

- name: Check PR job time limits
run: bun .github/scripts/check-pull-request-job-timeouts.mjs

# Readiness changes eligibility for advisory tests, not the already-tested source.
- name: Find identical successful required checks
id: readiness
Expand Down Expand Up @@ -178,6 +182,7 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true'
# Free hosted ARM: no job on the gate's critical path waits for preflight.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180
outputs:
managed_hook_node18: ${{ steps.managed_hook_node18.outcome }}

Expand Down Expand Up @@ -601,6 +606,7 @@ jobs:
- git: '2.49.1'
image: alpine/git:v2.49.1
runs-on: ubuntu-24.04
timeout-minutes: 180

steps:
- name: Checkout
Expand Down Expand Up @@ -641,6 +647,7 @@ jobs:
needs.preflight.result == 'success'
# Why ARM: @openai/codex ships @openai/codex-linux-arm64.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180
env:
CODEX_CLI_VERSION: '0.150.1'
# Why a second pin: --no-daemon only exists from 0.156, and Orca's codex wrapper relies on it.
Expand Down Expand Up @@ -736,6 +743,7 @@ jobs:
needs.preflight.result == 'success'
# Use ARM for four-package pristine/patched rebuild byte comparison: output generated on Linux x64 reproduces on darwin-arm64.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180

steps:
- name: Checkout
Expand Down Expand Up @@ -914,6 +922,7 @@ jobs:
needs: [code_paths]
if: needs.code_paths.outputs.mobile_web_app == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 180

steps:
- name: Checkout
Expand Down Expand Up @@ -982,6 +991,7 @@ jobs:
needs.preflight.result == 'success'
# Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser.
runs-on: ubuntu-24.04-arm
timeout-minutes: 180

steps:
# Fetch tags/full history for newest-release skew; shallow unit shards cannot supply it.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-javascript-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
comparison:
needs: [bundle, measure]
runs-on: ubuntu-slim
timeout-minutes: 180
permissions:
contents: read
actions: read
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/unit-selection-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
if: ${{ !cancelled() }}
continue-on-error: true
runs-on: ${{ inputs.rebuild_plan && 'ubuntu-24.04' || 'ubuntu-slim' }}
timeout-minutes: 180
steps:
- uses: actions/checkout@v6
with:
Expand Down
38 changes: 38 additions & 0 deletions config/scripts/ci-pull-request-job-timeouts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { findUnboundedPullRequestJobs } from '../../.github/scripts/check-pull-request-job-timeouts.mjs'

it('bounds every job a pull request can start below the 6-hour default', () => {
expect(findUnboundedPullRequestJobs(parse)).toEqual([])
})

it('reports jobs without a working limit, including jobs of called workflows', () => {
const parseChanged = (source) => {
const workflow = parse(source)
if (workflow.jobs?.selection_evidence) {
delete workflow.jobs.selection_evidence['timeout-minutes']
}
if (workflow.jobs?.code_paths) {
workflow.jobs.code_paths['timeout-minutes'] = 360
}
return workflow
}
expect(findUnboundedPullRequestJobs(parseChanged)).toEqual([
'pr.yml code_paths: set timeout-minutes below 360',
'unit-selection-evidence.yml selection_evidence: set timeout-minutes below 360'
])
})

it('checks time limits even when workflow-only changes skip root tests', () => {
const job = parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.code_paths
const step = job.steps.find(
(candidate) => candidate.run === 'bun .github/scripts/check-pull-request-job-timeouts.mjs'
)
expect(step).toBeDefined()
expect(step.if).toBeUndefined()
const checkout = job.steps.find((candidate) => candidate.uses?.startsWith('actions/checkout@'))
expect(checkout.with['sparse-checkout'].split('\n')).toContain(
'/.github/scripts/check-pull-request-job-timeouts.mjs'
)
})
Loading