From 0d16f9dd381983e4953249795b6a1460c829ae38 Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 7 Oct 2026 20:47:10 -0400 Subject: [PATCH 1/4] Keep Python suites out of YAML test batches --- .../ci-yaml-only-test-batches.fixed.md | 1 + .../test_batched_yaml_collection.py | 109 ++++++++++++++++++ ...st_additional_tax_bracket_unset_bracket.py | 8 +- policyengine_us/tests/test_batched.py | 10 ++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 changelog.d/ci-yaml-only-test-batches.fixed.md create mode 100644 policyengine_us/tests/code_health/test_batched_yaml_collection.py diff --git a/changelog.d/ci-yaml-only-test-batches.fixed.md b/changelog.d/ci-yaml-only-test-batches.fixed.md new file mode 100644 index 00000000000..768463ed165 --- /dev/null +++ b/changelog.d/ci-yaml-only-test-batches.fixed.md @@ -0,0 +1 @@ +Prevent YAML test batches from also collecting Python suites that already run in dedicated CI steps. diff --git a/policyengine_us/tests/code_health/test_batched_yaml_collection.py b/policyengine_us/tests/code_health/test_batched_yaml_collection.py new file mode 100644 index 00000000000..d2e6d20234c --- /dev/null +++ b/policyengine_us/tests/code_health/test_batched_yaml_collection.py @@ -0,0 +1,109 @@ +"""YAML batches must not rerun the separately routed Python suites. + +Exercise the real Core CLI and pytest collector with a one-variable country +package, so this runner regression never constructs the full US model. +""" + +import importlib.util +import os +import shlex +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +BATCHER = Path(__file__).resolve().parents[1] / "test_batched.py" +SPEC = importlib.util.spec_from_file_location("test_batched", BATCHER) +batched = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(batched) + + +@pytest.fixture +def small_country(tmp_path, monkeypatch): + # The shim still uses its normal country name, resolved from the child cwd. + (tmp_path / "policyengine_us.py").write_text( + """ +from policyengine_core.entities import Entity +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import YEAR +from policyengine_core.taxbenefitsystems import TaxBenefitSystem +from policyengine_core.variables import Variable + +person = Entity("person", "people", "Person", "") + +class age(Variable): + label = "Age" + value_type = int + entity = person + definition_period = YEAR + +class CountryTaxBenefitSystem(TaxBenefitSystem): + def __init__(self): + super().__init__([person]) + self.parameters = ParameterNode("", data={}) + self.add_variable(age) +""" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + monkeypatch.setenv("PYTEST_ADDOPTS", "") + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_python.py").write_text( + "from pathlib import Path\n" + "Path('python-imported').touch()\n" + "def test_python():\n" + " assert True\n" + ) + return tests + + +def test_directory_batch_collects_yaml_only(small_country, monkeypatch): + tests = small_country + (tests / "nested").mkdir() + for path in (tests / "a.yaml", tests / "nested" / "b.yml"): + path.write_text( + "- name: age input\n" + " period: 2026\n" + " input: {age: 30}\n" + " output: {age: 30}\n" + ) + (tests / "conftest.py").write_text( + "from pathlib import Path\nPath('conftest-loaded').touch()\n" + ) + report = tests.parent / "yaml report.xml" + inherited_options = f"-q --junitxml={shlex.quote(str(report))}" + monkeypatch.setenv("PYTEST_ADDOPTS", inherited_options) + + result = batched.run_batch([str(tests)], "mixed directory", stream=False) + + assert result["status"] == "passed", result["output"] + cases = ET.parse(report).findall(".//testcase") + assert len(cases) == 2, result["output"] + assert not (tests.parent / "python-imported").exists() + assert (tests.parent / "conftest-loaded").exists() + assert os.environ["PYTEST_ADDOPTS"] == inherited_options + + # The ordinary Python step must still collect and run the same file. + python_step = subprocess.run( + [sys.executable, "-m", "pytest", str(tests / "test_python.py")], + capture_output=True, + text=True, + timeout=30, + ) + assert python_step.returncode == 0, python_step.stdout + python_step.stderr + assert len(ET.parse(report).findall(".//testcase")) == 1 + assert (tests.parent / "python-imported").exists() + + +def test_python_only_directory_is_not_a_passing_yaml_batch(small_country): + result = batched.run_batch([str(small_country)], "no YAML", stream=False) + assert result["returncode"] == batched.PYTEST_NO_TESTS_COLLECTED, result["output"] + assert result["status"] == "failed" + assert not (small_country.parent / "python-imported").exists() + + +def test_python_only_subdir_does_not_create_a_yaml_batch(small_country): + assert batched.split_into_batches(small_country.parent, 2, mode="per-subdir") == [] diff --git a/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py b/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py index 90bfa90f8cc..92e9dc540c7 100644 --- a/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py +++ b/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py @@ -456,8 +456,8 @@ def calculate(variables, year, reforms=None): changes, or under the baseline when ``reforms`` is None. A reformed simulation runs on its own clone of the loaded default system. - This file also runs inside the contrib YAML batch, whose memory is - budgeted, so collect each simulation before building the next. + Collect each simulation before building the next to keep memory bounded + in the dedicated contrib Python step. """ sim = Simulation( tax_benefit_system=baseline_system, @@ -563,8 +563,8 @@ def per_status(values): # Each example runs two simulations, each on its own clone of the default -# system, and the file runs in two CI steps, so the generated examples are -# few; the schedule properties above take 300 examples each. +# system, so the generated examples are few; the schedule properties above +# take 300 examples each. @hypothesis.settings( max_examples=3, deadline=None, diff --git a/policyengine_us/tests/test_batched.py b/policyengine_us/tests/test_batched.py index 54b73ff1f8d..73f9e510640 100644 --- a/policyengine_us/tests/test_batched.py +++ b/policyengine_us/tests/test_batched.py @@ -201,6 +201,9 @@ def subdir_batches(subdir: Path) -> List[List[str]]: instead. """ files = yaml_files(subdir) + if not files: + # Python-only folders are covered by the dedicated Python suites. + return [] combos: set = set() for file in files: combos |= set(file_reform_combos(file)) @@ -512,9 +515,16 @@ def collect_gov_paths(folder_names): BATCH_EXIT_MARKER = "__POLICYENGINE_BATCH_EXIT__" RUNNER_SHIM = f""" +import os import runpy import sys +# Python suites run separately; directory batches must collect only YAML. +# Set this in the child so the later Python step keeps its normal collection. +os.environ["PYTEST_ADDOPTS"] = ( + os.environ.get("PYTEST_ADDOPTS", "") + " --ignore-glob=*.py" +) + code = 0 try: runpy.run_module( From b72947ab2ebd3fd1e9595b3a4658ce5e20baa655 Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 7 Oct 2026 21:02:07 -0400 Subject: [PATCH 2/4] Consolidate PR CI from 35 to 28 runner jobs --- .github/actions/candidate-wheel/action.yaml | 66 +++++++ .github/actions/python-compat/action.yaml | 35 ++++ .github/release_build.py | 2 +- .github/tests/test_release_build.py | 16 +- .github/workflows/pr.yaml | 184 +++++++----------- .../ci-yaml-only-test-batches.fixed.md | 2 +- .../code_health/test_pr_runner_budget.py | 134 +++++++++++++ 7 files changed, 314 insertions(+), 125 deletions(-) create mode 100644 .github/actions/candidate-wheel/action.yaml create mode 100644 .github/actions/python-compat/action.yaml create mode 100644 policyengine_us/tests/code_health/test_pr_runner_budget.py diff --git a/.github/actions/candidate-wheel/action.yaml b/.github/actions/candidate-wheel/action.yaml new file mode 100644 index 00000000000..c82af1e3567 --- /dev/null +++ b/.github/actions/candidate-wheel/action.yaml @@ -0,0 +1,66 @@ +name: Build an unpublished release wheel +description: Build one release candidate from a clean reviewed-head checkout and retain its receipt. +inputs: + kind: + description: Candidate version kind (rc1 or final). + required: true +runs: + using: composite + steps: + - name: Checkout reviewed PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: candidate-${{ inputs.kind }} + - name: Detect the release tooling on the PR head + id: tooling + # Older PR heads may predate the tooling present on the merge commit. + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: | + if [ -f .github/release_lock.py ] && [ -f .github/release_build.py ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Candidate wheel skipped::The PR head predates the release tooling (.github/release_lock.py and .github/release_build.py). Merge main into the branch to build its candidate wheel." + fi + - name: Check committed registry lock + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: python .github/release_lock.py --committed + - name: Predict release version with the release helper + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: python .github/release_build.py --prepare "${{ inputs.kind }}" + - name: Refresh only the release version in the registry lock + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: python .github/release_lock.py --refresh + - name: Install package + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: uv sync --locked --extra dev + - name: Build package from unchanged policy source + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: uv run --no-sync make + - name: Record and verify release build + if: steps.tooling.outputs.present == 'true' + shell: bash + working-directory: candidate-${{ inputs.kind }} + run: uv run --no-sync python .github/release_build.py + - name: Retain unpublished wheel and build receipt + if: steps.tooling.outputs.present == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-wheel-${{ inputs.kind }}-${{ github.event.pull_request.head.sha }} + path: | + candidate-${{ inputs.kind }}/dist/*.whl + candidate-${{ inputs.kind }}/release-build/receipt.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/actions/python-compat/action.yaml b/.github/actions/python-compat/action.yaml new file mode 100644 index 00000000000..af6f24e89fa --- /dev/null +++ b/.github/actions/python-compat/action.yaml @@ -0,0 +1,35 @@ +name: Python compatibility checks +description: Install, import, and check dataset semantics in an isolated checkout and environment. +inputs: + python-version: + description: Supported Python version to check. + required: true +runs: + using: composite + steps: + - name: Checkout for Python ${{ inputs.python-version }} + uses: actions/checkout@v6 + with: + path: python-${{ inputs.python-version }} + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + - name: Resolve latest compatible PolicyEngine Core + shell: bash + working-directory: python-${{ inputs.python-version }} + run: uv lock --upgrade-package policyengine-core + - name: Install package + shell: bash + working-directory: python-${{ inputs.python-version }} + run: uv sync --locked --python "${{ inputs.python-version }}" --extra dev + - name: Smoke-import + shell: bash + working-directory: python-${{ inputs.python-version }} + run: | + uv run --no-sync --python "${{ inputs.python-version }}" python -c "from policyengine_us import CountryTaxBenefitSystem, Simulation, Microsimulation; print('import OK')" + - name: Dataset copy/extension tests (real pandas semantics per Python version) + # Fixture-based and requires no dataset downloads. + shell: bash + working-directory: python-${{ inputs.python-version }} + run: uv run --no-sync --python "${{ inputs.python-version }}" python -m pytest policyengine_us/tests/microsimulation/data/ -q diff --git a/.github/release_build.py b/.github/release_build.py index 6508fc32d1e..2ae18aa0524 100644 --- a/.github/release_build.py +++ b/.github/release_build.py @@ -1,6 +1,6 @@ """Verify clean policy source and record a locally built release wheel. -The PR matrix can prepare either candidate using the actual release bump helper. +The PR workflow prepares both candidates using the actual release bump helper. Both candidates and Publish record a receipt after the same make command. This script never resolves dependencies or publishes. """ diff --git a/.github/tests/test_release_build.py b/.github/tests/test_release_build.py index 2eb27d5416b..3c74b4f844c 100644 --- a/.github/tests/test_release_build.py +++ b/.github/tests/test_release_build.py @@ -31,11 +31,15 @@ def test_candidate_build_matches_publish_without_publication_or_policy_mutations self, ): pr = (ROOT / ".github/workflows/pr.yaml").read_text() - candidate = job(pr, "CandidateWheel") + candidate_job = job(pr, "CandidateWheel") + candidate_action = ( + ROOT / ".github/actions/candidate-wheel/action.yaml" + ).read_text() + candidate = candidate_job + candidate_action publish = job((ROOT / ".github/workflows/push.yaml").read_text(), "Publish") commands = ( "python .github/release_lock.py --committed", - 'python .github/release_build.py --prepare "${{ matrix.kind }}"', + 'python .github/release_build.py --prepare "${{ inputs.kind }}"', "python .github/release_lock.py --refresh", "uv sync --locked --extra dev", "uv run --no-sync make", @@ -47,8 +51,12 @@ def test_candidate_build_matches_publish_without_publication_or_policy_mutations sorted(candidate.index(value) for value in commands), ) self.assertIn("github.event.pull_request.head.sha", candidate) - self.assertIn("kind: [rc1, final]", candidate) - self.assertIn("release-wheel-${{ matrix.kind }}-", candidate) + self.assertEqual( + candidate_job.count("uses: ./.github/actions/candidate-wheel"), 2 + ) + self.assertIn("kind: rc1", candidate_job) + self.assertIn("kind: final", candidate_job) + self.assertIn("release-wheel-${{ inputs.kind }}-", candidate_action) for expected in ( "runs-on: ubuntu-24.04", 'python-version: "3.14.7"', diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 2e84e19dea9..b1c8f7f5427 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -12,11 +12,13 @@ concurrency: cancel-in-progress: true jobs: ReleaseLock: - name: Check committed registry lock + name: PR validation (lock, guard tests, lint, changelog) if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 2 - name: Set up Python uses: actions/setup-python@v6 with: @@ -25,117 +27,30 @@ jobs: uses: astral-sh/setup-uv@v8.1.0 with: version: "0.12.13" + # Keep collecting independent results after a failure; failed steps + # still fail the job, and cancellation stops the remaining checks. - name: Check committed registry lock run: python .github/release_lock.py --committed - name: Rehearse the release version refresh + if: ${{ !cancelled() }} run: python .github/release_lock.py --rehearse - ReleaseLockTests: - name: Release lock guard tests - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - name: Test guard without importing the country model + if: ${{ !cancelled() }} env: RELEASE_LOCK_REAL_UV: "1" run: python -m unittest discover -s .github/tests -p 'test_release_*.py' -v - CandidateWheel: - name: Unpublished release wheel (${{ matrix.kind }}) - needs: [ReleaseLock, ReleaseLockTests] - permissions: - contents: read - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - kind: [rc1, final] - steps: - - name: Checkout reviewed PR head - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - - name: Detect the release tooling on the PR head - id: tooling - # The job builds from the reviewed head, not the merge commit, so a - # branch that predates the release tooling has no script to run. - # Skip the build with a notice instead of failing the PR; the - # ReleaseLock job above still checks the lock on the merge commit. - run: | - if [ -f .github/release_lock.py ] && [ -f .github/release_build.py ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Candidate wheel skipped::The PR head predates the release tooling (.github/release_lock.py and .github/release_build.py). Merge main into the branch to build its candidate wheel." - fi - - name: Set up Python - if: steps.tooling.outputs.present == 'true' - uses: actions/setup-python@v6 - with: - python-version: "3.14.7" - - name: Install uv - if: steps.tooling.outputs.present == 'true' - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Check committed registry lock - if: steps.tooling.outputs.present == 'true' - run: python .github/release_lock.py --committed - - name: Predict release version with the release helper - if: steps.tooling.outputs.present == 'true' - run: python .github/release_build.py --prepare "${{ matrix.kind }}" - - name: Refresh only the release version in the registry lock - if: steps.tooling.outputs.present == 'true' - run: python .github/release_lock.py --refresh - - name: Install package - if: steps.tooling.outputs.present == 'true' - run: uv sync --locked --extra dev - - name: Build package from unchanged policy source - if: steps.tooling.outputs.present == 'true' - run: uv run --no-sync make - - name: Record and verify release build - if: steps.tooling.outputs.present == 'true' - run: uv run --no-sync python .github/release_build.py - - name: Retain unpublished wheel and build receipt - if: steps.tooling.outputs.present == 'true' - uses: actions/upload-artifact@v4 - with: - name: release-wheel-${{ matrix.kind }}-${{ github.event.pull_request.head.sha }} - path: | - dist/*.whl - release-build/receipt.json - if-no-files-found: error - retention-days: 14 - Lint: - needs: ReleaseLock - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Install dependencies + - name: Install lint dependencies + if: ${{ !cancelled() }} + id: lint-install run: uv sync --locked --extra dev - name: Check formatting + if: ${{ !cancelled() && steps.lint-install.outcome == 'success' }} run: uv run --no-sync ruff format --check . - check-changelog: - name: Check changelog fragment - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - name: Check changed changelog fragments + if: ${{ !cancelled() }} shell: bash + env: + PR_HEAD_REF: ${{ github.head_ref }} run: | set -euo pipefail @@ -165,10 +80,11 @@ jobs: fi if [ "$valid_count" -eq 0 ]; then - echo "::error::No valid changelog fragment found. Add one like: changelog.d/${{ github.head_ref }}.fixed.md" + echo "::error::No valid changelog fragment found. Add one like: changelog.d/${PR_HEAD_REF}.fixed.md" exit 1 fi - name: Validate changelog.d tree + if: ${{ !cancelled() }} shell: bash run: | set -euo pipefail @@ -192,6 +108,33 @@ jobs: if [ "$invalid" -ne 0 ]; then exit 1 fi + CandidateWheel: + name: Unpublished release wheels (rc1, final) + needs: ReleaseLock + permissions: + contents: read + runs-on: ubuntu-24.04 + steps: + # Load the action from the tested merge commit. Each invocation checks + # out the reviewed head into its own clean directory before building. + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14.7" + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Build and retain rc1 wheel + uses: ./.github/actions/candidate-wheel + with: + kind: rc1 + - name: Build and retain final wheel + if: ${{ !cancelled() }} + uses: ./.github/actions/candidate-wheel + with: + kind: final BundleMetadataContract: needs: ReleaseLock name: Validate bundle metadata contract @@ -218,32 +161,35 @@ jobs: run: uv run --no-sync python -m pytest policyengine_us/tests/test_build_metadata.py Python-Compat: needs: ReleaseLock - name: Install + smoke-import (py${{ matrix.python-version }}) + name: Python compatibility (3.11, 3.12, 3.13, 3.14) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install package - run: uv sync --locked --python "${{ matrix.python-version }}" --extra dev - - name: Smoke-import - run: | - uv run --no-sync --python "${{ matrix.python-version }}" python -c "from policyengine_us import CountryTaxBenefitSystem, Simulation, Microsimulation; print('import OK')" - - name: Dataset copy/extension tests (real pandas semantics per Python version) - # Fixture-based and requires no dataset downloads. - run: uv run --no-sync --python "${{ matrix.python-version }}" python -m pytest policyengine_us/tests/microsimulation/data/ -q + # Sequential versions, each with its own checkout, lock resolution, + # and .venv. A failed version does not suppress the later versions. + - name: Python 3.11 + uses: ./.github/actions/python-compat + with: + python-version: "3.11" + - name: Python 3.12 + if: ${{ !cancelled() }} + uses: ./.github/actions/python-compat + with: + python-version: "3.12" + - name: Python 3.13 + if: ${{ !cancelled() }} + uses: ./.github/actions/python-compat + with: + python-version: "3.13" + - name: Python 3.14 + if: ${{ !cancelled() }} + uses: ./.github/actions/python-compat + with: + python-version: "3.14" Quick-Feedback: needs: ReleaseLock name: Quick Feedback (Selective Tests + Coverage) diff --git a/changelog.d/ci-yaml-only-test-batches.fixed.md b/changelog.d/ci-yaml-only-test-batches.fixed.md index 768463ed165..73950b8c924 100644 --- a/changelog.d/ci-yaml-only-test-batches.fixed.md +++ b/changelog.d/ci-yaml-only-test-batches.fixed.md @@ -1 +1 @@ -Prevent YAML test batches from also collecting Python suites that already run in dedicated CI steps. +Reduce PR CI from 35 to 28 jobs by consolidating validation, Python compatibility, and unpublished wheel builds; prevent YAML batches from rerunning separately routed Python tests. diff --git a/policyengine_us/tests/code_health/test_pr_runner_budget.py b/policyengine_us/tests/code_health/test_pr_runner_budget.py new file mode 100644 index 00000000000..e9d5e02e7ef --- /dev/null +++ b/policyengine_us/tests/code_health/test_pr_runner_budget.py @@ -0,0 +1,134 @@ +"""Consolidating short checks must preserve coverage and failure isolation.""" + +import math +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[3] +JOBS = yaml.safe_load((ROOT / ".github/workflows/pr.yaml").read_text())["jobs"] +KEEP_RUNNING = "${{ !cancelled() }}" + + +def action(name): + return yaml.safe_load((ROOT / f".github/actions/{name}/action.yaml").read_text())[ + "runs" + ]["steps"] + + +def calls(job, name): + return [ + step + for step in JOBS[job]["steps"] + if step.get("uses") == f"./.github/actions/{name}" + ] + + +def test_pr_runner_budget_keeps_the_heavy_suites_separate(): + counts = {} + for name, job in JOBS.items(): + matrix = job.get("strategy", {}).get("matrix", {}) + if "include" in matrix: + counts[name] = len(matrix["include"]) + else: + counts[name] = math.prod(len(values) for values in matrix.values()) + assert sum(counts.values()) == 28 + assert { + name: counts[name] + for name in ("Baseline", "Contrib", "Rest", "Microsimulation") + } == { + "Baseline": 11, + "Contrib": 9, + "Rest": 1, + "Microsimulation": 1, + } + + +def test_sequential_variants_preserve_failures_and_allow_later_results(): + for job, name, key, expected in ( + ( + "Python-Compat", + "python-compat", + "python-version", + ["3.11", "3.12", "3.13", "3.14"], + ), + ("CandidateWheel", "candidate-wheel", "kind", ["rc1", "final"]), + ): + variants = calls(job, name) + assert [step["with"][key] for step in variants] == expected + assert all(step["if"] == KEEP_RUNNING for step in variants[1:]) + # A failure must remain a job failure even if a later variant passes. + assert not JOBS[job].get("continue-on-error") + assert all( + not step.get("continue-on-error") + for step in JOBS[job]["steps"] + action(name) + ) + + +def test_variants_use_distinct_clean_checkouts_for_locks_environments_and_outputs(): + for job, name, key in ( + ("Python-Compat", "python-compat", "python-version"), + ("CandidateWheel", "candidate-wheel", "kind"), + ): + steps = action(name) + checkout = steps[0] + assert checkout["uses"].startswith("actions/checkout@") + assert checkout.get("with", {}).get("clean", True) + template = checkout["with"]["path"] + directories = [ + template.replace("${{ inputs." + key + " }}", step["with"][key]) + for step in calls(job, name) + ] + assert len(set(directories)) == len(directories) + assert all(path not in ("", ".") for path in directories) + # uv's default .venv and modified lock/build outputs stay in each checkout. + assert all( + step.get("working-directory") == template for step in steps if "run" in step + ) + if name == "candidate-wheel": + assert ( + checkout["with"]["ref"] == "${{ github.event.pull_request.head.sha }}" + ) + upload = next( + step + for step in steps + if step.get("uses", "").startswith("actions/upload-artifact@") + ) + assert "${{ inputs.kind }}" in upload["with"]["name"] + assert all( + path.startswith(template + "/") + for path in upload["with"]["path"].splitlines() + ) + + +def test_every_python_version_keeps_import_and_dataset_semantics_checks(): + steps = action("python-compat") + commands = [step["run"] for step in steps if "run" in step] + assert "uv lock --upgrade-package policyengine-core" in commands + assert any( + 'uv sync --locked --python "${{ inputs.python-version }}" --extra dev' + == command + for command in commands + ) + assert any( + "CountryTaxBenefitSystem, Simulation, Microsimulation" in command + for command in commands + ) + assert any( + "python -m pytest policyengine_us/tests/microsimulation/data/ -q" in command + for command in commands + ) + + +def test_validation_collects_independent_results_after_failure(): + steps = JOBS["ReleaseLock"]["steps"] + runs = [step for step in steps if "run" in step] + assert runs[0]["run"] == "python .github/release_lock.py --committed" + for step in runs[1:]: + assert "!cancelled()" in step["if"] + assert all(not step.get("continue-on-error") for step in runs) + assert any("--rehearse" in step["run"] for step in runs) + assert any("unittest discover" in step["run"] for step in runs) + assert any("ruff format --check" in step["run"] for step in runs) + assert any("No valid changelog fragment found" in step["run"] for step in runs) + assert any("git ls-files -- changelog.d" in step["run"] for step in runs) From 36de05910261c9317e5208c1ad0358ce544465ae Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 7 Oct 2026 21:33:27 -0400 Subject: [PATCH 3/4] Reduce PR CI to 26 runners and group Python suites --- .github/actions/bundle-metadata/action.yaml | 30 ++++ .github/tests/test_release_build.py | 2 +- .github/workflows/pr.yaml | 154 ++---------------- .github/workflows/python-tests.yaml | 104 ++++++++++++ .../ci-yaml-only-test-batches.fixed.md | 2 +- .../code_health/test_make_test_matches_ci.py | 13 +- .../code_health/test_pr_runner_budget.py | 71 +++++++- .../code_health/test_rest_python_groups.py | 4 +- 8 files changed, 229 insertions(+), 151 deletions(-) create mode 100644 .github/actions/bundle-metadata/action.yaml create mode 100644 .github/workflows/python-tests.yaml diff --git a/.github/actions/bundle-metadata/action.yaml b/.github/actions/bundle-metadata/action.yaml new file mode 100644 index 00000000000..92473d1d400 --- /dev/null +++ b/.github/actions/bundle-metadata/action.yaml @@ -0,0 +1,30 @@ +name: Validate bundle metadata +description: Check the runtime metadata contract in a separate checkout and environment. +runs: + using: composite + steps: + - name: Checkout tested merge commit + uses: actions/checkout@v6 + with: + path: bundle-metadata + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Resolve latest compatible PolicyEngine Core + shell: bash + working-directory: bundle-metadata + run: uv lock --upgrade-package policyengine-core + - name: Install package + shell: bash + working-directory: bundle-metadata + run: uv sync --locked --extra dev + - name: Install bundle validation tooling + # Preserve the independently pinned contract dependency. + shell: bash + working-directory: bundle-metadata + run: uv pip install --python .venv/bin/python "policyengine-bundles @ git+https://github.com/PolicyEngine/policyengine-bundles@8ae9f56fefcf89f69b8a7e3bc49928509c6207be" + - name: Validate runtime metadata contract + shell: bash + working-directory: bundle-metadata + run: uv run --no-sync python -m pytest policyengine_us/tests/test_build_metadata.py diff --git a/.github/tests/test_release_build.py b/.github/tests/test_release_build.py index 3c74b4f844c..196b2b1d1b5 100644 --- a/.github/tests/test_release_build.py +++ b/.github/tests/test_release_build.py @@ -31,7 +31,7 @@ def test_candidate_build_matches_publish_without_publication_or_policy_mutations self, ): pr = (ROOT / ".github/workflows/pr.yaml").read_text() - candidate_job = job(pr, "CandidateWheel") + candidate_job = job(pr, "PackageCompatibility") candidate_action = ( ROOT / ".github/actions/candidate-wheel/action.yaml" ).read_text() diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b1c8f7f5427..e483c936439 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -108,17 +108,17 @@ jobs: if [ "$invalid" -ne 0 ]; then exit 1 fi - CandidateWheel: - name: Unpublished release wheels (rc1, final) + PackageCompatibility: + name: Package and compatibility needs: ReleaseLock permissions: contents: read runs-on: ubuntu-24.04 steps: - # Load the action from the tested merge commit. Each invocation checks - # out the reviewed head into its own clean directory before building. + # Load actions from the tested merge commit. Each area uses its own + # checkout and environment; wheels still build the reviewed PR head. - uses: actions/checkout@v6 - - name: Set up Python + - name: Set up wheel Python uses: actions/setup-python@v6 with: python-version: "3.14.7" @@ -135,43 +135,13 @@ jobs: uses: ./.github/actions/candidate-wheel with: kind: final - BundleMetadataContract: - needs: ReleaseLock - name: Validate bundle metadata contract - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install package - run: uv sync --locked --extra dev - - name: Install bundle validation tooling - # Pin the test-only bundle contract dependency until policyengine-bundles - # has published releases suitable for ordinary dependency specifiers. - run: uv pip install --python .venv/bin/python "policyengine-bundles @ git+https://github.com/PolicyEngine/policyengine-bundles@8ae9f56fefcf89f69b8a7e3bc49928509c6207be" - - name: Validate runtime metadata contract - run: uv run --no-sync python -m pytest policyengine_us/tests/test_build_metadata.py - Python-Compat: - needs: ReleaseLock - name: Python compatibility (3.11, 3.12, 3.13, 3.14) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - # Sequential versions, each with its own checkout, lock resolution, - # and .venv. A failed version does not suppress the later versions. + - name: Validate bundle metadata contract + if: ${{ !cancelled() }} + uses: ./.github/actions/bundle-metadata + # Keep running independent checks after a failure, and stop on cancel. + # Each version resolves its own lock and installs into its own .venv. - name: Python 3.11 + if: ${{ !cancelled() }} uses: ./.github/actions/python-compat with: python-version: "3.11" @@ -409,103 +379,7 @@ jobs: - name: Run Contrib Python tests if: ${{ matrix.group == 'other-shard-1' }} run: uv run --no-sync make test-policy-contrib-python - Rest: - needs: ReleaseLock - name: Full Suite - Rest (Python + variables) - runs-on: ubuntu-latest - # Passing runs on 2026-10-04 took 42-55 minutes (#9653, #9766, #9769, - # #9777, #9784), so a 60-minute limit cancels PRs that add a few - # minutes of tests (#9695 was cancelled at 60 minutes twice). - timeout-minutes: 90 - env: - PYTHONUNBUFFERED: 1 - PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.14 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install dependencies - run: uv sync --locked --extra dev - - name: Turn off default branching - shell: bash - run: bash ./update_itemization.sh - # Each Rest group runs in a separate process to release its heap. - # SPM simulation/API contracts run in the Microsimulation job instead. - - name: Run remaining Python tests - # Continue after failures, but stop when the workflow is cancelled. - if: ${{ !cancelled() }} - env: - PYTEST_ADDOPTS: "--durations=0 -v -p no:unraisableexception -p no:threadexception" - run: uv run --no-sync make test-other-python-rest REST_REPORT_DIR=. - - name: Run tests/variables YAML tests - # Continue after failures, but stop when the workflow is cancelled. - if: ${{ !cancelled() }} - env: - PYTEST_ADDOPTS: "--durations=0 --junitxml=rest-variables.xml -p no:unraisableexception -p no:threadexception" - run: /usr/bin/time -v -o rest-variables-resources.txt uv run --no-sync make test-yaml-variables - - name: Upload Rest timing and memory reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: rest-test-timings - path: | - rest-python-*.xml - rest-variables.xml - rest-python-*-resources.txt - rest-variables-resources.txt - Microsimulation: + PythonTests: + name: Python tests needs: ReleaseLock - name: Full Suite - Microsimulation - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - PYTHONUNBUFFERED: 1 - PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.14 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install dependencies - run: uv sync --locked --extra dev - - name: Turn off default branching - shell: bash - run: bash ./update_itemization.sh - # Keep simulation/API and dataset compatibility on this existing runner. - # Use separate, sequential processes: combining these suites in one - # pytest process previously accumulated enough memory to kill the runner. - - name: Run SPM Python tests - env: - PYTEST_ADDOPTS: "--durations=0 -v --junitxml=simulation-spm.xml -p no:unraisableexception -p no:threadexception" - run: /usr/bin/time -v -o simulation-spm-resources.txt uv run --no-sync make test-other-python-spm - - name: Run microsimulation tests - # Continue after failures, but stop when the workflow is cancelled. - if: ${{ !cancelled() }} - env: - PYTEST_ADDOPTS: "--durations=0 -v --junitxml=simulation-microsimulation.xml -p no:unraisableexception -p no:threadexception" - run: /usr/bin/time -v -o simulation-microsimulation-resources.txt uv run --no-sync make test-microsimulation - - name: Upload simulation timing and memory reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: simulation-test-timings - path: | - simulation-*.xml - simulation-*-resources.txt + uses: ./.github/workflows/python-tests.yaml diff --git a/.github/workflows/python-tests.yaml b/.github/workflows/python-tests.yaml new file mode 100644 index 00000000000..034aa44b16d --- /dev/null +++ b/.github/workflows/python-tests.yaml @@ -0,0 +1,104 @@ +name: Python tests +on: + workflow_call: +# Group these jobs in the Actions view while retaining two independent runners. +# Only the guarded PR workflow calls this file; it has no automatic triggers. +jobs: + Rest: + name: Full Suite - Rest (Python + variables) + runs-on: ubuntu-latest + # Passing runs on 2026-10-04 took 42-55 minutes (#9653, #9766, #9769, + # #9777, #9784), so a 60-minute limit cancels PRs that add a few + # minutes of tests (#9695 was cancelled at 60 minutes twice). + timeout-minutes: 90 + env: + PYTHONUNBUFFERED: 1 + PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.14 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Resolve latest compatible PolicyEngine Core + run: uv lock --upgrade-package policyengine-core + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Turn off default branching + shell: bash + run: bash ./update_itemization.sh + # Each Rest group runs in a separate process to release its heap. + # SPM simulation/API contracts run in the Microsimulation job instead. + - name: Run remaining Python tests + # Continue after failures, but stop when the workflow is cancelled. + if: ${{ !cancelled() }} + env: + PYTEST_ADDOPTS: "--durations=0 -v -p no:unraisableexception -p no:threadexception" + run: uv run --no-sync make test-other-python-rest REST_REPORT_DIR=. + - name: Run tests/variables YAML tests + # Continue after failures, but stop when the workflow is cancelled. + if: ${{ !cancelled() }} + env: + PYTEST_ADDOPTS: "--durations=0 --junitxml=rest-variables.xml -p no:unraisableexception -p no:threadexception" + run: /usr/bin/time -v -o rest-variables-resources.txt uv run --no-sync make test-yaml-variables + - name: Upload Rest timing and memory reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: rest-test-timings + path: | + rest-python-*.xml + rest-variables.xml + rest-python-*-resources.txt + rest-variables-resources.txt + Microsimulation: + name: Full Suite - Microsimulation + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + PYTHONUNBUFFERED: 1 + PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.14 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Resolve latest compatible PolicyEngine Core + run: uv lock --upgrade-package policyengine-core + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Turn off default branching + shell: bash + run: bash ./update_itemization.sh + # Keep simulation/API and dataset compatibility on this existing runner. + # Use separate, sequential processes: combining these suites in one + # pytest process previously accumulated enough memory to kill the runner. + - name: Run SPM Python tests + env: + PYTEST_ADDOPTS: "--durations=0 -v --junitxml=simulation-spm.xml -p no:unraisableexception -p no:threadexception" + run: /usr/bin/time -v -o simulation-spm-resources.txt uv run --no-sync make test-other-python-spm + - name: Run microsimulation tests + # Continue after failures, but stop when the workflow is cancelled. + if: ${{ !cancelled() }} + env: + PYTEST_ADDOPTS: "--durations=0 -v --junitxml=simulation-microsimulation.xml -p no:unraisableexception -p no:threadexception" + run: /usr/bin/time -v -o simulation-microsimulation-resources.txt uv run --no-sync make test-microsimulation + - name: Upload simulation timing and memory reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: simulation-test-timings + path: | + simulation-*.xml + simulation-*-resources.txt diff --git a/changelog.d/ci-yaml-only-test-batches.fixed.md b/changelog.d/ci-yaml-only-test-batches.fixed.md index 73950b8c924..c29ab24daad 100644 --- a/changelog.d/ci-yaml-only-test-batches.fixed.md +++ b/changelog.d/ci-yaml-only-test-batches.fixed.md @@ -1 +1 @@ -Reduce PR CI from 35 to 28 jobs by consolidating validation, Python compatibility, and unpublished wheel builds; prevent YAML batches from rerunning separately routed Python tests. +Reduce PR CI from 35 to 26 jobs by consolidating validation, package builds, and compatibility checks; group Rest and Microsimulation while preserving their separate runners; prevent YAML batches from rerunning separately routed Python tests. diff --git a/policyengine_us/tests/code_health/test_make_test_matches_ci.py b/policyengine_us/tests/code_health/test_make_test_matches_ci.py index cefa797b67a..532945e5cf9 100644 --- a/policyengine_us/tests/code_health/test_make_test_matches_ci.py +++ b/policyengine_us/tests/code_health/test_make_test_matches_ci.py @@ -44,11 +44,20 @@ def make_commands(target: str) -> frozenset: ) +def workflow_jobs(path): + """Include suites in local reusable workflows as well as direct jobs.""" + for job in yaml.safe_load(path.read_text())["jobs"].values(): + if "uses" in job: + assert job["uses"].startswith("./.github/workflows/"), job["uses"] + yield from workflow_jobs(REPO_ROOT / job["uses"]) + else: + yield job + + def ci_suites(): """Every Make target and batch-runner command the PR workflow runs.""" targets, commands = set(), set() - workflow = yaml.safe_load(WORKFLOW.read_text()) - for job in workflow["jobs"].values(): + for job in workflow_jobs(WORKFLOW): matrix = (job.get("strategy") or {}).get("matrix") or {} for entry in matrix.get("include") or []: if "target" in entry: diff --git a/policyengine_us/tests/code_health/test_pr_runner_budget.py b/policyengine_us/tests/code_health/test_pr_runner_budget.py index e9d5e02e7ef..30c45baa55c 100644 --- a/policyengine_us/tests/code_health/test_pr_runner_budget.py +++ b/policyengine_us/tests/code_health/test_pr_runner_budget.py @@ -7,6 +7,10 @@ ROOT = Path(__file__).resolve().parents[3] JOBS = yaml.safe_load((ROOT / ".github/workflows/pr.yaml").read_text())["jobs"] +PYTHON_WORKFLOW = yaml.safe_load( + (ROOT / ".github/workflows/python-tests.yaml").read_text() +) +PYTHON_JOBS = PYTHON_WORKFLOW["jobs"] KEEP_RUNNING = "${{ !cancelled() }}" @@ -26,13 +30,17 @@ def calls(job, name): def test_pr_runner_budget_keeps_the_heavy_suites_separate(): counts = {} - for name, job in JOBS.items(): + runner_jobs = {name: job for name, job in JOBS.items() if "uses" not in job} + for name, job in PYTHON_JOBS.items(): + assert name not in runner_jobs + runner_jobs[name] = job + for name, job in runner_jobs.items(): matrix = job.get("strategy", {}).get("matrix", {}) if "include" in matrix: counts[name] = len(matrix["include"]) else: counts[name] = math.prod(len(values) for values in matrix.values()) - assert sum(counts.values()) == 28 + assert sum(counts.values()) == 26 assert { name: counts[name] for name in ("Baseline", "Contrib", "Rest", "Microsimulation") @@ -44,15 +52,39 @@ def test_pr_runner_budget_keeps_the_heavy_suites_separate(): } +def test_python_group_has_two_independent_runners_and_no_extra_trigger(): + callers = [job for job in JOBS.values() if "uses" in job] + assert callers == [ + { + "name": "Python tests", + "needs": "ReleaseLock", + "uses": "./.github/workflows/python-tests.yaml", + } + ] + # PyYAML parses the bare `on` key as True. No push/PR trigger here: + # the draft guard must control the only route to these runners. + assert PYTHON_WORKFLOW[True] == {"workflow_call": None} + assert set(PYTHON_JOBS) == {"Rest", "Microsimulation"} + assert {name: job["timeout-minutes"] for name, job in PYTHON_JOBS.items()} == { + "Rest": 90, + "Microsimulation": 60, + } + for job in PYTHON_JOBS.values(): + assert job["runs-on"] == "ubuntu-latest" + assert not job.get("needs") + assert not job.get("strategy") + assert not job.get("continue-on-error") + + def test_sequential_variants_preserve_failures_and_allow_later_results(): for job, name, key, expected in ( ( - "Python-Compat", + "PackageCompatibility", "python-compat", "python-version", ["3.11", "3.12", "3.13", "3.14"], ), - ("CandidateWheel", "candidate-wheel", "kind", ["rc1", "final"]), + ("PackageCompatibility", "candidate-wheel", "kind", ["rc1", "final"]), ): variants = calls(job, name) assert [step["with"][key] for step in variants] == expected @@ -63,12 +95,20 @@ def test_sequential_variants_preserve_failures_and_allow_later_results(): not step.get("continue-on-error") for step in JOBS[job]["steps"] + action(name) ) + # A wheel failure must not suppress metadata or any Python version. + checks = [ + step + for step in JOBS["PackageCompatibility"]["steps"] + if step.get("uses", "").startswith("./.github/actions/") + ] + assert len(checks) == 7 + assert all(step["if"] == KEEP_RUNNING for step in checks[1:]) def test_variants_use_distinct_clean_checkouts_for_locks_environments_and_outputs(): for job, name, key in ( - ("Python-Compat", "python-compat", "python-version"), - ("CandidateWheel", "candidate-wheel", "kind"), + ("PackageCompatibility", "python-compat", "python-version"), + ("PackageCompatibility", "candidate-wheel", "kind"), ): steps = action(name) checkout = steps[0] @@ -101,6 +141,25 @@ def test_variants_use_distinct_clean_checkouts_for_locks_environments_and_output ) +def test_bundle_contract_keeps_its_dependency_and_separate_environment(): + (call,) = calls("PackageCompatibility", "bundle-metadata") + assert call["if"] == KEEP_RUNNING + steps = action("bundle-metadata") + assert steps[0]["with"] == {"path": "bundle-metadata"} + assert steps[1]["with"]["python-version"] == "3.14" + runs = [step for step in steps if "run" in step] + assert all(step["working-directory"] == "bundle-metadata" for step in runs) + assert [step["run"] for step in runs] == [ + "uv lock --upgrade-package policyengine-core", + "uv sync --locked --extra dev", + 'uv pip install --python .venv/bin/python "policyengine-bundles @ ' + "git+https://github.com/PolicyEngine/policyengine-bundles@" + '8ae9f56fefcf89f69b8a7e3bc49928509c6207be"', + "uv run --no-sync python -m pytest policyengine_us/tests/test_build_metadata.py", + ] + assert all(not step.get("continue-on-error") for step in steps) + + def test_every_python_version_keeps_import_and_dataset_semantics_checks(): steps = action("python-compat") commands = [step["run"] for step in steps if "run" in step] diff --git a/policyengine_us/tests/code_health/test_rest_python_groups.py b/policyengine_us/tests/code_health/test_rest_python_groups.py index 5cecc2399f9..cb35d7a6294 100644 --- a/policyengine_us/tests/code_health/test_rest_python_groups.py +++ b/policyengine_us/tests/code_health/test_rest_python_groups.py @@ -39,7 +39,9 @@ REPO = Path(__file__).resolve().parents[3] TESTS = REPO / "policyengine_us" / "tests" -WORKFLOWS = [REPO / ".github" / "workflows" / name for name in ("pr.yaml", "push.yaml")] +WORKFLOWS = [ + REPO / ".github" / "workflows" / name for name in ("python-tests.yaml", "push.yaml") +] # CI runs this file under `make test-other-python-rest REST_REPORT_DIR=.`, and # that make exports these. Passed on, they would carry its flags and report # directory into the make runs below, and the stub run would overwrite the From 770fbaffbdc9d5df90553aef1cd3cc6b572fa6c5 Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 7 Oct 2026 21:39:27 -0400 Subject: [PATCH 4/4] Group Baseline and Contrib jobs in the Actions view --- .github/workflows/baseline-tests.yaml | 61 ++++++++++ .github/workflows/contrib-tests.yaml | 58 ++++++++++ .github/workflows/pr.yaml | 109 +----------------- .../ci-yaml-only-test-batches.fixed.md | 2 +- .../code_health/test_pr_runner_budget.py | 41 ++++--- 5 files changed, 148 insertions(+), 123 deletions(-) create mode 100644 .github/workflows/baseline-tests.yaml create mode 100644 .github/workflows/contrib-tests.yaml diff --git a/.github/workflows/baseline-tests.yaml b/.github/workflows/baseline-tests.yaml new file mode 100644 index 00000000000..b0ab85dcdd9 --- /dev/null +++ b/.github/workflows/baseline-tests.yaml @@ -0,0 +1,61 @@ +name: Baseline tests +on: + workflow_call: +# Group the existing matrix in the Actions view without adding runners. +# The guarded PR workflow is the only caller. +jobs: + Baseline: + name: Full Suite - Baseline (${{ matrix.group }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + PYTHONUNBUFFERED: 1 + PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" + strategy: + fail-fast: false + matrix: + include: + # One state per subprocess; retain four runners and one worker each. + # The former NY/OH/OK process peaked at 15.1 GB (run 36930199045). + - group: states-shard-1 + cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 1/4 + - group: states-shard-2 + cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 2/4 + - group: states-shard-3 + cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 3/4 + - group: states-shard-4 + cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 4/4 + - group: irs + cmd: make test-yaml-no-structural-other-irs + - group: household + cmd: make test-yaml-no-structural-other-household + - group: ssa-usda + cmd: make test-yaml-no-structural-other-ssa-usda + - group: rest-a + cmd: make test-yaml-no-structural-other-rest-a + - group: rest-b + cmd: make test-yaml-no-structural-other-rest-b + - group: contrib-hhs + cmd: make test-yaml-contrib-hhs + - group: reform + cmd: make test-yaml-reform + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.14 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Resolve latest compatible PolicyEngine Core + run: uv lock --upgrade-package policyengine-core + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Turn off default branching + shell: bash + run: bash ./update_itemization.sh + - name: Run Baseline YAML tests (${{ matrix.group }}) + run: uv run --no-sync ${{ matrix.cmd }} diff --git a/.github/workflows/contrib-tests.yaml b/.github/workflows/contrib-tests.yaml new file mode 100644 index 00000000000..cd1c80fcd32 --- /dev/null +++ b/.github/workflows/contrib-tests.yaml @@ -0,0 +1,58 @@ +name: Contrib tests +on: + workflow_call: +# Group the existing matrix in the Actions view without adding runners. +# The guarded PR workflow is the only caller. +jobs: + Contrib: + name: Full Suite - Contrib (${{ matrix.group }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + PYTHONUNBUFFERED: 1 + PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" + strategy: + fail-fast: false + matrix: + include: + - group: states-shard-1 + target: test-yaml-structural-heavy-shard-1 + - group: states-shard-2 + target: test-yaml-structural-heavy-shard-2 + - group: states-shard-3 + target: test-yaml-structural-heavy-shard-3 + - group: states-shard-4 + target: test-yaml-structural-heavy-shard-4 + - group: other-shard-1 + target: test-yaml-structural-other + - group: other-shard-2a + target: test-yaml-structural-other-shard-2a + - group: other-shard-2b + target: test-yaml-structural-other-shard-2b + - group: other-shard-3 + target: test-yaml-structural-other-shard-3 + - group: congress + target: test-yaml-structural-congress + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.14 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.13" + - name: Resolve latest compatible PolicyEngine Core + run: uv lock --upgrade-package policyengine-core + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Turn off default branching + shell: bash + run: bash ./update_itemization.sh + - name: Run Contrib YAML tests (${{ matrix.group }}) + run: uv run --no-sync make ${{ matrix.target }} + - name: Run Contrib Python tests + if: ${{ matrix.group == 'other-shard-1' }} + run: uv run --no-sync make test-policy-contrib-python diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e483c936439..a65ddef67d9 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -243,61 +243,9 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} verbose: true Baseline: + name: Baseline tests needs: ReleaseLock - name: Full Suite - Baseline (${{ matrix.group }}) - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - PYTHONUNBUFFERED: 1 - PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" - strategy: - fail-fast: false - matrix: - include: - # One state per subprocess; retain four runners and one worker each. - # The former NY/OH/OK process peaked at 15.1 GB (run 36930199045). - - group: states-shard-1 - cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 1/4 - - group: states-shard-2 - cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 2/4 - - group: states-shard-3 - cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 3/4 - - group: states-shard-4 - cmd: python policyengine_us/tests/test_batched.py policyengine_us/tests/policy/baseline/gov/states --mode per-subdir --workers 1 --shard 4/4 - - group: irs - cmd: make test-yaml-no-structural-other-irs - - group: household - cmd: make test-yaml-no-structural-other-household - - group: ssa-usda - cmd: make test-yaml-no-structural-other-ssa-usda - - group: rest-a - cmd: make test-yaml-no-structural-other-rest-a - - group: rest-b - cmd: make test-yaml-no-structural-other-rest-b - - group: contrib-hhs - cmd: make test-yaml-contrib-hhs - - group: reform - cmd: make test-yaml-reform - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.14 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install dependencies - run: uv sync --locked --extra dev - - name: Turn off default branching - shell: bash - run: bash ./update_itemization.sh - - name: Run Baseline YAML tests (${{ matrix.group }}) - run: uv run --no-sync ${{ matrix.cmd }} + uses: ./.github/workflows/baseline-tests.yaml HouseholdAPIPartners: needs: ReleaseLock name: Household API Partners @@ -327,58 +275,9 @@ jobs: - name: Run Household API Partners YAML tests run: uv run --no-sync make test-yaml-no-structural-other-partners Contrib: + name: Contrib tests needs: ReleaseLock - name: Full Suite - Contrib (${{ matrix.group }}) - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - PYTHONUNBUFFERED: 1 - PYTEST_ADDOPTS: "--durations=25 -p no:unraisableexception -p no:threadexception" - strategy: - fail-fast: false - matrix: - include: - - group: states-shard-1 - target: test-yaml-structural-heavy-shard-1 - - group: states-shard-2 - target: test-yaml-structural-heavy-shard-2 - - group: states-shard-3 - target: test-yaml-structural-heavy-shard-3 - - group: states-shard-4 - target: test-yaml-structural-heavy-shard-4 - - group: other-shard-1 - target: test-yaml-structural-other - - group: other-shard-2a - target: test-yaml-structural-other-shard-2a - - group: other-shard-2b - target: test-yaml-structural-other-shard-2b - - group: other-shard-3 - target: test-yaml-structural-other-shard-3 - - group: congress - target: test-yaml-structural-congress - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.14 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - with: - version: "0.12.13" - - name: Resolve latest compatible PolicyEngine Core - run: uv lock --upgrade-package policyengine-core - - name: Install dependencies - run: uv sync --locked --extra dev - - name: Turn off default branching - shell: bash - run: bash ./update_itemization.sh - - name: Run Contrib YAML tests (${{ matrix.group }}) - run: uv run --no-sync make ${{ matrix.target }} - - name: Run Contrib Python tests - if: ${{ matrix.group == 'other-shard-1' }} - run: uv run --no-sync make test-policy-contrib-python + uses: ./.github/workflows/contrib-tests.yaml PythonTests: name: Python tests needs: ReleaseLock diff --git a/changelog.d/ci-yaml-only-test-batches.fixed.md b/changelog.d/ci-yaml-only-test-batches.fixed.md index c29ab24daad..944c5b6c2f0 100644 --- a/changelog.d/ci-yaml-only-test-batches.fixed.md +++ b/changelog.d/ci-yaml-only-test-batches.fixed.md @@ -1 +1 @@ -Reduce PR CI from 35 to 26 jobs by consolidating validation, package builds, and compatibility checks; group Rest and Microsimulation while preserving their separate runners; prevent YAML batches from rerunning separately routed Python tests. +Reduce PR CI from 35 to 26 jobs by consolidating validation, package builds, and compatibility checks; group Baseline, Contrib, and Python suites while preserving their separate runners; prevent YAML batches from rerunning separately routed Python tests. diff --git a/policyengine_us/tests/code_health/test_pr_runner_budget.py b/policyengine_us/tests/code_health/test_pr_runner_budget.py index 30c45baa55c..ef6f6197167 100644 --- a/policyengine_us/tests/code_health/test_pr_runner_budget.py +++ b/policyengine_us/tests/code_health/test_pr_runner_budget.py @@ -7,10 +7,12 @@ ROOT = Path(__file__).resolve().parents[3] JOBS = yaml.safe_load((ROOT / ".github/workflows/pr.yaml").read_text())["jobs"] -PYTHON_WORKFLOW = yaml.safe_load( - (ROOT / ".github/workflows/python-tests.yaml").read_text() -) -PYTHON_JOBS = PYTHON_WORKFLOW["jobs"] +GROUP_WORKFLOWS = { + name: yaml.safe_load((ROOT / job["uses"]).read_text()) + for name, job in JOBS.items() + if "uses" in job +} +PYTHON_JOBS = GROUP_WORKFLOWS["PythonTests"]["jobs"] KEEP_RUNNING = "${{ !cancelled() }}" @@ -31,9 +33,10 @@ def calls(job, name): def test_pr_runner_budget_keeps_the_heavy_suites_separate(): counts = {} runner_jobs = {name: job for name, job in JOBS.items() if "uses" not in job} - for name, job in PYTHON_JOBS.items(): - assert name not in runner_jobs - runner_jobs[name] = job + for workflow in GROUP_WORKFLOWS.values(): + for name, job in workflow["jobs"].items(): + assert name not in runner_jobs + runner_jobs[name] = job for name, job in runner_jobs.items(): matrix = job.get("strategy", {}).get("matrix", {}) if "include" in matrix: @@ -52,18 +55,22 @@ def test_pr_runner_budget_keeps_the_heavy_suites_separate(): } -def test_python_group_has_two_independent_runners_and_no_extra_trigger(): - callers = [job for job in JOBS.values() if "uses" in job] - assert callers == [ - { - "name": "Python tests", +def test_suite_groups_have_no_extra_runners_or_triggers(): + groups = { + "Baseline": ("Baseline tests", "baseline-tests.yaml"), + "Contrib": ("Contrib tests", "contrib-tests.yaml"), + "PythonTests": ("Python tests", "python-tests.yaml"), + } + assert set(GROUP_WORKFLOWS) == set(groups) + for name, (label, file) in groups.items(): + assert JOBS[name] == { + "name": label, "needs": "ReleaseLock", - "uses": "./.github/workflows/python-tests.yaml", + "uses": f"./.github/workflows/{file}", } - ] - # PyYAML parses the bare `on` key as True. No push/PR trigger here: - # the draft guard must control the only route to these runners. - assert PYTHON_WORKFLOW[True] == {"workflow_call": None} + # PyYAML parses the bare `on` key as True. No push/PR trigger here: + # the draft guard must control the only route to these runners. + assert GROUP_WORKFLOWS[name][True] == {"workflow_call": None} assert set(PYTHON_JOBS) == {"Rest", "Microsimulation"} assert {name: job["timeout-minutes"] for name, job in PYTHON_JOBS.items()} == { "Rest": 90,