From 403d96d8b95955fa142a66470cac2ffdbdd145e3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 09:41:48 -0400 Subject: [PATCH 1/4] Shard the hosted Gradle e2e suite and ungate the yarn/cargo matrices The merge-group CI run took ~46 min, and its critical path was the four Gradle capstone legs: each ran the agent suites plus all 43 hosted real-Gradle tests serially (~31 min of test time per leg). - Split each line's agent+hosted leg into three: the agent suites plus gradle_hosted_[345], gradle_hosted_[b-p], and a catch-all that runs gradle_hosted_ with --skip on exactly those words, so a new test always lands in some leg. test_ci_gradle_prefixes.py's HostedShards checks that every hosted test runs in exactly one leg per line and that the catch-all's skip list matches the other legs. - Drop needs: [test, coverage] from yarn-classic-matrix, yarn-berry-e2e, cargo-old-toolchains and cargo-vex-matrix (which keeps e2e-build). They consume nothing from those jobs and started only after the ~31 min windows test leg. Draft skipping is unchanged: the yarn jobs never run on pull_request, cargo-old-toolchains has its own draft guard, and e2e-build gates cargo-vex-matrix. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 37 +++++++++++---- scripts/tests/test_ci_e2e_tiers.py | 17 +++++-- scripts/tests/test_ci_gradle_prefixes.py | 57 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce660612..298709391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1218,13 +1218,27 @@ jobs: # way): a missing suite fails the leg, and every suite must run at # least one test on its own. Maven is installed only where a # selected test needs it (gradle_vendor_395's mixed root). - - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + # The hosted suite (~43 real-Gradle builds, ~31 min serially) is + # split into three legs per line so it stops being the merge-queue + # critical path: the agent suites plus `gradle_hosted_[345]`, the + # `gradle_hosted_[b-p]` names, and a catch-all that `--skip`s + # exactly those words, so a new test always lands in some leg + # (test_ci_gradle_prefixes.py keeps the skip list in sync). + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_agent_ gradle_hosted_3 gradle_hosted_4 gradle_hosted_5'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_hosted_b gradle_hosted_c gradle_hosted_d gradle_hosted_e gradle_hosted_f gradle_hosted_l gradle_hosted_m gradle_hosted_n gradle_hosted_o gradle_hosted_p'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_hosted_ --skip gradle_hosted_3 --skip gradle_hosted_4 --skip gradle_hosted_5 --skip gradle_hosted_b --skip gradle_hosted_c --skip gradle_hosted_d --skip gradle_hosted_e --skip gradle_hosted_f --skip gradle_hosted_l --skip gradle_hosted_m --skip gradle_hosted_n --skip gradle_hosted_o --skip gradle_hosted_p'} - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} - - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_agent_ gradle_hosted_3 gradle_hosted_4 gradle_hosted_5'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_hosted_b gradle_hosted_c gradle_hosted_d gradle_hosted_e gradle_hosted_f gradle_hosted_l gradle_hosted_m gradle_hosted_n gradle_hosted_o gradle_hosted_p'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_hosted_ --skip gradle_hosted_3 --skip gradle_hosted_4 --skip gradle_hosted_5 --skip gradle_hosted_b --skip gradle_hosted_c --skip gradle_hosted_d --skip gradle_hosted_e --skip gradle_hosted_f --skip gradle_hosted_l --skip gradle_hosted_m --skip gradle_hosted_n --skip gradle_hosted_o --skip gradle_hosted_p'} - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} - - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_3 gradle_hosted_4 gradle_hosted_5'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_hosted_b gradle_hosted_c gradle_hosted_d gradle_hosted_e gradle_hosted_f gradle_hosted_l gradle_hosted_m gradle_hosted_n gradle_hosted_o gradle_hosted_p'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_hosted_ --skip gradle_hosted_3 --skip gradle_hosted_4 --skip gradle_hosted_5 --skip gradle_hosted_b --skip gradle_hosted_c --skip gradle_hosted_d --skip gradle_hosted_e --skip gradle_hosted_f --skip gradle_hosted_l --skip gradle_hosted_m --skip gradle_hosted_n --skip gradle_hosted_o --skip gradle_hosted_p'} - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} - - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_3 gradle_hosted_4 gradle_hosted_5'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_hosted_b gradle_hosted_c gradle_hosted_d gradle_hosted_e gradle_hosted_f gradle_hosted_l gradle_hosted_m gradle_hosted_n gradle_hosted_o gradle_hosted_p'} + - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_hosted_ --skip gradle_hosted_3 --skip gradle_hosted_4 --skip gradle_hosted_5 --skip gradle_hosted_b --skip gradle_hosted_c --skip gradle_hosted_d --skip gradle_hosted_e --skip gradle_hosted_f --skip gradle_hosted_l --skip gradle_hosted_m --skip gradle_hosted_n --skip gradle_hosted_o --skip gradle_hosted_p'} - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} # Real-sbt hosted (socket-patch.sbt) + vendored @@ -1904,7 +1918,9 @@ jobs: # ---------------------------------------------------------------------- yarn-classic-matrix: name: yarn-classic ${{ matrix.release }} - needs: [test, coverage] + # No `needs: [test, coverage]`: nothing here consumes their outputs, + # and waiting on the ~30 min windows `test` leg made this the merge + # queue's critical path. runs-on: ubuntu-latest timeout-minutes: 40 strategy: @@ -1940,7 +1956,9 @@ jobs: yarn-berry-e2e: name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) - needs: [test, coverage] + # No `needs: [test, coverage]`: nothing here consumes their outputs, + # and waiting on the ~30 min windows `test` leg made this the merge + # queue's critical path. strategy: fail-fast: false matrix: @@ -2022,7 +2040,8 @@ jobs: # e2e_safety_cargo_build (its headline test honours the knobs). cargo-vex-matrix: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) - needs: [test, coverage, e2e-build] + # e2e-build only (its binaries); see yarn-classic-matrix on test/coverage. + needs: [e2e-build] runs-on: ${{ matrix.os }} timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these @@ -2149,7 +2168,9 @@ jobs: # available; this leg pulls both images and requires them. cargo-old-toolchains: name: cargo old toolchains (manifest [patch]) - needs: [test, coverage] + # No `needs: [test, coverage]`: nothing here consumes their outputs, + # and waiting on the ~30 min windows `test` leg made this the merge + # queue's critical path. runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/scripts/tests/test_ci_e2e_tiers.py b/scripts/tests/test_ci_e2e_tiers.py index 9ed68b8c1..c995384c0 100644 --- a/scripts/tests/test_ci_e2e_tiers.py +++ b/scripts/tests/test_ci_e2e_tiers.py @@ -122,8 +122,17 @@ def test_bundle_reads_cargo_json(self): GRADLE_COMPAT = ROOT / ".github" / "workflows" / "gradle-compatibility.yml" GRADLE_LINES = {"6.9.4": "11", "7.6.6": "17", "8.14.3": "21", "9.8.0": "21"} -AGENT_HOSTED = ("e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build", - "--ignored gradle_agent_ gradle_hosted_") +# The hosted suite is sharded over three legs per line (test_ci_gradle_prefixes +# HostedShards checks every hosted test runs in exactly one of them). +HOSTED_SHARD_1 = ["gradle_hosted_3", "gradle_hosted_4", "gradle_hosted_5"] +HOSTED_SHARD_2 = ["gradle_hosted_" + c for c in "bcdeflmnop"] +AGENT_HOSTED = ( + ("e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build", + " ".join(["--ignored", "gradle_agent_", *HOSTED_SHARD_1])), + ("e2e_redirect_gradle_build", " ".join(["--ignored", *HOSTED_SHARD_2])), + ("e2e_redirect_gradle_build", + " ".join(["--ignored", "gradle_hosted_"] + [w for p in HOSTED_SHARD_1 + HOSTED_SHARD_2 for w in ("--skip", p)])), +) VENDOR = ("e2e_vendor_gradle_build e2e_vendor_jvm_build", "--ignored gradle_vendor_ gradle_multi_project") @@ -160,7 +169,7 @@ def test_pr_rows_are_the_lean_table(self): gradle = [r for r in rows("e2e") if r.get("jvm_tool") == "gradle"] want = [] for line, java in GRADLE_LINES.items(): - for suite, test_filter in (AGENT_HOSTED, VENDOR): + for suite, test_filter in (*AGENT_HOSTED, VENDOR): row = {"os": "ubuntu-latest", "suite": suite, "jvm_tool": "gradle", "gradle": line, "java": java, "test_filter": test_filter} # The allowance lasts only while a suite of the row is unlanded. @@ -169,7 +178,7 @@ def test_pr_rows_are_the_lean_table(self): want.append(row) want.append({"os": "windows-latest", "suite": "e2e_vendor_jvm_build", "jvm_tool": "gradle", "gradle": "8.14.3", "java": "17", "test_filter": "--ignored gradle_multi_project"}) - self.assertEqual(len(gradle), 9) + self.assertEqual(len(gradle), 17) self.assertEqual(sorted(map(str, gradle)), sorted(map(str, want))) self.assertFalse([r for r in rows("e2e-full") if "gradle" in r or "jvm_tool" in r]) diff --git a/scripts/tests/test_ci_gradle_prefixes.py b/scripts/tests/test_ci_gradle_prefixes.py index 2a52cd027..b4dc6e45f 100644 --- a/scripts/tests/test_ci_gradle_prefixes.py +++ b/scripts/tests/test_ci_gradle_prefixes.py @@ -175,6 +175,63 @@ def test_compat_overrides_select_admitted_tests(self): self.assertTrue(row.get("suites"), "a narrowed filter names the suite that owns it") +def libtest_selects(words, name): + """libtest's filter semantics for the argument words a row passes: a + name runs when it contains any positional filter (all names when there + is none) and no `--skip` word.""" + filters, skips, it = [], [], iter(words) + for word in it: + if word == "--skip": + skips.append(next(it)) + elif not word.startswith("--"): + filters.append(word) + return (not filters or any(f in name for f in filters)) and not any(s in name for s in skips) + + +class HostedShards(unittest.TestCase): + """The hosted suite runs as several legs per Gradle line (it is the + merge-queue critical path in one leg). Every hosted test must run in + exactly one leg of each line, and the catch-all leg's `--skip` words + must be exactly the other legs' hosted words.""" + SUITE = "e2e_redirect_gradle_build" + + def rows_by_line(self): + rows = [r for r in rows_mod.job_rows(rows_mod.jobs(CI.read_text(encoding="utf-8")), "e2e") + if r.get("jvm_tool") == "gradle" and self.SUITE in r["suite"].split()] + lines = {} + for row in rows: + lines.setdefault(row["gradle"], []).append(row["test_filter"].split()) + return lines + + def test_every_hosted_test_runs_in_exactly_one_leg_per_line(self): + names = [n for path in bundle.suite_files(self.SUITE) + for n in bundle.ignored_tests(path.read_text(encoding="utf-8"))] + self.assertGreater(len(names), 20) + lines = self.rows_by_line() + self.assertTrue(lines) + for line, filters in lines.items(): + for name in names: + with self.subTest(gradle=line, test=name): + self.assertEqual(sum(libtest_selects(f, name) for f in filters), 1) + + def test_catch_all_skips_exactly_the_other_legs_words(self): + for line, filters in self.rows_by_line().items(): + with self.subTest(gradle=line): + catch_all = [f for f in filters if "--skip" in f] + self.assertEqual(len(catch_all), 1) + skips = {w for a, w in zip(catch_all[0], catch_all[0][1:]) if a == "--skip"} + named = {w for f in filters if f is not catch_all[0] + for w in f if w.startswith("gradle_hosted_")} + self.assertEqual(skips, named) + + def test_libtest_selects_negative(self): + self.assertFalse(libtest_selects(["--ignored", "gradle_hosted_b"], "gradle_hosted_catalog")) + self.assertFalse(libtest_selects(["--ignored", "gradle_hosted_", "--skip", "gradle_hosted_c"], + "gradle_hosted_catalog")) + self.assertTrue(libtest_selects(["--ignored", "gradle_hosted_", "--skip", "gradle_hosted_c"], + "gradle_hosted_tamper_fails")) + + class AllowEmpty(unittest.TestCase): rows = rows_mod.job_rows(rows_mod.jobs(CI.read_text(encoding="utf-8")), "e2e") From 09aded0f7210b630c3acb5d5f7e335a199426c20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:20:50 +0000 Subject: [PATCH 2/4] Skip ungated yarn/cargo matrix jobs on draft PRs Dropping `needs: [test, coverage]` from yarn-classic-matrix, yarn-berry-e2e and cargo-old-toolchains also dropped the draft skip they inherited from those jobs, so draft pushes started compiling and running every leg. Gate them on `github.event.pull_request.draft != true` like the other top-level jobs; push, merge_group and schedule events have no pull_request payload and still run them. Co-Authored-By: Claude --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 298709391..72b3765c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1921,6 +1921,9 @@ jobs: # No `needs: [test, coverage]`: nothing here consumes their outputs, # and waiting on the ~30 min windows `test` leg made this the merge # 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). + if: github.event.pull_request.draft != true runs-on: ubuntu-latest timeout-minutes: 40 strategy: @@ -1959,6 +1962,9 @@ jobs: # No `needs: [test, coverage]`: nothing here consumes their outputs, # and waiting on the ~30 min windows `test` leg made this the merge # 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). + if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: @@ -2171,6 +2177,9 @@ jobs: # No `needs: [test, coverage]`: nothing here consumes their outputs, # and waiting on the ~30 min windows `test` leg made this the merge # 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). + if: github.event.pull_request.draft != true runs-on: ubuntu-latest timeout-minutes: 30 steps: From a1f2d347d2334dc561a063c7f475db204f7ca4c8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 11:21:49 -0400 Subject: [PATCH 3/4] Shard the test legs, skip test-release in the merge queue, cancel orphaned merge-group runs - test (macOS / Windows): two legs per OS via scripts/ci-test-shard.py. The Windows leg spent ~10 min linking ~240 test binaries and ~15 min running them in one job (~31 min, the next critical path once the Gradle legs are sharded). Shard 1 runs the unit tests, doctests and a third of the integration targets; shard 2 the rest. The shards together run exactly the old cargo test --workspace selection (test_ci_test_shard.py), and a renamed target fails loudly. - test-release: skipped on merge_group. Every PR already ran it on its head, and it still runs on main after each merge; in the queue it re-spent ~30 min (23 compiling) per entry. ci-ok counts skipped as passing. - merge-queue-janitor.yml: on each merge group, cancel queued or running merge-group runs whose gh-readonly-queue ref was deleted (the queue rebuilt or dropped that entry). Only a definite 404 cancels; it never fails the merge group. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 17 ++++- .github/workflows/merge-queue-janitor.yml | 55 ++++++++++++++ scripts/ci-test-shard.py | 87 +++++++++++++++++++++++ scripts/tests/test_ci_test_shard.py | 80 +++++++++++++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/merge-queue-janitor.yml create mode 100644 scripts/ci-test-shard.py create mode 100644 scripts/tests/test_ci_test_shard.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b3765c1..b6fd73854 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,6 +250,11 @@ jobs: fail-fast: false matrix: os: [macos-latest, windows-latest] + # Two legs per OS (scripts/ci-test-shard.py): one leg linked ~240 + # test binaries and ran them serially for ~26 min, the merge queue's + # critical path. Shard 1 = unit tests + doctests + a third of the + # integration targets; shard 2 = the rest. + shard: [1, 2] exclude: # macOS legs run on main, the merge queue and nightly, not per PR push. - os: ${{ github.event_name == 'pull_request' && 'macos-latest' || '' }} @@ -400,11 +405,19 @@ jobs: # `zip`, and assert the pinned release. SOCKET_PATCH_GO_E2E_REQUIRED: '1' SOCKET_PATCH_GO_E2E_VERSION: '1.24' + TEST_SHARD: ${{ matrix.shard }} + # This leg's share of `cargo test --workspace --no-fail-fast`; the + # shards together run exactly that selection (test_ci_test_shard.py). run: | - cargo test --workspace --no-fail-fast + python3 scripts/ci-test-shard.py "$TEST_SHARD" 2 test-release: - if: github.event.pull_request.draft != true + # Not in the merge queue: each PR's head already ran this, and so did + # every PR ahead of it, so a merge group would re-spend ~30 min (23 of it + # compiling) on the same release-mode suite while the queue waits. It + # still runs on every PR and on main after each merge; `ci-ok` counts a + # skipped job as passing. + if: github.event.pull_request.draft != true && github.event_name != 'merge_group' runs-on: ubuntu-latest # Every tests/ target is its own optimized link (~240 test binaries). # The manifest-less VEX suites share two multi-module binaries diff --git a/.github/workflows/merge-queue-janitor.yml b/.github/workflows/merge-queue-janitor.yml new file mode 100644 index 000000000..aee0bae53 --- /dev/null +++ b/.github/workflows/merge-queue-janitor.yml @@ -0,0 +1,55 @@ +name: Merge queue janitor + +# Cancels merge-group runs the queue has orphaned. Each queue entry runs CI on +# its own `gh-readonly-queue/main/pr--` ref, so ci.yml's +# concurrency group never lets a newer run cancel an older one. When an entry +# ahead fails or is removed, the queue deletes the refs of every entry behind +# it and rebuilds them on new refs, but the runs on the deleted refs keep going +# (each one is a full ~200-job CI run, macOS legs included). A run whose ref no +# longer exists can never merge, so it is cancelled here. Every rebuild creates +# a new merge group, which triggers this sweep. +# +# Not a required check, and it never fails the merge group: a sweep error is +# only a warning. + +on: + merge_group: + types: [checks_requested] + workflow_dispatch: + +permissions: {} + +concurrency: + group: merge-queue-janitor + cancel-in-progress: false + +jobs: + cancel-orphaned-runs: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Cancel merge-group runs whose queue ref is gone + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -uo pipefail + for status in queued in_progress; do + gh api --paginate "repos/$REPO/actions/runs?event=merge_group&status=$status&per_page=100" \ + -q '.workflow_runs[] | "\(.id) \(.head_branch)"' || echo "::warning::could not list $status runs" + done | sort -u | while read -r id branch; do + case "$branch" in gh-readonly-queue/*) ;; *) continue ;; esac + # Only a definite 404 means the entry is gone; any other lookup + # error (rate limit, 5xx) leaves the run alone. + if err=$(gh api "repos/$REPO/git/ref/heads/$branch" --silent 2>&1); then + continue + fi + case "$err" in *"HTTP 404"*) ;; *) echo "::warning::ref lookup for $branch failed: $err"; continue ;; esac + echo "Cancelling run $id: $branch is no longer in the queue" + gh api -X POST "repos/$REPO/actions/runs/$id/cancel" --silent \ + || echo "::warning::could not cancel run $id" + done + exit 0 diff --git a/scripts/ci-test-shard.py b/scripts/ci-test-shard.py new file mode 100644 index 000000000..6aed76b4d --- /dev/null +++ b/scripts/ci-test-shard.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Run one shard of ci.yml's `test` job: `cargo test --workspace` split over +`COUNT` runners so the macOS / Windows legs stop being the merge queue's +critical path (one leg spent ~10 min linking ~240 test binaries and ~15 min +running them). + +Shard 1 runs the unit tests (`--lib --bins`, ~4 min of the run on Windows) +and the doctests; the integration-test targets (`cargo metadata`, kind +`test`) are dealt out over the shards by name, with shard 1 taking a smaller +share to balance its unit tests. Every target lands in exactly one shard, so +the union of the shards is the old single `cargo test --workspace` run. + +Extra arguments after `SHARD COUNT` go to every `cargo test` invocation. +Each invocation runs with `--no-fail-fast`; the exit status is non-zero if +any of them failed. + + python3 scripts/ci-test-shard.py 1 2 +""" + +import json +import subprocess +import sys + +# Shard 1's share of the integration targets, relative to every other +# shard's 1.0 (it also carries the unit tests and doctests). +FIRST_SHARD_WEIGHT = 0.5 + + +def integration_targets(metadata): + """Sorted, de-duplicated names of the workspace's integration tests.""" + members = set(metadata["workspace_members"]) + return sorted({t["name"] for p in metadata["packages"] if p["id"] in members + for t in p["targets"] if "test" in t["kind"]}) + + +def partition(names, count): + """`count` lists covering `names` exactly once, in order, with the first + list weighted by FIRST_SHARD_WEIGHT.""" + if count < 1: + raise ValueError("count must be >= 1") + weights = [FIRST_SHARD_WEIGHT if count > 1 else 1.0] + [1.0] * (count - 1) + shards = [[] for _ in range(count)] + load = [0.0] * count + for name in names: + # The least-loaded shard relative to its weight; ties go to the lower index. + i = min(range(count), key=lambda k: ((load[k] + 1) / weights[k], k)) + shards[i].append(name) + load[i] += 1 + return shards + + +def invocations(shard, count, names, extra=()): + """The `cargo test` argument lists shard `shard` (1-based) runs.""" + if not 1 <= shard <= count: + raise ValueError(f"shard {shard} not in 1..{count}") + base = ["cargo", "test", "--workspace", "--no-fail-fast", *extra] + mine = partition(names, count)[shard - 1] + runs = [] + if shard == 1: + runs.append(base + ["--lib", "--bins"] + [a for n in mine for a in ("--test", n)]) + runs.append(base + ["--doc"]) + elif mine: + runs.append(base + [a for n in mine for a in ("--test", n)]) + return runs + + +def main(argv): + if len(argv) < 2: + print(__doc__, file=sys.stderr) + return 2 + shard, count = int(argv[0]), int(argv[1]) + metadata = json.loads(subprocess.run( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + check=True, capture_output=True, text=True).stdout) + names = integration_targets(metadata) + print(f"ci-test-shard: shard {shard}/{count}: " + f"{len(partition(names, count)[shard - 1])} of {len(names)} integration targets", flush=True) + status = 0 + for args in invocations(shard, count, names, argv[2:]): + print("+ " + " ".join(args), flush=True) + if subprocess.run(args).returncode != 0: + status = 1 + return status + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/tests/test_ci_test_shard.py b/scripts/tests/test_ci_test_shard.py new file mode 100644 index 000000000..c35eaa61d --- /dev/null +++ b/scripts/tests/test_ci_test_shard.py @@ -0,0 +1,80 @@ +"""ci-test-shard.py: the `test` job's shards together run exactly the old +single `cargo test --workspace` selection.""" + +import importlib.util +import json +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[2] +spec = importlib.util.spec_from_file_location("ci_test_shard", ROOT / "scripts" / "ci-test-shard.py") +shard = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shard) + + +def selected(runs): + names = [] + for args in runs: + names += [args[i + 1] for i, a in enumerate(args) if a == "--test"] + return names + + +class Partition(unittest.TestCase): + NAMES = [f"t{i:03}" for i in range(236)] + + def test_every_target_runs_in_exactly_one_shard(self): + for count in (1, 2, 3): + with self.subTest(count=count): + got = [] + for k in range(1, count + 1): + got += selected(shard.invocations(k, count, self.NAMES)) + self.assertEqual(sorted(got), self.NAMES) + + def test_unit_tests_and_doctests_run_once_on_shard_one(self): + for count in (1, 2, 3): + runs = [args for k in range(1, count + 1) for args in shard.invocations(k, count, self.NAMES)] + self.assertEqual(sum("--lib" in a and "--bins" in a for a in runs), 1) + self.assertEqual(sum("--doc" in a for a in runs), 1) + self.assertTrue(all("--doc" not in a or "--test" not in a for a in runs), + "cargo rejects --doc with other target selectors") + + def test_shard_one_takes_the_smaller_share(self): + first, second = shard.partition(self.NAMES, 2) + self.assertLess(len(first), len(second)) + self.assertAlmostEqual(len(first) / len(second), shard.FIRST_SHARD_WEIGHT, delta=0.02) + + def test_every_run_is_workspace_wide_and_keeps_going(self): + for args in shard.invocations(2, 2, self.NAMES, ["--locked"]): + self.assertEqual(args[:4], ["cargo", "test", "--workspace", "--no-fail-fast"]) + self.assertIn("--locked", args) + + def test_negative_bad_shard(self): + with self.assertRaises(ValueError): + shard.invocations(3, 2, self.NAMES) + with self.assertRaises(ValueError): + shard.invocations(0, 2, self.NAMES) + + def test_integration_targets_reads_workspace_test_kinds(self): + metadata = { + "workspace_members": ["a", "b"], + "packages": [ + {"id": "a", "targets": [{"name": "lib_a", "kind": ["lib"]}, {"name": "e2e_x", "kind": ["test"]}]}, + {"id": "b", "targets": [{"name": "e2e_x", "kind": ["test"]}, {"name": "zz", "kind": ["test"]}]}, + {"id": "dep", "targets": [{"name": "not_ours", "kind": ["test"]}]}, + ], + } + self.assertEqual(shard.integration_targets(metadata), ["e2e_x", "zz"]) + + def test_the_checkout_has_integration_targets(self): + try: + out = subprocess.run(["cargo", "metadata", "--no-deps", "--format-version", "1"], + cwd=ROOT, check=True, capture_output=True, text=True).stdout + except (OSError, subprocess.CalledProcessError): + self.skipTest("cargo not available") + names = shard.integration_targets(json.loads(out)) + self.assertGreater(len(names), 100) + + +if __name__ == "__main__": + unittest.main() From 0c9a5e1ece1503984df1c220352be9323bccef23 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 11:41:42 -0400 Subject: [PATCH 4/4] Run CI platforms independently and share compatible Rust caches Keep every E2E and compatibility row while removing cross-platform build barriers and unit-test dependencies. Check all test targets before the expensive fan-out, keep ci-ok dependent on every split job, and share dependency caches by profile and runner image without allowing PR cache writes. --- .github/workflows/bun-compatibility.yml | 8 +- .github/workflows/ci.yml | 216 ++++++++++++------- .github/workflows/composer-compatibility.yml | 2 +- .github/workflows/go-compatibility.yml | 2 +- .github/workflows/gradle-compatibility.yml | 2 +- .github/workflows/pdm-compatibility.yml | 2 +- .github/workflows/pipenv-compatibility.yml | 2 +- .github/workflows/poetry-compatibility.yml | 2 +- .github/workflows/sbt-compatibility.yml | 2 +- .github/workflows/vlt-compatibility.yml | 2 +- scripts/tests/test_ci_scheduling.py | 104 +++++++++ scripts/tests/test_ci_vlt_rows.py | 9 +- 12 files changed, 256 insertions(+), 97 deletions(-) create mode 100644 scripts/tests/test_ci_scheduling.py diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index a60fe94d6..c5b78dcbc 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -130,11 +130,11 @@ jobs: - name: Cache cargo # save-if keeps writes on main so open PRs do not churn the repo's - # 10 GiB cache budget; rust-cache's automatic key already includes - # the runner OS, so one logical key serves all three builds. + # 10 GiB cache budget. Share dependency artifacts with the other + # debug=0 E2E builders on the same runner image. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: bun-native + shared-key: e2e-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build CLI @@ -390,7 +390,7 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: # Keep binaries linked against different glibc versions separate. - key: bun-native-binary-${{ matrix.os }} + shared-key: e2e-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Python diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6fd73854..690cd06d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Required independently of ci-ok so the merge queue sees a compile/lint + # failure immediately. Expensive jobs also depend on this preflight. clippy: if: github.event.pull_request.draft != true runs-on: ubuntu-latest @@ -67,18 +69,28 @@ jobs: # smaller), which keeps this repo's total cache footprint inside # GitHub's 10 GiB budget (a raw target/ cache let every PR save evict # main's caches). save-if restricts writes to main so PR branches - # restore without churning the budget. + # restore without churning the budget. Compatible build jobs use + # shared-key by profile + runner image (not job or package-manager + # version). rust-cache also hashes the toolchain, Cargo manifests + # and Rust environment. Check/coverage/release keep separate caches. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run clippy - run: cargo clippy --workspace --all-features -- -D warnings + run: cargo clippy --locked --workspace --all-features -- -D warnings + + - name: Check every test target before starting expensive 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. + run: cargo check --locked --workspace --all-targets --all-features # 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 runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -93,6 +105,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: + shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -242,6 +255,7 @@ jobs: test: if: github.event.pull_request.draft != true + needs: 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 @@ -280,6 +294,7 @@ jobs: # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build @@ -418,6 +433,7 @@ jobs: # still runs on every PR and on main after each merge; `ci-ok` counts a # skipped job as passing. if: github.event.pull_request.draft != true && github.event_name != 'merge_group' + needs: clippy runs-on: ubuntu-latest # Every tests/ target is its own optimized link (~240 test binaries). # The manifest-less VEX suites share two multi-module binaries @@ -453,6 +469,7 @@ jobs: coverage: if: github.event.pull_request.draft != true + needs: clippy # 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 @@ -566,6 +583,7 @@ jobs: # 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 runs-on: ubuntu-22.04 timeout-minutes: 30 permissions: @@ -845,27 +863,25 @@ jobs: npm test node --test --test-name-pattern="npm package contents" bin/socket-patch.test.mjs - # Compiles the CLI and every CLI test target once per OS (--all-features, - # so this is also the feature-gated suites' compile-rot check) and uploads - # the binaries the e2e, e2e-full and cargo-vex legs run. The legs run the + # Independent OS producers compile the CLI and every CLI test target + # once per OS (--all-features, so this also checks feature-gated suites) + # and upload the binaries the e2e, e2e-full and cargo-vex legs run. + # The legs run the # 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 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - exclude: - # macOS legs run on main, the merge queue and nightly, not per PR push. - - os: ${{ github.event_name == 'pull_request' && 'macos-latest' || '' }} + os: [ubuntu-latest] runs-on: ${{ matrix.os }} - # Windows compiles the same ~240 targets ~1.6x slower (see `test`). - timeout-minutes: ${{ matrix.os == 'windows-latest' && 60 || 45 }} - env: + timeout-minutes: 45 + env: &e2e-build-env CARGO_PROFILE_DEV_DEBUG: '0' CARGO_INCREMENTAL: '0' - steps: + steps: &e2e-build-steps - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -877,7 +893,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: e2e-build + shared-key: e2e-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Compile the CLI and every CLI test target @@ -899,6 +915,30 @@ jobs: if-no-files-found: error retention-days: 3 + e2e-build-windows: + if: github.event.pull_request.draft != true + needs: clippy + strategy: + fail-fast: false + matrix: + os: [windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: *e2e-build-env + steps: *e2e-build-steps + + e2e-build-macos: + if: github.event_name != 'pull_request' + needs: clippy + strategy: + fail-fast: false + matrix: + os: [macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: *e2e-build-env + steps: *e2e-build-steps + e2e: # These jobs consume e2e-build's binaries and can run alongside unit tests. needs: [e2e-build] @@ -991,8 +1031,6 @@ jobs: # follow-up (`std::fs::Metadata` doesn't expose nlink on # Windows; needs `GetFileInformationByHandle` via # `windows-sys`). - - os: windows-latest - suite: e2e_safety_pnpm # Wall-bound real-package-manager redirect capstones (~150s and # ~70s of network installs + bootstrap resolutions — profile- # insensitive, measured identical in debug and release). They ran @@ -1008,13 +1046,8 @@ jobs: - os: ubuntu-latest suite: e2e_redirect_npm_build npm_required: '1' - - os: windows-latest - suite: e2e_redirect_npm_build - npm_required: '1' - os: ubuntu-latest suite: e2e_redirect_rush_sim - - os: windows-latest - suite: e2e_redirect_rush_sim # Hermetic real-bun capstones (wiremock patch service, real `bun # install`): hosted (`e2e_redirect_bun_build`), vendored # (`e2e_vendor_bun_build`) and the hosted⇄vendored takeover / @@ -1043,10 +1076,6 @@ jobs: suite: e2e_redirect_bun_build bun: '1.4.2' test_filter: --include-ignored - - os: windows-latest - suite: e2e_redirect_bun_build - bun: '1.4.2' - test_filter: --include-ignored - os: ubuntu-latest suite: e2e_redirect_bun_build bun: '1.1.45' @@ -1059,10 +1088,6 @@ jobs: suite: e2e_vendor_bun_build bun: '1.4.2' test_filter: --include-ignored - - os: windows-latest - suite: e2e_vendor_bun_build - bun: '1.4.2' - test_filter: --include-ignored - os: ubuntu-latest suite: e2e_vendor_bun_build bun: '1.1.45' @@ -1075,10 +1100,6 @@ jobs: suite: mode_migration_bun bun: '1.4.2' test_filter: --include-ignored - - os: windows-latest - suite: mode_migration_bun - bun: '1.4.2' - test_filter: --include-ignored - os: ubuntu-latest suite: mode_migration_bun bun: '1.3.14' @@ -1108,7 +1129,6 @@ jobs: # 0.0.0-32, B rc.12/rc.14 (rc.14 legs reach public npm), C rc.32, # D 1.0.4/1.0.7, E 1.1.1, F 1.2.0. - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '0.0.0-16', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} @@ -1116,38 +1136,30 @@ jobs: - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.0.4', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.1.1', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.4', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix, vlt_upgrade: '1.2.0'} - - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} # Linux `auto` hardlinks from the global store; every OS gets the # explicit hardlink linker. - {os: ubuntu-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} - - {os: windows-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} # rc.12 gets the definite no-hook advisory; windows rc.14 runs the # legacy DepIDs on NTFS with pre-junction symlinks. - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vlt, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.0-rc.12', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.0-rc.32', test_filter: --include-ignored vlt_pinned_matrix} - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.7', test_filter: --include-ignored vlt_pinned_matrix} - - {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} # The named corepack pnpm hosted legs (pnpm 7-11, get-uuid, # zero-touch, --trust-lockfile). `#[ignore]`d; the pinned matrix # inside the same suite runs in pnpm-compatibility.yml, hence the # skip. Node 24 (step below): the # corepack pnpm@10/11 legs require it. - {os: ubuntu-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} - - {os: windows-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} # Real-uv hosted/vendored capstones ending in manifest-less VEX: # the oldest and newest line + the 0.5.x boundary (0.5.4 still # re-resolves a transitive override / rejects a repointed @@ -1219,7 +1231,6 @@ jobs: - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.2', test_filter: '--ignored maven_reactor'} - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} # Real-Gradle capstones, PR tier: one leg per Gradle line x {agent + # hosted, vendor + multi-project} on ubuntu, each on its line's LTS # JDK. Every other OS x line x mode cell (and the JDK-ceiling, @@ -1253,7 +1264,6 @@ jobs: - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_hosted_b gradle_hosted_c gradle_hosted_d gradle_hosted_e gradle_hosted_f gradle_hosted_l gradle_hosted_m gradle_hosted_n gradle_hosted_o gradle_hosted_p'} - {os: ubuntu-latest, suite: e2e_redirect_gradle_build, jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_hosted_ --skip gradle_hosted_3 --skip gradle_hosted_4 --skip gradle_hosted_5 --skip gradle_hosted_b --skip gradle_hosted_c --skip gradle_hosted_d --skip gradle_hosted_e --skip gradle_hosted_f --skip gradle_hosted_l --skip gradle_hosted_m --skip gradle_hosted_n --skip gradle_hosted_o --skip gradle_hosted_p'} - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} # Real-sbt hosted (socket-patch.sbt) + vendored # (socket-patch-vendor.sbt) capstones on the current 1.x line. The # other sbt lines, JDK 21, the agent cells beyond coverage-docker's, @@ -1276,7 +1286,7 @@ jobs: timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these # legs launch the test binaries themselves. - env: + env: &e2e-env SOCKET_NO_CONFIG: '1' SOCKET_NO_UPDATE_CHECK: '1' SOCKET_TELEMETRY_DISABLED: '1' @@ -1722,13 +1732,55 @@ jobs: # v5 landings, the nightly schedule and dispatch run them with the `e2e` # steps. - # The macOS rows of `e2e`: they run on main, the merge queue and - # nightly, not on every PR push, so PRs stop queueing on the small - # macOS runner pool. + # Each OS consumes only its own build. A queued macOS runner must not + # delay Linux or Windows, and a Windows compile must not delay Gradle. + e2e-windows: + needs: [e2e-build-windows] + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + suite: e2e_safety_pnpm + - os: windows-latest + suite: e2e_redirect_npm_build + npm_required: '1' + - os: windows-latest + suite: e2e_redirect_rush_sim + - os: windows-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - {os: windows-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} + - {os: windows-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + env: *e2e-env + steps: *e2e-steps + + # The macOS rows run on main, the merge queue and nightly, not on every + # PR push, so PRs stop queueing on the small macOS runner pool. e2e-macos: if: github.event_name != 'pull_request' # These jobs consume e2e-build's binaries and can run alongside unit tests. - needs: [e2e-build] + needs: [e2e-build-macos] strategy: fail-fast: false matrix: @@ -1786,7 +1838,7 @@ jobs: e2e-full: # merge_group runs the pull_request tier: the queue gates on ci-ok. if: (github.event_name != 'pull_request' && github.event_name != 'merge_group') || github.head_ref == 'release/v5-prerelease' - needs: [test, coverage, e2e-build] + needs: [e2e-build] strategy: fail-fast: false matrix: @@ -1931,12 +1983,9 @@ jobs: # ---------------------------------------------------------------------- yarn-classic-matrix: name: yarn-classic ${{ matrix.release }} - # No `needs: [test, coverage]`: nothing here consumes their outputs, - # and waiting on the ~30 min windows `test` leg made this the merge - # 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 preflight; this job consumes no unit-test artifacts. if: github.event.pull_request.draft != true + needs: clippy runs-on: ubuntu-latest timeout-minutes: 40 strategy: @@ -1956,7 +2005,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: yarn-classic-${{ matrix.release }} + shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -1972,12 +2021,9 @@ jobs: yarn-berry-e2e: name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) - # No `needs: [test, coverage]`: nothing here consumes their outputs, - # and waiting on the ~30 min windows `test` leg made this the merge - # 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 preflight; this job consumes no unit-test artifacts. if: github.event.pull_request.draft != true + needs: clippy strategy: fail-fast: false matrix: @@ -2001,7 +2047,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: yarn-berry-${{ matrix.yarn }} + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Enable corepack run: corepack enable @@ -2021,7 +2067,7 @@ jobs: yarn-berry-e2e-macos: if: github.event_name != 'pull_request' name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) - needs: [test, coverage] + needs: clippy strategy: fail-fast: false matrix: @@ -2038,7 +2084,7 @@ jobs: name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) # merge_group runs the pull_request tier: the queue gates on ci-ok. if: (github.event_name != 'pull_request' && github.event_name != 'merge_group') || github.head_ref == 'release/v5-prerelease' - needs: [test, coverage] + needs: clippy strategy: fail-fast: false matrix: @@ -2059,7 +2105,7 @@ jobs: # e2e_safety_cargo_build (its headline test honours the knobs). cargo-vex-matrix: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) - # e2e-build only (its binaries); see yarn-classic-matrix on test/coverage. + # Only the matching OS build is needed. needs: [e2e-build] runs-on: ${{ matrix.os }} timeout-minutes: 40 @@ -2078,8 +2124,6 @@ jobs: - {os: ubuntu-latest, toolchain: stable, lock: '2'} - {os: ubuntu-latest, toolchain: '1.82.0', lock: '3'} - {os: ubuntu-latest, toolchain: '1.93.1', lock: '4'} - - {os: windows-latest, toolchain: stable, lock: '1'} - - {os: windows-latest, toolchain: '1.93.1', lock: ''} steps: &cargo-vex-steps - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -2127,13 +2171,25 @@ jobs: "../../target/e2e-bin/e2e_safety_cargo_build$exe" --ignored || status=1 exit "$status" - # The macOS rows of `cargo-vex-matrix`: they run on main, the merge queue and - # nightly, not on every PR push, so PRs stop queueing on the small - # macOS runner pool. + cargo-vex-matrix-windows: + name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) + needs: [e2e-build-windows] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + env: *e2e-env + strategy: + fail-fast: false + matrix: + include: + - {os: windows-latest, toolchain: stable, lock: '1'} + - {os: windows-latest, toolchain: '1.93.1', lock: ''} + steps: *cargo-vex-steps + + # The macOS rows run on main, the merge queue and nightly, not per PR push. cargo-vex-matrix-macos: if: github.event_name != 'pull_request' name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) - needs: [test, coverage, e2e-build] + needs: [e2e-build-macos] runs-on: ${{ matrix.os }} timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these @@ -2154,7 +2210,7 @@ jobs: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) # merge_group runs the pull_request tier: the queue gates on ci-ok. if: (github.event_name != 'pull_request' && github.event_name != 'merge_group') || github.head_ref == 'release/v5-prerelease' - needs: [test, coverage, e2e-build] + needs: [e2e-build] runs-on: ${{ matrix.os }} timeout-minutes: 40 # What .cargo/config.toml's [env] gives processes cargo launches; these @@ -2187,12 +2243,9 @@ jobs: # available; this leg pulls both images and requires them. cargo-old-toolchains: name: cargo old toolchains (manifest [patch]) - # No `needs: [test, coverage]`: nothing here consumes their outputs, - # and waiting on the ~30 min windows `test` leg made this the merge - # 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 preflight; this job consumes no unit-test artifacts. if: github.event.pull_request.draft != true + needs: clippy runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -2209,7 +2262,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: cargo-old-toolchains + shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} - name: Vendored manifest [patch] on old cargo shell: bash @@ -2256,6 +2309,7 @@ jobs: hosted-e2e: name: hosted-e2e # may be a required check; do not rename if: github.event.pull_request.draft != true + needs: clippy runs-on: ubuntu-latest permissions: contents: read @@ -2312,7 +2366,7 @@ jobs: if: steps.gate.outputs.run == 'true' uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: hosted-e2e + shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -2454,13 +2508,13 @@ jobs: echo "::error title=hosted-e2e::the vendored vlt production proof failed on all 3 attempts" exit 1 - # The single required status check for the merge queue (and PRs). It needs + # The aggregate required status check for the merge queue (and PRs). It needs # 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 if: always() - needs: [clippy, node-addon, lint-ecosystems, release-readiness, test, test-release, coverage, docker-base, coverage-docker, coverage-merge, dispatch-tests, e2e-build, e2e, e2e-full, e2e-docker, yarn-classic-matrix, yarn-berry-e2e, yarn-berry-full, cargo-vex-matrix, cargo-vex-matrix-full, cargo-old-toolchains, hosted-e2e, e2e-macos, yarn-berry-e2e-macos, cargo-vex-matrix-macos] + needs: [clippy, node-addon, lint-ecosystems, release-readiness, test, test-release, coverage, docker-base, coverage-docker, coverage-merge, dispatch-tests, e2e-build, e2e-build-windows, e2e-build-macos, e2e, e2e-windows, 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] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/composer-compatibility.yml b/.github/workflows/composer-compatibility.yml index 9c9f9ea98..65cee8a08 100644 --- a/.github/workflows/composer-compatibility.yml +++ b/.github/workflows/composer-compatibility.yml @@ -110,7 +110,7 @@ jobs: - run: rustup show - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: composer-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: diff --git a/.github/workflows/go-compatibility.yml b/.github/workflows/go-compatibility.yml index 6251cd386..d53907b42 100644 --- a/.github/workflows/go-compatibility.yml +++ b/.github/workflows/go-compatibility.yml @@ -55,7 +55,7 @@ jobs: - run: rustup show - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: go-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/gradle-compatibility.yml b/.github/workflows/gradle-compatibility.yml index 8ebf4842b..c0506e518 100644 --- a/.github/workflows/gradle-compatibility.yml +++ b/.github/workflows/gradle-compatibility.yml @@ -125,7 +125,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: gradle-compat + shared-key: e2e-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check the Gradle test-name prefixes diff --git a/.github/workflows/pdm-compatibility.yml b/.github/workflows/pdm-compatibility.yml index c286fcaa9..6b194dec4 100644 --- a/.github/workflows/pdm-compatibility.yml +++ b/.github/workflows/pdm-compatibility.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: pdm-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Compile the CLI and the capstone once run: | diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index 4664f6c02..d8d2a3335 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -68,7 +68,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: pipenv-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install uv uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 diff --git a/.github/workflows/poetry-compatibility.yml b/.github/workflows/poetry-compatibility.yml index cf38394ab..139c68513 100644 --- a/.github/workflows/poetry-compatibility.yml +++ b/.github/workflows/poetry-compatibility.yml @@ -70,7 +70,7 @@ jobs: persist-credentials: false - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: poetry-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo build --locked -p socket-patch-cli - uses: ./.github/actions/upload-artifact diff --git a/.github/workflows/sbt-compatibility.yml b/.github/workflows/sbt-compatibility.yml index 76eb5edc9..412a67342 100644 --- a/.github/workflows/sbt-compatibility.yml +++ b/.github/workflows/sbt-compatibility.yml @@ -283,7 +283,7 @@ jobs: - run: rustup show - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: sbt-compat + shared-key: dev-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index a4435a561..d827ff8c8 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -168,7 +168,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: - key: vlt-e2e + shared-key: e2e-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Compile the CLI and the vlt capstones once shell: bash diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py new file mode 100644 index 000000000..1a7b83e67 --- /dev/null +++ b/scripts/tests/test_ci_scheduling.py @@ -0,0 +1,104 @@ +"""Keep CI's required verdict complete while scheduling each OS independently.""" + +import importlib.util +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[2] +spec = importlib.util.spec_from_file_location("ci_rows", Path(__file__).with_name("test_ci_vlt_rows.py")) +reader = importlib.util.module_from_spec(spec) +spec.loader.exec_module(reader) +TEXT = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") +JOBS = reader.jobs(TEXT) + + +def dependencies(job): + for line in JOBS[job]: + match = re.match(r"^ needs: (.*)$", line) + if match: + return {s.strip() for s in match[1].strip("[]").split(",")} + return set() + + +def ancestors(job, visiting=()): + if job in visiting: + raise AssertionError(f"dependency cycle: {visiting + (job,)}") + parents = dependencies(job) + return parents | {parent for dep in parents for parent in ancestors(dep, visiting + (job,))} + + +class Scheduling(unittest.TestCase): + def test_required_verdict_includes_every_job(self): + # A successful aggregate must never hide a failed OS builder/consumer + # introduced when a matrix is split into independently scheduled jobs. + self.assertEqual(dependencies("ci-ok"), set(JOBS) - {"ci-ok"}) + self.assertIn(" if: always()", JOBS["ci-ok"]) + self.assertIn('if v["result"] not in ("success", "skipped")', "\n".join(JOBS["ci-ok"])) + for job in JOBS: + ancestors(job) # All dependencies exist and the graph is acyclic. + + def test_expensive_jobs_cannot_start_after_a_failed_preflight(self): + cheap = {"clippy", "lint-ecosystems", "release-readiness", "dispatch-tests", "ci-ok"} + for job in set(JOBS) - cheap: + with self.subTest(job=job): + 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])) + self.assertIn(" if: github.event.pull_request.draft != true", JOBS["clippy"]) + preflight = "\n".join(JOBS["clippy"]) + self.assertIn("cargo check --locked --workspace --all-targets --all-features", preflight) + + def test_consumers_wait_only_for_their_own_os_build(self): + for family in ("e2e", "cargo-vex-matrix"): + for suffix, os_name in (("", "ubuntu-latest"), ("-windows", "windows-latest"), + ("-macos", "macos-latest"), ("-full", "ubuntu-latest")): + job = family + suffix + builder = "e2e-build" + (suffix if suffix in ("-windows", "-macos") else "") + with self.subTest(job=job): + 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"}) + + def test_os_builds_use_the_same_artifact_contract(self): + producer = "\n".join(JOBS["e2e-build"]) + self.assertIn(" steps: &e2e-build-steps", producer) + self.assertIn("--all-features --tests --no-run", producer) + self.assertIn("name: e2e-bin-${{ matrix.os }}", producer) + self.assertIn("--os \"$BUNDLE_OS\"", producer) + for suffix in ("windows", "macos"): + self.assertIn(" steps: *e2e-build-steps", JOBS[f"e2e-build-{suffix}"]) + self.assertIn(" env: *e2e-build-env", JOBS[f"e2e-build-{suffix}"]) + for family in ("e2e", "cargo-vex-matrix"): + 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"): + self.assertIn(" if: github.event_name != 'pull_request'", JOBS[job]) + + def test_row_reader_preserves_both_os_siblings(self): + jobs = reader.jobs("""jobs: + example: + strategy: + matrix: + include: + - {os: ubuntu-latest, suite: linux_only} + example-windows: + strategy: + matrix: + include: + - {os: windows-latest, suite: windows_only} + example-macos: + strategy: + matrix: + include: + - {os: macos-latest, suite: macos_only} +""") + rows = reader.job_rows(jobs, "example") + self.assertEqual(len(rows), 3) + self.assertEqual({r["suite"] for r in rows}, {"linux_only", "windows_only", "macos_only"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ci_vlt_rows.py b/scripts/tests/test_ci_vlt_rows.py index b5c105fbb..46c328b3f 100644 --- a/scripts/tests/test_ci_vlt_rows.py +++ b/scripts/tests/test_ci_vlt_rows.py @@ -127,11 +127,12 @@ def matrix_include(job_lines): def job_rows(jobs_by_id, job): - """`matrix_include` of a job plus its `-macos` sibling, which holds - the macOS rows that run off the pull_request path.""" + """All rows of a job family, including its independent OS siblings.""" rows = matrix_include(jobs_by_id[job]) - if f"{job}-macos" in jobs_by_id: - rows += matrix_include(jobs_by_id[f"{job}-macos"]) + for os_name in ("windows", "macos"): + sibling = f"{job}-{os_name}" + if sibling in jobs_by_id: + rows += matrix_include(jobs_by_id[sibling]) return rows