|
7 | 7 | branches: [main] |
8 | 8 |
|
9 | 9 | jobs: |
10 | | - test: |
| 10 | + changes: |
| 11 | + runs-on: ubuntu-latest |
| 12 | + outputs: |
| 13 | + shared: ${{ steps.filter.outputs.shared }} |
| 14 | + us: ${{ steps.filter.outputs.us }} |
| 15 | + uk: ${{ steps.filter.outputs.uk }} |
| 16 | + steps: |
| 17 | + - uses: actions/checkout@v4 |
| 18 | + - name: Classify changed paths |
| 19 | + id: filter |
| 20 | + env: |
| 21 | + GITHUB_TOKEN: ${{ github.token }} |
| 22 | + run: | |
| 23 | + python3 - <<'PY' |
| 24 | + from __future__ import annotations |
| 25 | +
|
| 26 | + import fnmatch |
| 27 | + import json |
| 28 | + import os |
| 29 | + import subprocess |
| 30 | + import urllib.request |
| 31 | +
|
| 32 | + event_name = os.environ["GITHUB_EVENT_NAME"] |
| 33 | + event = json.loads(open(os.environ["GITHUB_EVENT_PATH"]).read()) |
| 34 | + repo = os.environ["GITHUB_REPOSITORY"] |
| 35 | + token = os.environ["GITHUB_TOKEN"] |
| 36 | +
|
| 37 | + def api_json(url: str) -> object: |
| 38 | + request = urllib.request.Request( |
| 39 | + url, |
| 40 | + headers={ |
| 41 | + "Accept": "application/vnd.github+json", |
| 42 | + "Authorization": f"Bearer {token}", |
| 43 | + "X-GitHub-Api-Version": "2022-11-28", |
| 44 | + }, |
| 45 | + ) |
| 46 | + with urllib.request.urlopen(request) as response: |
| 47 | + return json.loads(response.read()) |
| 48 | +
|
| 49 | + def pull_request_files() -> list[str]: |
| 50 | + number = event["pull_request"]["number"] |
| 51 | + files: list[str] = [] |
| 52 | + page = 1 |
| 53 | + while True: |
| 54 | + payload = api_json( |
| 55 | + f"https://api.github.com/repos/{repo}/pulls/{number}/files" |
| 56 | + f"?per_page=100&page={page}" |
| 57 | + ) |
| 58 | + if not payload: |
| 59 | + return files |
| 60 | + files.extend(item["filename"] for item in payload) |
| 61 | + page += 1 |
| 62 | +
|
| 63 | + def push_files() -> list[str]: |
| 64 | + before = event.get("before") |
| 65 | + after = event.get("after") |
| 66 | + if before and after and set(before) != {"0"}: |
| 67 | + payload = api_json( |
| 68 | + f"https://api.github.com/repos/{repo}/compare/{before}...{after}" |
| 69 | + ) |
| 70 | + return [item["filename"] for item in payload.get("files", [])] |
| 71 | + output = subprocess.check_output( |
| 72 | + ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", after], |
| 73 | + text=True, |
| 74 | + ) |
| 75 | + return [line for line in output.splitlines() if line] |
| 76 | +
|
| 77 | + changed = pull_request_files() if event_name == "pull_request" else push_files() |
| 78 | +
|
| 79 | + filters = { |
| 80 | + "shared": { |
| 81 | + "include": [ |
| 82 | + "uv.lock", |
| 83 | + "pyproject.toml", |
| 84 | + "packages/*/pyproject.toml", |
| 85 | + "conftest.py", |
| 86 | + # Every shard's src, not just build+frame: microcosm-build |
| 87 | + # depends on frame, fit, and calibrate (and on data via the |
| 88 | + # us extra), and 19 country test files import calibrate/fit |
| 89 | + # — e.g. uk_runtime/diagnostics.py imports microcosm.calibrate. |
| 90 | + # The country-runtime excludes below keep country-scoped |
| 91 | + # changes classifying narrowly. |
| 92 | + "packages/*/src/**", |
| 93 | + # The quiet shards' tests execute in the engine tier (us-am), |
| 94 | + # so a change to them must open that lane. fnmatch has no |
| 95 | + # brace expansion, hence three lines. |
| 96 | + "packages/microcosm-calibrate/tests/**", |
| 97 | + "packages/microcosm-data/tests/**", |
| 98 | + "packages/microcosm-fit/tests/**", |
| 99 | + "specs/**", |
| 100 | + ".github/workflows/**", |
| 101 | + "tools/**", |
| 102 | + ], |
| 103 | + "exclude": [ |
| 104 | + "packages/microcosm-build/src/microcosm/build/us_runtime/**", |
| 105 | + "packages/microcosm-build/src/microcosm/build/uk_runtime/**", |
| 106 | + "tools/*us*", |
| 107 | + "tools/*uk*", |
| 108 | + ], |
| 109 | + }, |
| 110 | + "us": { |
| 111 | + "include": [ |
| 112 | + "build/us/**", |
| 113 | + "packages/microcosm-build/src/microcosm/build/us_runtime/**", |
| 114 | + "packages/microcosm-build/tests/test_us_*", |
| 115 | + "packages/microcosm-frame/tests/test_policyengine_us_*", |
| 116 | + "packages/microcosm-frame/tests/test_rules_engine_contract.py", |
| 117 | + "tools/*us*", |
| 118 | + ], |
| 119 | + "exclude": [], |
| 120 | + }, |
| 121 | + "uk": { |
| 122 | + "include": [ |
| 123 | + "build/uk/**", |
| 124 | + "packages/microcosm-build/src/microcosm/build/uk_runtime/**", |
| 125 | + "packages/microcosm-build/tests/test_uk_*", |
| 126 | + "packages/microcosm-frame/tests/test_policyengine_uk_adapter.py", |
| 127 | + "tools/*uk*", |
| 128 | + ], |
| 129 | + "exclude": [], |
| 130 | + }, |
| 131 | + } |
| 132 | +
|
| 133 | + def matches(path: str, include: list[str], exclude: list[str]) -> bool: |
| 134 | + included = any(fnmatch.fnmatch(path, pattern) for pattern in include) |
| 135 | + excluded = any(fnmatch.fnmatch(path, pattern) for pattern in exclude) |
| 136 | + return included and not excluded |
| 137 | +
|
| 138 | + outputs = { |
| 139 | + name: any( |
| 140 | + matches(path, spec["include"], spec["exclude"]) |
| 141 | + for path in changed |
| 142 | + ) |
| 143 | + for name, spec in filters.items() |
| 144 | + } |
| 145 | + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: |
| 146 | + for name, value in outputs.items(): |
| 147 | + print(f"{name}={str(value).lower()}", file=output) |
| 148 | + print("changed paths:") |
| 149 | + for path in changed: |
| 150 | + print(path) |
| 151 | + print("filters:", outputs) |
| 152 | + PY |
| 153 | +
|
| 154 | + lint: |
| 155 | + runs-on: ubuntu-latest |
| 156 | + steps: |
| 157 | + - uses: actions/checkout@v4 |
| 158 | + - uses: astral-sh/setup-uv@v6 |
| 159 | + with: |
| 160 | + python-version: "3.13" |
| 161 | + - name: Verify CI test groups |
| 162 | + # python3 (not bare `python`): guaranteed on the Ubuntu image, and this |
| 163 | + # step runs before any sync so it must hold with the stdlib alone. |
| 164 | + run: python3 tools/ci_test_groups.py --verify |
| 165 | + - name: Sync workspace |
| 166 | + run: uv sync --all-packages --locked |
| 167 | + - name: Lint |
| 168 | + run: uv run --no-sync ruff check . |
| 169 | + |
| 170 | + fast: |
11 | 171 | runs-on: ubuntu-latest |
12 | 172 | strategy: |
| 173 | + fail-fast: false |
13 | 174 | matrix: |
14 | 175 | python-version: ["3.13", "3.14"] |
| 176 | + group: [trade, spine-uk, rest] |
15 | 177 | steps: |
16 | 178 | - uses: actions/checkout@v4 |
17 | 179 | - uses: astral-sh/setup-uv@v6 |
18 | 180 | with: |
19 | 181 | python-version: ${{ matrix.python-version }} |
20 | | - - name: Sync workspace with US engine metadata |
21 | | - # The pool-input sweeps are live PolicyEngine-US ownership and consumer |
22 | | - # existence guards. Installing the US extra makes an upstream formula or |
23 | | - # consumer change fail here instead of being skipped until a remote build. |
24 | | - run: uv sync --all-packages --extra us |
25 | | - - name: Behavioral contract suite + unit tests |
26 | | - # One pytest process per shard. A single process accumulates the |
27 | | - # engine plus every shard's fixtures and repeatedly OOM-killed the |
28 | | - # 7 GB runner at ~91% (the runner-shutdown signature, three runs in |
29 | | - # a row); per-shard processes bound residency and give per-shard |
30 | | - # attribution if one shard ever outgrows the runner on its own. |
| 182 | + - name: Sync workspace without engines |
| 183 | + run: uv sync --all-packages --locked |
| 184 | + - name: Engine-free tests (${{ matrix.group }}) |
31 | 185 | run: | |
32 | | - for shard in packages/*/; do |
33 | | - uv run pytest "${shard}tests" -p no:cacheprovider |
34 | | - done |
35 | | - - name: Lint |
36 | | - run: uv run ruff check . |
| 186 | + mapfile -t files < <(uv run --no-sync python tools/ci_test_groups.py --list "${{ matrix.group }}") |
| 187 | + /usr/bin/time -v uv run --no-sync pytest "${files[@]}" -p no:cacheprovider |
| 188 | +
|
| 189 | + engine-shared: |
| 190 | + # Deliberately does NOT depend on `changes`: it reads no classifier output, |
| 191 | + # and a dependency-skip (the inline script makes live API calls with no |
| 192 | + # retry) would take the unconditional engine lane down with it. |
| 193 | + runs-on: ubuntu-latest |
| 194 | + strategy: |
| 195 | + fail-fast: false |
| 196 | + matrix: |
| 197 | + python-version: ["3.13", "3.14"] |
| 198 | + steps: |
| 199 | + - uses: actions/checkout@v4 |
| 200 | + - uses: astral-sh/setup-uv@v6 |
| 201 | + with: |
| 202 | + python-version: ${{ matrix.python-version }} |
| 203 | + - name: Sync workspace with US and UK engines |
| 204 | + run: uv sync --all-packages --locked --extra us --extra uk |
| 205 | + - name: Shared/spec engine tests |
| 206 | + run: | |
| 207 | + mapfile -t files < <(uv run --no-sync python tools/ci_test_groups.py --list shared-spec) |
| 208 | + /usr/bin/time -v uv run --no-sync pytest "${files[@]}" -p no:cacheprovider |
| 209 | +
|
| 210 | + engine-us: |
| 211 | + needs: changes |
| 212 | + if: github.event_name == 'push' || needs.changes.outputs.us == 'true' || needs.changes.outputs.shared == 'true' |
| 213 | + runs-on: ubuntu-latest |
| 214 | + strategy: |
| 215 | + fail-fast: false |
| 216 | + matrix: |
| 217 | + python-version: ["3.13", "3.14"] |
| 218 | + group: [us-p, us-qs, us-not, us-am] |
| 219 | + steps: |
| 220 | + - uses: actions/checkout@v4 |
| 221 | + - uses: astral-sh/setup-uv@v6 |
| 222 | + with: |
| 223 | + python-version: ${{ matrix.python-version }} |
| 224 | + - name: Sync workspace with US and UK engines |
| 225 | + run: uv sync --all-packages --locked --extra us --extra uk |
| 226 | + - name: US engine tests (${{ matrix.group }}) |
| 227 | + run: | |
| 228 | + while IFS= read -r proc; do |
| 229 | + mapfile -t files < <(uv run --no-sync python tools/ci_test_groups.py --list "${{ matrix.group }}:${proc}") |
| 230 | + /usr/bin/time -v uv run --no-sync pytest "${files[@]}" -p no:cacheprovider |
| 231 | + done < <(uv run --no-sync python tools/ci_test_groups.py --procs "${{ matrix.group }}") |
| 232 | +
|
| 233 | + engine-uk: |
| 234 | + needs: changes |
| 235 | + if: github.event_name == 'push' || needs.changes.outputs.uk == 'true' || needs.changes.outputs.shared == 'true' |
| 236 | + runs-on: ubuntu-latest |
| 237 | + strategy: |
| 238 | + fail-fast: false |
| 239 | + matrix: |
| 240 | + python-version: ["3.13", "3.14"] |
| 241 | + group: [uk] |
| 242 | + steps: |
| 243 | + - uses: actions/checkout@v4 |
| 244 | + - uses: astral-sh/setup-uv@v6 |
| 245 | + with: |
| 246 | + python-version: ${{ matrix.python-version }} |
| 247 | + - name: Sync workspace with US and UK engines |
| 248 | + run: uv sync --all-packages --locked --extra us --extra uk |
| 249 | + - name: UK engine tests |
| 250 | + run: | |
| 251 | + while IFS= read -r proc; do |
| 252 | + mapfile -t files < <(uv run --no-sync python tools/ci_test_groups.py --list "uk:${proc}") |
| 253 | + /usr/bin/time -v uv run --no-sync pytest "${files[@]}" -p no:cacheprovider |
| 254 | + done < <(uv run --no-sync python tools/ci_test_groups.py --procs uk) |
37 | 255 |
|
38 | 256 | wheels: |
39 | 257 | # The charter-mandated packaging gate: editable installs hide packaging |
|
43 | 261 | # semantics, and run the suite against the installed constellation. |
44 | 262 | runs-on: ubuntu-latest |
45 | 263 | strategy: |
| 264 | + fail-fast: false |
46 | 265 | matrix: |
47 | 266 | python-version: ["3.13", "3.14"] |
48 | 267 | steps: |
@@ -106,5 +325,39 @@ jobs: |
106 | 325 | run: env -u PYTHONPATH /tmp/wheels-venv/bin/python -I tools/spec_envelope_digests.py be uk |
107 | 326 | - name: Run the suite against the installed wheels |
108 | 327 | run: | |
109 | | - env -u PYTHONPATH /tmp/wheels-venv/bin/python -I -m pytest \ |
| 328 | + /usr/bin/time -v env -u PYTHONPATH /tmp/wheels-venv/bin/python -I -m pytest \ |
110 | 329 | packages/*/tests -p no:cacheprovider |
| 330 | +
|
| 331 | + ci-ok: |
| 332 | + # The single aggregate context for branch protection. Per-leg matrix names |
| 333 | + # cannot be required: `jobs.<job_id>.if` is evaluated before the matrix |
| 334 | + # expands, so a skipped lane reports one bare `engine-us` check and the |
| 335 | + # per-leg contexts are never created — a required `engine-us (3.13, us-p)` |
| 336 | + # would wait forever on a UK-only PR. This job also closes the seam where a |
| 337 | + # dependency-skip lets a failed `changes` take the engine lanes down without |
| 338 | + # any required check going red: a lane skip is accepted only when |
| 339 | + # classification itself succeeded. |
| 340 | + needs: [changes, lint, fast, engine-shared, engine-us, engine-uk, wheels] |
| 341 | + if: always() |
| 342 | + runs-on: ubuntu-latest |
| 343 | + steps: |
| 344 | + - name: Require every lane to have passed (or legitimately skipped) |
| 345 | + run: | |
| 346 | + set -euo pipefail |
| 347 | + fail=0 |
| 348 | + require_success() { |
| 349 | + if [ "$2" != "success" ]; then echo "::error::$1 = $2 (expected success)"; fail=1; fi |
| 350 | + } |
| 351 | + require_success changes "${{ needs.changes.result }}" |
| 352 | + require_success lint "${{ needs.lint.result }}" |
| 353 | + require_success fast "${{ needs.fast.result }}" |
| 354 | + require_success engine-shared "${{ needs['engine-shared'].result }}" |
| 355 | + require_success wheels "${{ needs.wheels.result }}" |
| 356 | + for lane in "engine-us:${{ needs['engine-us'].result }}" "engine-uk:${{ needs['engine-uk'].result }}"; do |
| 357 | + name="${lane%%:*}"; result="${lane#*:}" |
| 358 | + case "$result" in |
| 359 | + success|skipped) echo "$name = $result" ;; |
| 360 | + *) echo "::error::$name = $result (expected success or skipped)"; fail=1 ;; |
| 361 | + esac |
| 362 | + done |
| 363 | + exit "$fail" |
0 commit comments