From e3167b5cc52145d96f02ed0c7d8799ce07ff79ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:52:52 +0000 Subject: [PATCH 1/3] Start fix for #1681 Assisted-by: Claude Code:claude-opus-5-5 From f826f8c4ff3b2aadb919c33b273ac549688fcdfe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:55:37 +0000 Subject: [PATCH 2/3] CI: start coverage and e2e-build before clippy Every CI run held coverage and e2e-build, the two chains on the ci-ok critical path, for ~1.4 min until clippy finished, only to read the reuse and pr_unchanged outputs that clippy set in its first second. Clippy failed in none of 256 PR runs in the last 7 days, so the gate cost time on every run and saved nothing. Move the two reuse checks into release-readiness, which already runs on every event in ~10 s with no needs, so no job is added. coverage and e2e-build now wait only for it; every other job that reads the outputs needs both. A red clippy still fails ci-ok, and merge-queue fail-fast still cancels the queue run on it. The scheduling tests now pin the new graph and check that every job reading needs..outputs lists in its needs. Fixes #1681 Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 180 +++++++++++++++------------- scripts/tests/test_ci_scheduling.py | 52 +++++--- 2 files changed, 130 insertions(+), 102 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e91f8b8d..3d07e3bcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ concurrency: # bypass. # MERGE-QUEUE REUSE: a push to main whose exact SHA already passed this # workflow in the merge queue (scripts/ci-reuse-merge-group.py, run by -# clippy) only compiles, to refresh the main-only caches, and skips the +# release-readiness) only compiles, to refresh the main-only caches, and skips the # test steps and test-only jobs the queue already ran. hosted-e2e still # runs. The full tier (e2e-full, yarn-berry-full, cargo-vex-matrix-full) # never runs on push unless CI_SCOPE is `full`; it is nightly and on @@ -76,54 +76,24 @@ concurrency: # else. # MERGE-MAIN REUSE: a pull_request push that only merged the base branch, # with the PR's own diff unchanged and the previous head green -# (scripts/ci-reuse-pr-head.py, run by clippy), runs only clippy, the cheap -# preflight jobs, test-release and hosted-e2e. The merge queue re-tests the -# PR on the latest main before it lands; PRs into other branches and the -# release/v5-prerelease PR never reuse. +# (scripts/ci-reuse-pr-head.py, run by release-readiness), runs only +# clippy, the cheap preflight jobs, test-release and hosted-e2e. The merge +# queue re-tests the PR on the latest main before it lands; PRs into other +# branches and the release/v5-prerelease PR never reuse. jobs: # Required independently of ci-ok so the merge queue sees a compile/lint - # failure immediately. Expensive jobs also depend on this preflight. + # failure immediately. Most expensive jobs also depend on this preflight; + # coverage and e2e-build don't (#1681), see release-readiness. clippy: if: github.event.pull_request.draft != true runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }} timeout-minutes: 20 - permissions: - contents: read - actions: read - outputs: - reuse: ${{ steps.merge-queue.outputs.reuse }} - pr_unchanged: ${{ steps.pr-head.outputs.unchanged }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - # Main remains the cache writer. Skip duplicate test execution only - # when this workflow passed in the merge queue on the identical SHA. - - name: Check merge-queue validation - id: merge-queue - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - GH_TOKEN: ${{ github.token }} - run: python3 scripts/ci-reuse-merge-group.py - - # A PR push that only merged the base branch, with the PR's own diff - # unchanged and the previous head green, skips the test jobs: the - # merge queue re-tests every entry on the latest main anyway (#1373). - # This job and hosted-e2e still run. Only PRs into main (the only - # queue), and never the v5 integration PR, whose full tier the queue - # does not run. - - name: Check for an unchanged PR diff - id: pr-head - if: github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref != 'release/v5-prerelease' - env: - GH_TOKEN: ${{ github.token }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_BASE_REF: ${{ github.base_ref }} - PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - run: python3 scripts/ci-reuse-pr-head.py - - name: Install Rust # rustup is pre-installed on GitHub-hosted runners. `toolchain # install` with no name reads rust-toolchain.toml in the repo root, @@ -158,8 +128,8 @@ jobs: # The napi addon is only ever loaded by Node, so cargo's own tests never # exercise its JS loader or the engine/provider boundary. node-addon: - if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: (github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 30 steps: @@ -188,7 +158,7 @@ jobs: run: node crates/socket-patch-node/npm/scripts/build-addon.mjs - name: Smoke-test addon - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' run: node --test crates/socket-patch-node/npm/test/smoke.mjs # Check the standalone installer, release scripts, and native installer @@ -285,15 +255,57 @@ jobs: # - any other PR that bumps the workspace version: the full gate — # CHANGELOG has a dated, non-empty section for the new version and the # tag doesn't already exist. + # + # This ~10 s job also owns the two reuse checks (`reuse`, `pr_unchanged`) + # every gated job reads. They used to live in clippy, so coverage and + # e2e-build, the two critical-path chains, sat ~1.4 min waiting for + # clippy only to read outputs set in its first second. Both now start + # off this job instead; clippy failed in none of 256 PR runs in 7 days, + # and merge-queue fail-fast still cancels the queue run on a red clippy + # (#1681). A failed release lint now also skips them, as a red clippy + # does the rest; ci-ok fails either way. Kept here rather than in a new + # job so a run starts no extra runner. release-readiness: if: github.event.pull_request.draft != true runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + timeout-minutes: 10 + permissions: + contents: read + actions: read + outputs: + reuse: ${{ steps.merge-queue.outputs.reuse }} + pr_unchanged: ${{ steps.pr-head.outputs.unchanged }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + # Main remains the cache writer. Skip duplicate test execution only + # when this workflow passed in the merge queue on the identical SHA. + - name: Check merge-queue validation + id: merge-queue + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci-reuse-merge-group.py + + # A PR push that only merged the base branch, with the PR's own diff + # unchanged and the previous head green, skips the test jobs: the + # merge queue re-tests every entry on the latest main anyway (#1373). + # This job and hosted-e2e still run. Only PRs into main (the only + # queue), and never the v5 integration PR, whose full tier the queue + # does not run. + - name: Check for an unchanged PR diff + id: pr-head + if: github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref != 'release/v5-prerelease' + env: + GH_TOKEN: ${{ github.token }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_REF: ${{ github.base_ref }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + run: python3 scripts/ci-reuse-pr-head.py + - name: Lint release readiness env: EVENT_NAME: ${{ github.event_name }} @@ -328,8 +340,8 @@ jobs: bash scripts/release-lint.sh --sync-only test: - if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] # No ubuntu-latest leg: `coverage` runs this same `cargo test # --workspace` (debug, default features, plus Go and vexctl) on ubuntu, # instrumented, and fails on any test failure. A plain ubuntu leg ran @@ -349,7 +361,7 @@ jobs: - os: ${{ github.event_name == 'pull_request' && 'macos-latest' || '' }} # One compile-only cache writer per OS is enough for an already # tested SHA. - - shard: ${{ needs.clippy.outputs.reuse == 'true' && 2 || 0 }} + - shard: ${{ needs.release-readiness.outputs.reuse == 'true' && 2 || 0 }} runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} timeout-minutes: 50 env: @@ -382,11 +394,11 @@ jobs: run: cargo build --workspace - name: Warm the test cache after merge-queue validation - if: needs.clippy.outputs.reuse == 'true' + if: needs.release-readiness.outputs.reuse == 'true' run: cargo test --locked --workspace --no-run - name: Install Go (for vexctl) - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' id: go # The `vex` subcommand emits OpenVEX documents; tests/e2e_vex.rs # validates the output with vexctl when it's on PATH. vexctl is @@ -410,14 +422,14 @@ jobs: # version, the Go toolchain and the runner, so it is cached under # exactly those. Saved from main only, like the cargo cache. id: vexctl-cache - if: needs.clippy.outputs.reuse != 'true' && runner.os == 'macOS' + if: needs.release-readiness.outputs.reuse != 'true' && runner.os == 'macOS' uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ${{ runner.temp }}/vexctl-bin key: vexctl-${{ env.VEXCTL_VERSION }}-go${{ steps.go.outputs.go-version }}-${{ runner.os }}-${{ runner.arch }} - name: Install vexctl - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # Linux / Windows: the v0.3.0 release binary, checked against the # sha256 pinned here (from the release's vexctl_checksums.txt). # Compiling it took ~80s on ubuntu and ~180s on windows, the @@ -481,7 +493,7 @@ jobs: - name: Save vexctl (macOS) if: >- - needs.clippy.outputs.reuse != 'true' + needs.release-readiness.outputs.reuse != 'true' && runner.os == 'macOS' && github.ref == 'refs/heads/main' && steps.vexctl-cache.outputs.cache-hit != 'true' @@ -491,7 +503,7 @@ jobs: key: ${{ steps.vexctl-cache.outputs.cache-primary-key }} - name: Run tests - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # Default features only: `--all-features` would also RUN the # docker-e2e suites, which soft-skip as "ok" here (no images, and # macOS/Windows have no Docker) — fake greens hiding a broken @@ -565,8 +577,8 @@ jobs: run: python3 scripts/ci-test-shard.py "$TEST_SHARD" "$TEST_SHARD_COUNT" --locked --profile ci-release coverage: - if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: (github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: release-readiness # Code coverage via cargo-llvm-cov (LLVM source-based instrumentation). # Reports as a markdown table in the job summary and uploads the raw # lcov.info file as a workflow artifact. No threshold gating — the @@ -606,14 +618,14 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Warm the coverage cache after merge-queue validation - if: needs.clippy.outputs.reuse == 'true' + if: needs.release-readiness.outputs.reuse == 'true' run: | cargo llvm-cov show-env --sh > "$RUNNER_TEMP/coverage-env.sh" source "$RUNNER_TEMP/coverage-env.sh" cargo test --locked --workspace --no-run - name: Install Go (for vexctl and the real-go suites) - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # This job is the Linux leg of `test` (see there): same Go pin. uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -621,7 +633,7 @@ jobs: cache: false - name: Install vexctl - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # The v0.3.0 Linux release binary, as in `test`'s vexctl step. env: VEXCTL_VERSION: v0.3.0 @@ -638,7 +650,7 @@ jobs: echo "$dir" >> "$GITHUB_PATH" - name: Run tests with coverage - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # Two-step pattern: `--no-report` runs instrumented tests and # collects raw profiles, then one `report` exports LCOV. The # summary is derived from that file (scripts/ci-lcov-summary.py) @@ -676,7 +688,7 @@ jobs: python3 scripts/ci-lcov-summary.py coverage-host.lcov | tee coverage-summary.txt - name: Publish coverage summary to job summary - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' # Render the per-file LCOV line/function/branch totals as a fenced # block in the GitHub Actions job summary so reviewers don't # need to crack open the artifact for a quick look. @@ -693,7 +705,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload host LCOV artifact - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' uses: ./.github/actions/upload-artifact with: name: coverage-host @@ -704,8 +716,8 @@ jobs: # Dockerfile.base compiles the full-LTO release binary inside Docker, with # no cache. Build it once per run and hand the image to every docker leg. docker-base: - if: ((github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease')) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: ((github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease')) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-22.04' || 'depot-ubuntu-22.04-4' }} timeout-minutes: 30 permissions: @@ -994,10 +1006,10 @@ jobs: # test binaries directly from the same checkout path, so `CARGO_BIN_EXE_*` # and `CARGO_MANIFEST_DIR` resolve as they did under `cargo test`. e2e-build: - if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: (github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: release-readiness outputs: - reuse: ${{ needs.clippy.outputs.reuse }} + reuse: ${{ needs.release-readiness.outputs.reuse }} strategy: fail-fast: false matrix: @@ -1032,14 +1044,14 @@ jobs: - name: Bundle the binaries the legs run # A reused push runs no leg, except e2e-full and cargo-vex-matrix-full # on ubuntu when CI_SCOPE is `full`. - if: needs.clippy.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') + if: needs.release-readiness.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') shell: bash env: BUNDLE_OS: ${{ matrix.os }} run: python3 scripts/ci-e2e-bundle.py --os "$BUNDLE_OS" --cargo-json target-build.json --dest target/e2e-bin - name: Compress the e2e binaries - if: needs.clippy.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') + if: needs.release-readiness.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') # Compress the bundle as one stream so identical Rust code across # test binaries shares a dictionary. Zstd is on every runner image. # The 128 MiB window cuts the Linux artifact from ~200 MB to ~94 MB; @@ -1050,7 +1062,7 @@ jobs: tar -C target/e2e-bin -cf - . | zstd -q -f -10 --long=27 -o target/e2e-bin.tar.zst - uses: ./.github/actions/upload-artifact - if: needs.clippy.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') + if: needs.release-readiness.outputs.reuse != 'true' || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full') with: name: e2e-bin-${{ matrix.os }} path: target/e2e-bin.tar.zst @@ -1097,10 +1109,10 @@ jobs: # Builds the Windows test binaries for e2e-windows and # cargo-vex-matrix-windows. The lean merge queue runs # windows-compile-check instead. - if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] outputs: - reuse: ${{ needs.clippy.outputs.reuse }} + reuse: ${{ needs.release-readiness.outputs.reuse }} strategy: fail-fast: false matrix: @@ -1112,9 +1124,9 @@ jobs: e2e-build-macos: if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - needs: clippy + needs: [release-readiness, clippy] outputs: - reuse: ${{ needs.clippy.outputs.reuse }} + reuse: ${{ needs.release-readiness.outputs.reuse }} strategy: fail-fast: false matrix: @@ -2216,9 +2228,9 @@ jobs: # queue's critical path. # Dropping that `needs` also dropped the draft skip it inherited, so # gate on draft here directly (push/merge_group/schedule still run). - # Only wait for the clippy preflight. - if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + # Only wait for the clippy and release-readiness preflights. + if: (github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 40 strategy: @@ -2260,9 +2272,9 @@ jobs: # queue's critical path. # Dropping that `needs` also dropped the draft skip it inherited, so # gate on draft here directly (push/merge_group/schedule still run). - # Only wait for the clippy preflight. - if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + # Only wait for the clippy and release-readiness preflights. + if: (github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] strategy: fail-fast: false matrix: @@ -2302,9 +2314,9 @@ jobs: # nightly, not on every PR push, so PRs stop queueing on the small # macOS runner pool. yarn-berry-e2e-macos: - if: (github.event_name != 'pull_request' && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: (github.event_name != 'pull_request' && needs.release-readiness.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) - needs: clippy + needs: [release-readiness, clippy] strategy: fail-fast: false matrix: @@ -2499,9 +2511,9 @@ jobs: # queue's critical path. # Dropping that `needs` also dropped the draft skip it inherited, so # gate on draft here directly (push/merge_group/schedule still run). - # Only wait for the clippy preflight. - if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true' - needs: clippy + # Only wait for the clippy and release-readiness preflights. + if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.release-readiness.outputs.pr_unchanged != 'true' + needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 30 steps: @@ -2512,7 +2524,7 @@ jobs: - name: Install Rust run: scripts/rustup-retry.sh toolchain install - name: Pull the old cargos under test - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' run: | docker pull rust:1.41-slim docker pull rust:1.56-slim @@ -2522,10 +2534,10 @@ jobs: shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} - name: Warm the shared Linux dev cache after merge-queue validation - if: needs.clippy.outputs.reuse == 'true' + if: needs.release-readiness.outputs.reuse == 'true' run: cargo test --locked -p socket-patch-cli --test e2e_vendor_cargo_build --no-run - name: Vendored manifest [patch] on old cargo - if: needs.clippy.outputs.reuse != 'true' + if: needs.release-readiness.outputs.reuse != 'true' shell: bash env: SOCKET_PATCH_CARGO_E2E_REQUIRED: '1' diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py index 790ea0013..21b616c88 100644 --- a/scripts/tests/test_ci_scheduling.py +++ b/scripts/tests/test_ci_scheduling.py @@ -45,10 +45,16 @@ def test_expensive_jobs_cannot_start_after_a_failed_preflight(self): # on its critical path, so it starts in parallel with clippy; # merge-queue fail-fast cancels it when clippy fails. parallel = {"windows-compile-check"} + # coverage and e2e-build head the two critical-path chains and wait + # only for the ~10 s release-readiness job, not clippy (#1681). + early = {"coverage", "e2e-build"} + for job in early: + self.assertEqual(dependencies(job), {"release-readiness"}) for job in set(JOBS) - cheap: with self.subTest(job=job): if job not in parallel: - self.assertIn("clippy", ancestors(job)) + self.assertTrue(ancestors(job) & {"clippy", "release-readiness"}) + self.assertTrue(({job} | ancestors(job)) & ({"clippy"} | early)) # Job-level always() would defeat failure/skip propagation. self.assertFalse(any(line.startswith(" if:") and "always()" in line for line in JOBS[job])) @@ -66,7 +72,8 @@ def test_consumers_wait_only_for_their_own_os_build(self): self.assertEqual(dependencies(job), {builder}) self.assertEqual({row["os"] for row in reader.matrix_include(JOBS[job])}, {os_name}) self.assertIn(f" os: [{os_name}]", JOBS[builder]) - self.assertEqual(ancestors(job), {builder, "clippy"}) + preflight = {"release-readiness"} if builder == "e2e-build" else {"release-readiness", "clippy"} + self.assertEqual(ancestors(job), {builder} | preflight) def test_os_builds_use_the_same_artifact_contract(self): producer = "\n".join(JOBS["e2e-build"]) @@ -107,7 +114,7 @@ def test_lean_queue_type_checks_windows_and_full_scope_builds_it(self): build = next(line for line in JOBS["e2e-build-windows"] if line.startswith(" if:")) self.assertEqual(build, " if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full'" " || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))" - " && needs.clippy.outputs.pr_unchanged != 'true'") + " && needs.release-readiness.outputs.pr_unchanged != 'true'") def test_reused_push_keeps_cache_writers_and_unqueued_jobs(self): # A push whose SHA passed the merge queue still compiles (main is the @@ -124,31 +131,40 @@ def test_reused_push_keeps_cache_writers_and_unqueued_jobs(self): if name.startswith(("Bundle ", "Compress ")) or "upload-artifact" in body] self.assertEqual(len(ship), 3) for body in ship: - self.assertIn("if: needs.clippy.outputs.reuse != 'true'" + self.assertIn("if: needs.release-readiness.outputs.reuse != 'true'" " || (matrix.os == 'ubuntu-latest' && vars.CI_SCOPE == 'full')", body) for job in ("docker-base", "yarn-classic-matrix", "yarn-berry-e2e", "yarn-berry-e2e-macos"): with self.subTest(job=job): condition = next(line for line in JOBS[job] if line.startswith(" if:")) - self.assertIn("needs.clippy.outputs.reuse != 'true'", condition) + self.assertIn("needs.release-readiness.outputs.reuse != 'true'", condition) for family in ("e2e", "cargo-vex-matrix"): for suffix in ("", "-windows", "-macos"): build = "e2e-build" + suffix with self.subTest(job=family + suffix): condition = next(line for line in JOBS[family + suffix] if line.startswith(" if:")) self.assertIn(f"needs.{build}.outputs.reuse != 'true'", condition) - self.assertIn(" reuse: ${{ needs.clippy.outputs.reuse }}", JOBS[build]) + self.assertIn(" reuse: ${{ needs.release-readiness.outputs.reuse }}", JOBS[build]) self.assertIn("needs.e2e-build.outputs.reuse != 'true'", next(line for line in JOBS["e2e-extended"] if line.startswith(" if:"))) for job in ("test", "coverage", "cargo-old-toolchains"): with self.subTest(job=job): warm = [body for name, body in reader.steps(JOBS[job]) if name.startswith("Warm ")] self.assertEqual(len(warm), 1) - self.assertIn("if: needs.clippy.outputs.reuse == 'true'", warm[0]) + self.assertIn("if: needs.release-readiness.outputs.reuse == 'true'", warm[0]) self.assertIn("--no-run", warm[0]) - clippy = "\n".join(JOBS["clippy"]) - self.assertIn("actions: read", clippy) - self.assertIn("reuse: ${{ steps.merge-queue.outputs.reuse }}", clippy) - self.assertIn("python3 scripts/ci-reuse-merge-group.py", clippy) + owner = "\n".join(JOBS["release-readiness"]) + self.assertIn("actions: read", owner) + self.assertIn("reuse: ${{ steps.merge-queue.outputs.reuse }}", owner) + self.assertIn("python3 scripts/ci-reuse-merge-group.py", owner) + self.assertNotIn("ci-reuse-", "\n".join(JOBS["clippy"])) + + def test_every_needs_output_reader_needs_that_job(self): + # `needs..outputs` is empty unless is a direct `needs:` + # entry, which would silently turn every reuse gate off. + for job in JOBS: + for parent in set(re.findall(r"needs\.([a-z0-9-]+)\.outputs", "\n".join(JOBS[job]))): + with self.subTest(job=job, parent=parent): + self.assertIn(parent, dependencies(job)) def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): # A PR push that only merged main keeps clippy and the cheap preflight @@ -167,11 +183,11 @@ def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): for job in JOBS: condition = next((line for line in JOBS[job] if line.startswith(" if:")), "") with self.subTest(job=job): - self.assertEqual("needs.clippy.outputs.pr_unchanged != 'true'" in condition, + self.assertEqual("needs.release-readiness.outputs.pr_unchanged != 'true'" in condition, job in gated) if job in gated: - self.assertTrue(condition.endswith(") && needs.clippy.outputs.pr_unchanged != 'true'")) - pr_tier = {job for job in JOBS if "clippy" in ancestors(job)} + self.assertTrue(condition.endswith(") && needs.release-readiness.outputs.pr_unchanged != 'true'")) + pr_tier = {job for job in JOBS if ancestors(job) & {"clippy", "release-readiness"}} for job in sorted(pr_tier - gated - {"hosted-e2e", "test-release"}): condition = next(line for line in JOBS[job] if line.startswith(" if:")) with self.subTest(job=job): @@ -179,11 +195,11 @@ def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): # only on the v5 integration PR, which never reuses (below). self.assertTrue(ancestors(job) & gated or condition == full_tier or condition.startswith(never_on_pr) and "head_ref" not in condition, job) - clippy = "\n".join(JOBS["clippy"]) - self.assertIn("pr_unchanged: ${{ steps.pr-head.outputs.unchanged }}", clippy) - self.assertIn("python3 scripts/ci-reuse-pr-head.py", clippy) + owner = "\n".join(JOBS["release-readiness"]) + self.assertIn("pr_unchanged: ${{ steps.pr-head.outputs.unchanged }}", owner) + self.assertIn("python3 scripts/ci-reuse-pr-head.py", owner) self.assertIn("if: github.event_name == 'pull_request' && github.base_ref == 'main'" - " && github.head_ref != 'release/v5-prerelease'", clippy) + " && github.head_ref != 'release/v5-prerelease'", owner) def test_row_reader_preserves_both_os_siblings(self): jobs = reader.jobs("""jobs: From e312474f86c269ba854c5e50f0383fdf787c7fe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:59:22 +0000 Subject: [PATCH 3/3] CI: keep a red clippy from running the e2e legs With e2e-build no longer waiting for clippy, a PR or main push with a clippy error would still run every e2e and cargo-vex leg. Those legs now wait for clippy themselves, so a red clippy wastes only coverage and e2e-build. clippy finishes before e2e-build on a normal run, so the legs start no later than before. Also fix the comments the move left stale and make the scheduling test name exactly which jobs may skip clippy. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 27 ++++++++++++++++----------- scripts/tests/test_ci_scheduling.py | 14 ++++++++------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d07e3bcb..55b5679e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,9 @@ concurrency: # bypass. # MERGE-QUEUE REUSE: a push to main whose exact SHA already passed this # workflow in the merge queue (scripts/ci-reuse-merge-group.py, run by -# release-readiness) only compiles, to refresh the main-only caches, and skips the -# test steps and test-only jobs the queue already ran. hosted-e2e still -# runs. The full tier (e2e-full, yarn-berry-full, cargo-vex-matrix-full) +# release-readiness) only compiles, to refresh the main-only caches, and +# skips the test steps and test-only jobs the queue already ran. hosted-e2e +# still runs. The full tier (e2e-full, yarn-berry-full, cargo-vex-matrix-full) # never runs on push unless CI_SCOPE is `full`; it is nightly and on # dispatch. API errors, missing evidence and direct pushes run everything # else. @@ -82,8 +82,9 @@ concurrency: # branches and the release/v5-prerelease PR never reuse. jobs: # Required independently of ci-ok so the merge queue sees a compile/lint - # failure immediately. Most expensive jobs also depend on this preflight; - # coverage and e2e-build don't (#1681), see release-readiness. + # failure immediately. Every expensive job except coverage and e2e-build + # (#1681, see release-readiness) also waits for it, the e2e and cargo-vex + # legs through their own `needs`. clippy: if: github.event.pull_request.draft != true runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }} @@ -123,6 +124,8 @@ jobs: # Clippy above checks the shipped targets. This catches errors in # cfg(test), integration tests and feature-gated targets without # linking hundreds of executables or waiting for the E2E fan-out. + # coverage and e2e-build start alongside it (#1681); the legs that + # consume e2e-build's binaries still wait for it. run: cargo check --locked --workspace --all-targets --all-features # The napi addon is only ever loaded by Node, so cargo's own tests never @@ -293,7 +296,7 @@ jobs: # A PR push that only merged the base branch, with the PR's own diff # unchanged and the previous head green, skips the test jobs: the # merge queue re-tests every entry on the latest main anyway (#1373). - # This job and hosted-e2e still run. Only PRs into main (the only + # clippy, this job and hosted-e2e still run. Only PRs into main (the only # queue), and never the v5 integration PR, whose full tier the queue # does not run. - name: Check for an unchanged PR diff @@ -1139,7 +1142,9 @@ jobs: e2e: if: needs.e2e-build.outputs.reuse != 'true' # These jobs consume e2e-build's binaries and can run alongside unit tests. - needs: [e2e-build] + # e2e-build no longer waits for clippy (#1681), so wait for it here: a + # red clippy still skips the fan-out. + needs: [e2e-build, clippy] strategy: fail-fast: false matrix: @@ -1859,7 +1864,7 @@ jobs: # LEAN SCOPE note at the top of `jobs:`. e2e-extended: if: needs.e2e-build.outputs.reuse != 'true' && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - needs: [e2e-build] + needs: [e2e-build, clippy] strategy: fail-fast: false matrix: @@ -2079,7 +2084,7 @@ jobs: # Nightly, on dispatch (release-qa) and on v5 landings only, like # e2e-docker; CI_SCOPE=full also runs it on pushes to main. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && vars.CI_SCOPE == 'full') || github.head_ref == 'release/v5-prerelease' - needs: [e2e-build] + needs: [e2e-build, clippy] strategy: fail-fast: false matrix: @@ -2357,7 +2362,7 @@ jobs: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) # e2e-build only (its binaries; only the matching OS build is needed); # see yarn-classic-matrix on test/coverage. - needs: [e2e-build] + needs: [e2e-build, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these @@ -2473,7 +2478,7 @@ jobs: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) # Same gate as e2e-full. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && vars.CI_SCOPE == 'full') || github.head_ref == 'release/v5-prerelease' - needs: [e2e-build] + needs: [e2e-build, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py index 21b616c88..e49271e89 100644 --- a/scripts/tests/test_ci_scheduling.py +++ b/scripts/tests/test_ci_scheduling.py @@ -47,14 +47,14 @@ def test_expensive_jobs_cannot_start_after_a_failed_preflight(self): parallel = {"windows-compile-check"} # coverage and e2e-build head the two critical-path chains and wait # only for the ~10 s release-readiness job, not clippy (#1681). + # Everything downstream of them still waits for clippy. early = {"coverage", "e2e-build"} for job in early: self.assertEqual(dependencies(job), {"release-readiness"}) for job in set(JOBS) - cheap: with self.subTest(job=job): - if job not in parallel: - self.assertTrue(ancestors(job) & {"clippy", "release-readiness"}) - self.assertTrue(({job} | ancestors(job)) & ({"clippy"} | early)) + if job not in parallel | early: + self.assertIn("clippy", ancestors(job)) # Job-level always() would defeat failure/skip propagation. self.assertFalse(any(line.startswith(" if:") and "always()" in line for line in JOBS[job])) @@ -69,11 +69,13 @@ def test_consumers_wait_only_for_their_own_os_build(self): job = family + suffix builder = "e2e-build" + (suffix if suffix in ("-windows", "-macos") else "") with self.subTest(job=job): - self.assertEqual(dependencies(job), {builder}) + # The ubuntu build skips clippy (#1681), so its consumers + # wait for clippy themselves. + extra = {"clippy"} if builder == "e2e-build" else set() + self.assertEqual(dependencies(job), {builder} | extra) self.assertEqual({row["os"] for row in reader.matrix_include(JOBS[job])}, {os_name}) self.assertIn(f" os: [{os_name}]", JOBS[builder]) - preflight = {"release-readiness"} if builder == "e2e-build" else {"release-readiness", "clippy"} - self.assertEqual(ancestors(job), {builder} | preflight) + self.assertEqual(ancestors(job), {builder, "release-readiness", "clippy"}) def test_os_builds_use_the_same_artifact_contract(self): producer = "\n".join(JOBS["e2e-build"])