From ed1e6d0b1b7b6e8815085d29a50ca6fb86bd20a7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 27 Aug 2026 02:51:32 +0800 Subject: [PATCH] ci: hold every pull request lane to one 45-minute limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limits were inherited rather than chosen: 120 on `ci.yml` against a 19-minute worst observed successful run, 90 on `release-windows-check` against 20, then 30, 25, 15 and 5 elsewhere, and none at all on `gitoxide-helper-admission`, which left its Windows and macOS matrix on GitHub's 360-minute default. A hung job therefore held a scarce runner for hours after any real run would have finished. 45 is about 2.3x the worst observed run of the slowest lane, which keeps the cold-cache and flake-retry headroom a 30-minute limit would not. Per-lane tiers buy nothing here: the cheap lanes rarely run at all, so raising their worst case costs far less than the two expensive ones save. `ci-test-plan.test.mjs` now reads the limit off every pull-request-triggered workflow, counting jobs by `runs-on` rather than by job id — a quoted id escapes an id pattern, and a two-space line inside a `run: |` block satisfies one — so a new lane cannot land without a limit and an existing lane cannot drift back to its own value. Generated-by: Claude Opus 5 (Claude Code) --- .github/workflows/ci.yml | 2 +- .github/workflows/cli-package-validation.yml | 8 ++-- .github/workflows/dependency-audit.yml | 2 +- .../workflows/gitoxide-helper-admission.yml | 1 + .github/workflows/release-windows-check.yml | 2 +- .../workflows/runtime-host-owner-platform.yml | 2 +- .../workflows/runtime-host-peer-admission.yml | 2 +- .github/workflows/windows-recovery.yml | 2 +- .github/workflows/windows-sandbox-w0.yml | 2 +- scripts/ci-test-plan.test.mjs | 39 +++++++++++++++++++ 10 files changed, 51 insertions(+), 11 deletions(-) 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.