From 1943512f95f30fd53bf98fc9dcdf6d446ed5356e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:59:46 +0000 Subject: [PATCH 1/2] CI: let a dispatch run one OS's jobs only A fix for a Windows-only nightly failure is checked with a manual dispatch of CI, which re-ran every Linux and macOS nightly-tier job too (~97 Linux and ~41 macOS job-minutes per run), although the fix's PR run and the merge queue already cover Linux. Add an `os` dispatch input (all, linux, windows, macos; default all). Each job's `if:` gets a term that is always true off dispatch, so pull_request, merge_group, push and the nightly run exactly what they ran before. clippy and release-readiness still run on every dispatch because every OS leg waits on them. Fixes #1818 Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 47 +++++++++------ scripts/tests/test_ci_e2e_tiers.py | 10 +++- scripts/tests/test_ci_scheduling.py | 92 ++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e06020ea..9da16927f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,17 @@ on: type: choice default: auto options: [auto, force, skip] + os: + # Run only one OS's jobs (plus the clippy and release-readiness + # preflights every leg waits on). A fix for a Windows-only nightly + # failure dispatches `-f os=windows` instead of re-running ~95 Linux + # and macOS jobs its PR run and the merge queue already cover. Only + # a dispatch sets inputs.os; on every other event it is empty, so + # the `inputs.os != ...` terms below are always true there. + description: 'Only run jobs for this OS (all = the full nightly matrix)' + type: choice + default: all + options: [all, linux, windows, macos] permissions: contents: read @@ -144,7 +155,7 @@ 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.release-readiness.outputs.pr_unchanged != 'true' + if: ((github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true') && (inputs.os != 'windows' && inputs.os != 'macos') needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 30 @@ -182,7 +193,7 @@ jobs: # Check the standalone installer, release scripts, and native installer # test harnesses. lint-ecosystems: - if: github.event.pull_request.draft != true + if: (github.event.pull_request.draft != true) && (inputs.os != 'windows' && inputs.os != 'macos') runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 10 steps: @@ -362,7 +373,7 @@ 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.release-readiness.outputs.pr_unchanged != 'true' + 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') && (inputs.os != 'linux') needs: [release-readiness, clippy] # No ubuntu-latest leg: `coverage` runs this same `cargo test # --workspace` (debug, default features, plus Go and vexctl) on ubuntu, @@ -381,6 +392,8 @@ jobs: exclude: # macOS legs run on main, the merge queue and nightly, not per PR push. - os: ${{ github.event_name == 'pull_request' && 'macos-latest' || '' }} + # `workflow_dispatch -f os=windows|macos` runs that OS's legs only. + - os: ${{ inputs.os == 'windows' && 'macos-latest' || inputs.os == 'macos' && 'windows-latest' || '' }} # One compile-only cache writer per OS is enough for an already # tested SHA. - shard: ${{ needs.release-readiness.outputs.reuse == 'true' && 2 || 0 }} @@ -559,7 +572,7 @@ jobs: # Each PR and main push runs the complete release-mode suite. The merge # queue already skips this job because each constituent PR ran it, so # an unchanged merge-main PR push (pr_unchanged) still runs it too. - if: (github.event.pull_request.draft != true && github.event_name != 'merge_group') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: ((github.event.pull_request.draft != true && github.event_name != 'merge_group') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && (inputs.os != 'windows' && inputs.os != 'macos') needs: clippy runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }} strategy: @@ -607,7 +620,7 @@ 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.release-readiness.outputs.pr_unchanged != 'true' + if: ((github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true') && (inputs.os != 'windows' && inputs.os != 'macos') 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 @@ -765,7 +778,7 @@ 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.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' + 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') && (inputs.os != 'windows' && inputs.os != 'macos') needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-22.04' || 'depot-ubuntu-22.04-4' }} timeout-minutes: 30 @@ -1018,7 +1031,7 @@ jobs: retention-days: 30 dispatch-tests: - if: github.event.pull_request.draft != true + if: (github.event.pull_request.draft != true) && (inputs.os != 'windows' && inputs.os != 'macos') runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 10 steps: @@ -1055,7 +1068,7 @@ 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.release-readiness.outputs.pr_unchanged != 'true' + if: ((github.event.pull_request.draft != true) && needs.release-readiness.outputs.pr_unchanged != 'true') && (inputs.os != 'windows' && inputs.os != 'macos') needs: release-readiness outputs: reuse: ${{ needs.release-readiness.outputs.reuse }} @@ -1162,7 +1175,7 @@ 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.release-readiness.outputs.pr_unchanged != 'true' + 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') && (inputs.os != 'linux' && inputs.os != 'macos') needs: [release-readiness, clippy] outputs: reuse: ${{ needs.release-readiness.outputs.reuse }} @@ -1176,7 +1189,7 @@ jobs: steps: *e2e-build-steps e2e-build-macos: - if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: ((github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && (inputs.os != 'linux' && inputs.os != 'windows') needs: [release-readiness, clippy] outputs: reuse: ${{ needs.release-readiness.outputs.reuse }} @@ -2317,7 +2330,7 @@ jobs: # 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 and release-readiness preflights. - if: (github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true' + if: ((github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true') && (inputs.os != 'windows' && inputs.os != 'macos') needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 40 @@ -2363,7 +2376,7 @@ jobs: # 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 and release-readiness preflights. - if: (github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true' + if: ((github.event.pull_request.draft != true && needs.release-readiness.outputs.reuse != 'true') && needs.release-readiness.outputs.pr_unchanged != 'true') && (inputs.os != 'macos') needs: [release-readiness, clippy] strategy: fail-fast: false @@ -2372,7 +2385,7 @@ jobs: # `10c0/` writer, current, and 4.12.0 on macOS / Windows. The rest # of the spread (4.6.0, 4.12.0 on ubuntu) is yarn-berry-full. # LEAN SCOPE: the newest Linux release only, unless CI_SCOPE=full / nightly. - include: ${{ fromJSON((vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '[{"os":"ubuntu-latest","yarn":"4.0.2"},{"os":"ubuntu-latest","yarn":"4.1.0"},{"os":"ubuntu-latest","yarn":"4.18.0"},{"os":"windows-latest","yarn":"4.12.0"}]' || '[{"os":"ubuntu-latest","yarn":"4.18.0"}]') }} + include: ${{ fromJSON((vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && (inputs.os == 'windows' && '[{"os":"windows-latest","yarn":"4.12.0"}]' || inputs.os == 'linux' && '[{"os":"ubuntu-latest","yarn":"4.0.2"},{"os":"ubuntu-latest","yarn":"4.1.0"},{"os":"ubuntu-latest","yarn":"4.18.0"}]' || '[{"os":"ubuntu-latest","yarn":"4.0.2"},{"os":"ubuntu-latest","yarn":"4.1.0"},{"os":"ubuntu-latest","yarn":"4.18.0"},{"os":"windows-latest","yarn":"4.12.0"}]') || '[{"os":"ubuntu-latest","yarn":"4.18.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: 30 steps: &yarn-berry-steps @@ -2406,7 +2419,7 @@ 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.release-readiness.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')) && (inputs.os != 'linux' && inputs.os != 'windows') name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) needs: [release-readiness, clippy] strategy: @@ -2424,7 +2437,7 @@ jobs: yarn-berry-full: name: yarn-berry ${{ matrix.yarn }} (${{ 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' + if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && vars.CI_SCOPE == 'full') || github.head_ref == 'release/v5-prerelease') && (inputs.os != 'windows' && inputs.os != 'macos') needs: clippy strategy: fail-fast: false @@ -2604,7 +2617,7 @@ jobs: # 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 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' + 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') && (inputs.os != 'windows' && inputs.os != 'macos') needs: [release-readiness, clippy] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 30 @@ -2676,7 +2689,7 @@ jobs: # ---------------------------------------------------------------------- hosted-e2e: name: hosted-e2e # may be a required check; do not rename - if: github.event.pull_request.draft != true + if: (github.event.pull_request.draft != true) && (inputs.os != 'windows' && inputs.os != 'macos') needs: clippy runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} permissions: diff --git a/scripts/tests/test_ci_e2e_tiers.py b/scripts/tests/test_ci_e2e_tiers.py index e81f046c0..6bbc9dba6 100644 --- a/scripts/tests/test_ci_e2e_tiers.py +++ b/scripts/tests/test_ci_e2e_tiers.py @@ -77,9 +77,13 @@ def test_full_jobs_run_nightly_only_and_share_steps(self): # Nightly, dispatch (release-qa) and v5 landings, like # e2e-docker: not on PRs, the merge queue or a lean push to # main, where its 41 legs ran on every landing. - self.assertIn(" if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" - " || (github.event_name == 'push' && vars.CI_SCOPE == 'full')" - " || github.head_ref == 'release/v5-prerelease'\n", text) + # A job with no OS-gated ancestor carries the Linux-only + # term for `workflow_dispatch -f os=...` (#1818). + gate = ("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + " || (github.event_name == 'push' && vars.CI_SCOPE == 'full')" + " || github.head_ref == 'release/v5-prerelease'") + self.assertTrue(f" if: {gate}\n" in text or f" if: ({gate}) && (inputs.os != 'windows'" + " && inputs.os != 'macos')\n" in text, text) self.assertIn(f"steps: *{anchor}", text) self.assertIn(f"steps: &{anchor}", TEXT) diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py index 32bcad39f..2952fde8d 100644 --- a/scripts/tests/test_ci_scheduling.py +++ b/scripts/tests/test_ci_scheduling.py @@ -14,6 +14,34 @@ JOBS = reader.jobs(TEXT) +# `workflow_dispatch -f os=` (#1818) appends one OS term to a job's +# `if:`. Off dispatch inputs.os is empty, so the term is always true there +# and the rest of the condition is what PRs, the queue and the nightly see. +OS_TERM = re.compile(r"^ if: \((.*)\) && \((inputs\.os [^()]*)\)$") +OS_TERMS = { + "linux": "inputs.os != 'windows' && inputs.os != 'macos'", + "windows": "inputs.os != 'linux' && inputs.os != 'macos'", + "macos": "inputs.os != 'linux' && inputs.os != 'windows'", + "test": "inputs.os != 'linux'", + "yarn-berry-e2e": "inputs.os != 'macos'", +} + + +def raw_if(job): + return next((line for line in JOBS[job] if line.startswith(" if:")), "") + + +def os_term(job): + match = OS_TERM.match(raw_if(job)) + return match[2] if match else None + + +def job_if(job): + """The job's `if:` line without its dispatch-only OS term.""" + match = OS_TERM.match(raw_if(job)) + return f" if: {match[1]}" if match else raw_if(job) + + def dependencies(job): for line in JOBS[job]: match = re.match(r"^ needs: (.*)$", line) @@ -103,7 +131,7 @@ def test_os_builds_use_the_same_artifact_contract(self): self.assertIn("pattern: e2e-bin-${{ matrix.os }}*", "\n".join(JOBS[family])) for job in ("e2e-build-macos", "e2e-macos", "cargo-vex-matrix-macos", "yarn-berry-e2e-macos"): # Never on pull_request; lean scope also skips them (CI_SCOPE). - condition = next(line for line in JOBS[job] if line.startswith(" if:")) + condition = job_if(job) self.assertTrue(condition.startswith(" if: (github.event_name != 'pull_request'"), condition) self.assertTrue(condition.endswith(" && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule'" " || github.event_name == 'workflow_dispatch')"), condition) @@ -126,7 +154,7 @@ def test_lean_queue_type_checks_windows_and_full_scope_builds_it(self): self.assertIn("save-if: ${{ github.ref == 'refs/heads/main' }}", body) self.assertNotIn("upload-artifact", body) self.assertNotIn("--no-run", body) - build = next(line for line in JOBS["e2e-build-windows"] if line.startswith(" if:")) + build = job_if("e2e-build-windows") self.assertEqual(build, " 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'") @@ -140,7 +168,7 @@ def test_reused_push_keeps_cache_writers_and_unqueued_jobs(self): "e2e-build-windows", "e2e-build-macos", "cargo-old-toolchains", "e2e-full", "cargo-vex-matrix-full", "yarn-berry-full", "hosted-e2e"): with self.subTest(job=job): - condition = next((line for line in JOBS[job] if line.startswith(" if:")), "") + condition = job_if(job) self.assertNotIn("outputs.reuse", condition) ship = [body for name, body in reader.steps(JOBS["e2e-build"]) if name.startswith(("Bundle ", "Compress ")) or "upload-artifact" in body] @@ -150,17 +178,17 @@ def test_reused_push_keeps_cache_writers_and_unqueued_jobs(self): " || (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:")) + condition = job_if(job) 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:")) + condition = job_if(family + suffix) self.assertIn(f"needs.{build}.outputs.reuse != 'true'", condition) 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:"))) + job_if("e2e-extended")) 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 ")] @@ -196,7 +224,7 @@ def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): " || (github.event_name == 'push' && vars.CI_SCOPE == 'full')" " || github.head_ref == 'release/v5-prerelease'") for job in JOBS: - condition = next((line for line in JOBS[job] if line.startswith(" if:")), "") + condition = job_if(job) with self.subTest(job=job): self.assertEqual("needs.release-readiness.outputs.pr_unchanged != 'true'" in condition, job in gated) @@ -204,7 +232,7 @@ def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): 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:")) + condition = job_if(job) with self.subTest(job=job): # Ungated jobs wait on a gated build, never run on a PR, or run # only on the v5 integration PR, which never reuses (below). @@ -216,6 +244,54 @@ def test_unchanged_pr_merge_skips_only_what_the_queue_re_tests(self): self.assertIn("if: github.event_name == 'pull_request' && github.base_ref == 'main'" " && github.head_ref != 'release/v5-prerelease'", owner) + def test_dispatch_os_runs_only_that_os(self): + # A Windows-only fix dispatches `-f os=windows` (#1818): its PR run + # and the queue already cover Linux, so the ~95 Linux and macOS + # nightly-tier jobs must not run again. clippy and release-readiness + # always run because every OS leg waits on them. + self.assertRegex(TEXT, r"(?m)^ os:\n(?: #.*\n)* description: .*\n" + r" type: choice\n default: all\n" + r" options: \[all, linux, windows, macos\]$") + oses = ("linux", "windows", "macos") + excluded = {job: set(re.findall(r"inputs\.os != '([a-z]+)'", os_term(job) or "")) + for job in JOBS} + + def runs(job, os_name): + return all(os_name not in excluded[j] for j in {job} | ancestors(job)) + + def platforms(job): + # Matrix rows and a fixed runner name the OS; every runs-on + # mapping mentions ubuntu, and comments mention anything. + body = "\n".join(reader.strip_comment(line) for line in JOBS[job] + if not line.startswith(" runs-on: ${{")) + found = {os_name for os_name in oses + if re.search(rf"\b{'ubuntu' if os_name == 'linux' else os_name}-(latest|22\.04)", body)} + return found or {"linux"} + + always = {"clippy", "release-readiness", "ci-ok", "windows-compile-check"} + for job in JOBS: + with self.subTest(job=job): + if job in always: + self.assertIsNone(os_term(job)) + continue + if os_term(job) is not None: + self.assertIn(os_term(job), OS_TERMS.values()) + ran = {os_name for os_name in oses if runs(job, os_name)} + self.assertEqual(ran, platforms(job)) + # Mixed-OS matrices narrow their rows to the requested OS. + self.assertIn(" - os: ${{ inputs.os == 'windows' && 'macos-latest'" + " || inputs.os == 'macos' && 'windows-latest' || '' }}", JOBS["test"]) + include = next(line for line in JOBS["yarn-berry-e2e"] if "include:" in line) + self.assertIn("""(inputs.os == 'windows' && '[{"os":"windows-latest","yarn":"4.12.0"}]'""", include) + self.assertIn("""inputs.os == 'linux' && '[{"os":"ubuntu-latest","yarn":"4.0.2"},""", include) + # Each OS still runs at least its build and one consumer. + for os_name, jobs in (("linux", {"e2e-build", "e2e", "coverage"}), + ("windows", {"e2e-build-windows", "e2e-windows", "test"}), + ("macos", {"e2e-build-macos", "e2e-macos", "test"})): + for job in jobs: + with self.subTest(os=os_name, job=job): + self.assertTrue(runs(job, os_name)) + def test_late_draft_run_cannot_cancel_the_ready_run(self): # A push's draft-payload `synchronize` can land after `ready_for_review` # on the same head; sharing a group, it cancelled the real run and left From 39f6776f7beaa88fc07a60a57eeef669e0bd1421 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:03:45 +0000 Subject: [PATCH 2/2] CI: keep one-OS dispatches off ci-ok A dispatch that ran only one OS still reported `ci-ok`, the required check, on the branch head, so a Windows-only run could make a PR whose Linux jobs failed look green. Such a run now reports as `ci-ok ( only)`; every other run still reports `ci-ok`. Dispatches for different OSes on one branch also shared a concurrency group, so a third dispatch replaced the pending second one. Each OS-filtered dispatch now gets its own group. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 17 ++++++++++++----- scripts/tests/test_ci_e2e_tiers.py | 12 +++++++----- scripts/tests/test_ci_scheduling.py | 9 +++++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da16927f..54873a5ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,12 @@ on: # Run only one OS's jobs (plus the clippy and release-readiness # preflights every leg waits on). A fix for a Windows-only nightly # failure dispatches `-f os=windows` instead of re-running ~95 Linux - # and macOS jobs its PR run and the merge queue already cover. Only - # a dispatch sets inputs.os; on every other event it is empty, so - # the `inputs.os != ...` terms below are always true there. + # and macOS jobs (~140 job-minutes) its PR run and the merge queue + # already cover. hosted-e2e is a Linux job and skips too. Only a + # dispatch sets inputs.os; on every other event it is empty, so the + # `inputs.os != ...` terms below are always true there. Each + # OS-filtered dispatch gets its own concurrency group, so + # dispatching one per OS runs them all. description: 'Only run jobs for this OS (all = the full nightly matrix)' type: choice default: all @@ -56,7 +59,7 @@ permissions: # cancelled the real run, skipped every job, and left ci-ok green on a head # nothing had tested. concurrency: - group: ci-${{ github.event.pull_request.number || (github.event_name == 'push' && github.sha) || github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }}${{ github.event.pull_request.draft && '-draft' || '' }} + group: ci-${{ github.event.pull_request.number || (github.event_name == 'push' && github.sha) || github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }}${{ github.event.pull_request.draft && '-draft' || '' }}${{ inputs.os && inputs.os != 'all' && format('-{0}', inputs.os) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Linux jobs run on Depot's runners (depot-ubuntu-*-4: 4 vCPU, like GitHub's @@ -2895,7 +2898,11 @@ jobs: # every job above, so adding a job here is how it becomes merge-blocking. # Skipped jobs (the nightly `full` tier) pass; failed or cancelled ones fail. ci-ok: - name: ci-ok # registered as a required check; do not rename + # Registered as a required check; do not rename. A one-OS dispatch + # (inputs.os, #1818) skips the other OSes, so its verdict gets another + # name: a green `ci-ok` on the branch head must mean every PR-tier job + # passed, and a skipped required check would count as passing too. + name: ${{ inputs.os && inputs.os != 'all' && format('ci-ok ({0} only)', inputs.os) || 'ci-ok' }} if: always() needs: [clippy, node-addon, lint-ecosystems, release-readiness, test, test-release, coverage, docker-base, coverage-docker, coverage-merge, dispatch-tests, e2e-build, windows-compile-check, e2e-build-windows, e2e-build-macos, e2e, e2e-windows, e2e-extended, e2e-full, e2e-docker, yarn-classic-matrix, yarn-berry-e2e, yarn-berry-full, cargo-vex-matrix, cargo-vex-matrix-windows, cargo-vex-matrix-full, cargo-old-toolchains, hosted-e2e, e2e-macos, yarn-berry-e2e-macos, cargo-vex-matrix-macos] # GitHub-hosted, not Depot: the check is one 2 s python3 call that runs diff --git a/scripts/tests/test_ci_e2e_tiers.py b/scripts/tests/test_ci_e2e_tiers.py index 6bbc9dba6..8a704ccb6 100644 --- a/scripts/tests/test_ci_e2e_tiers.py +++ b/scripts/tests/test_ci_e2e_tiers.py @@ -77,13 +77,15 @@ def test_full_jobs_run_nightly_only_and_share_steps(self): # Nightly, dispatch (release-qa) and v5 landings, like # e2e-docker: not on PRs, the merge queue or a lean push to # main, where its 41 legs ran on every landing. - # A job with no OS-gated ancestor carries the Linux-only - # term for `workflow_dispatch -f os=...` (#1818). - gate = ("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + # yarn-berry-full waits only on clippy, so it carries the + # Linux-only term for `workflow_dispatch -f os=...` itself + # (#1818); the others skip with e2e-build. + gate = (" if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" " || (github.event_name == 'push' && vars.CI_SCOPE == 'full')" " || github.head_ref == 'release/v5-prerelease'") - self.assertTrue(f" if: {gate}\n" in text or f" if: ({gate}) && (inputs.os != 'windows'" - " && inputs.os != 'macos')\n" in text, text) + if job == "yarn-berry-full": + gate = gate.replace(" if: ", " if: (") + ") && (inputs.os != 'windows' && inputs.os != 'macos')" + self.assertIn(gate + "\n", text) self.assertIn(f"steps: *{anchor}", text) self.assertIn(f"steps: &{anchor}", TEXT) diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py index 2952fde8d..a551b825a 100644 --- a/scripts/tests/test_ci_scheduling.py +++ b/scripts/tests/test_ci_scheduling.py @@ -298,6 +298,15 @@ def test_late_draft_run_cannot_cancel_the_ready_run(self): # ci-ok green with every job skipped. group = re.search(r"^concurrency:\n group: (.*)$", TEXT, re.M)[1] self.assertIn("github.event.pull_request.draft && '-draft'", group) + # One dispatch per OS must not cancel the pending one before it. + self.assertIn("${{ inputs.os && inputs.os != 'all' && format('-{0}', inputs.os) || '' }}", group) + + def test_one_os_dispatch_cannot_post_a_green_ci_ok(self): + # A dispatch on a PR head that skipped the other OSes must not report + # under the required check's name (#1818); every other event does. + names = [line for line in JOBS["ci-ok"] if line.startswith(" name:")] + self.assertEqual(names, [" name: ${{ inputs.os && inputs.os != 'all'" + " && format('ci-ok ({0} only)', inputs.os) || 'ci-ok' }}"]) def test_row_reader_preserves_both_os_siblings(self): jobs = reader.jobs("""jobs: