diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef8c5a784..b73068e25b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: # workflow consumes one automatic job without dropping affected coverage. test: runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index dcd7a9756b..54130d0938 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -61,7 +61,7 @@ jobs: peer-native: name: Build direct-peer addon (${{ matrix.target }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 30 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -132,7 +132,7 @@ jobs: name: Build immutable tarball needs: peer-native runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 45 outputs: release_candidate_artifact_id: ${{ steps.release-candidate.outputs.artifact-id }} release_candidate_run_attempt: ${{ github.run_attempt }} @@ -177,7 +177,7 @@ jobs: name: Validate installed CLI ${{ matrix.name }} needs: build runs-on: ${{ matrix.runner }} - timeout-minutes: 15 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -236,7 +236,7 @@ jobs: if: github.event_name != 'pull_request' needs: build runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index b1b4540837..69572ba573 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -51,7 +51,7 @@ concurrency: jobs: audit: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 45 steps: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index f05ff46dff..5c86602d93 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -52,6 +52,7 @@ jobs: test: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 45 strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 27f00430de..bc99c6e019 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -106,7 +106,7 @@ concurrency: jobs: package: runs-on: windows-2025 - timeout-minutes: 90 + timeout-minutes: 45 defaults: run: shell: bash diff --git a/.github/workflows/runtime-host-owner-platform.yml b/.github/workflows/runtime-host-owner-platform.yml index 2a4457e945..2f1ecaeb4b 100644 --- a/.github/workflows/runtime-host-owner-platform.yml +++ b/.github/workflows/runtime-host-owner-platform.yml @@ -47,7 +47,7 @@ jobs: matrix: os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/runtime-host-peer-admission.yml b/.github/workflows/runtime-host-peer-admission.yml index 23102133b5..ed7e116e54 100644 --- a/.github/workflows/runtime-host-peer-admission.yml +++ b/.github/workflows/runtime-host-peer-admission.yml @@ -54,7 +54,7 @@ jobs: test: name: quality runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 342b328c80..aa64e11833 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -92,7 +92,7 @@ jobs: windows_recovery: name: windows_recovery runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 6ac97ef2b2..fc854df250 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -48,7 +48,7 @@ jobs: protocol: name: windows_sandbox_w0_protocol runs-on: windows-2025 - timeout-minutes: 25 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index d0ecae0d27..24a100987e 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -355,6 +355,45 @@ test('pull request triggers stay on an explicit allowlist', () => { ]); }); +test('every pull request lane holds a scarce runner for the same bounded time', () => { + // One tier, not per-lane values. The worst observed successful runs are 19 + // minutes (ci.yml) and 20 (release-windows-check), so 45 is about 2.3x the + // slowest lane: enough headroom for a cold cache and a flake retry, and far + // short of the 120 and 90 a hung job used to hold. A lane with no limit at + // all inherits GitHub's 360 and fails here. + // `pull_request` only: a `pull_request_target` lane reads the pull request + // rather than gating it, so it is not competing for a runner the author is + // waiting on and keeps its own tighter limit. + // Granularity is the file, not the job: a job inside a gating workflow that + // opts out of pull requests still carries the tier, because reading a job's + // `if:` would need the YAML parser this file cannot install. + const gates = readdirSync(WORKFLOW_DIR).filter((name) => + /\bpull_request\b/u.test(triggerBlock(name)), + ); + assert.ok(gates.length > 0, 'no pull request lane found'); + + for (const name of gates) { + // From `jobs:` on, with comment lines stripped, so prose above the triggers + // cannot be read as a job. + const workflow = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); + const start = workflow.indexOf('\njobs:'); + assert.ok(start >= 0, `${name}: no jobs block`); + const jobs = workflow.slice(start); + + const limits = [...jobs.matchAll(/^ {4}timeout-minutes: (\d+)$/gmu)].map((match) => match[1]); + // Counted by `runs-on`, one per job that consumes a runner, rather than by + // job id: a quoted id escapes an id pattern, and a two-space line inside a + // `run: |` block satisfies one. + const runners = [...jobs.matchAll(/^ {4}runs-on:/gmu)].length; + assert.ok(runners > 0, `${name}: no job consumes a runner`); + assert.deepEqual( + limits, + Array.from({ length: runners }, () => '45'), + name, + ); + } +}); + test('the recovery lane pairs its path filter with a nightly run and a main push', () => { // Read from the `on:` block with comments stripped, so documenting a trigger // cannot break its contract.