Skip to content

Commit b79d83e

Browse files
committed
Merge branch 'dev' into feat/feasibility-check
# Conflicts: # pyproject.toml
2 parents 1841d08 + 483ead2 commit b79d83e

47 files changed

Lines changed: 2323 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ci/compute_matrix.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# ruff: noqa: INP001
2+
# .ci/ is a top-level script directory invoked by GitHub Actions, not a
3+
# Python package, so no __init__.py here (mirrors .ci/warm_hf_cache.py).
4+
"""Decide test matrix scope and emit it to ``GITHUB_OUTPUT``.
5+
6+
Full matrix runs on push to ``dev`` and on PRs labeled ``full-ci``.
7+
Otherwise (default PR commits) only ubuntu-latest + Python 3.14 runs.
8+
On ``workflow_dispatch`` (the manual coverage run) a single
9+
ubuntu-latest + ``DISPATCH_PYTHON`` combo runs: coverage is the union of
10+
lines exercised, so it is OS/Python-independent and one combo keeps the
11+
manual job cheap.
12+
13+
Inputs come from environment variables:
14+
15+
* ``EVENT_NAME`` - the GitHub event name (``push`` / ``pull_request`` /
16+
``workflow_dispatch``).
17+
* ``LABELS_JSON`` - ``toJSON(github.event.pull_request.labels.*.name)``
18+
from the workflow; ``null`` / missing on non-PR events.
19+
* ``DISPATCH_PYTHON`` - Python version for the ``workflow_dispatch`` combo;
20+
falls back to ``DISPATCH_DEFAULT_PYTHON`` when unset/empty.
21+
* ``GITHUB_OUTPUT`` - file the runner reads to pick up step outputs.
22+
23+
The script writes ``matrix``, ``warm_os`` and ``full`` to that output
24+
file and mirrors them to the log for debuggability.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import json
30+
import logging
31+
import os
32+
import sys
33+
from pathlib import Path
34+
35+
FULL_MATRIX = {
36+
"os": ["ubuntu-latest"],
37+
"python-version": ["3.10", "3.11", "3.12", "3.13", "3.14"],
38+
"include": [{"os": "windows-latest", "python-version": "3.10"}],
39+
}
40+
41+
MINIMAL_MATRIX = {
42+
"os": ["ubuntu-latest"],
43+
"python-version": ["3.14"],
44+
}
45+
46+
FULL_CI_LABEL = "full-ci"
47+
48+
DISPATCH_EVENT = "workflow_dispatch"
49+
DISPATCH_DEFAULT_PYTHON = "3.12"
50+
51+
52+
def dispatch_matrix(python_version: str) -> dict:
53+
"""Return the single-combo matrix for the manual coverage run."""
54+
return {"os": ["ubuntu-latest"], "python-version": [python_version or DISPATCH_DEFAULT_PYTHON]}
55+
56+
57+
logger = logging.getLogger("compute_matrix")
58+
59+
60+
def collect_os_list(matrix: dict) -> list[str]:
61+
"""Return the unique runner OSes referenced by ``matrix`` (base + includes)."""
62+
seen: list[str] = []
63+
for entry in matrix.get("os", []):
64+
if entry not in seen:
65+
seen.append(entry)
66+
for include in matrix.get("include", []):
67+
entry = include.get("os")
68+
if entry and entry not in seen:
69+
seen.append(entry)
70+
return seen
71+
72+
73+
def is_full(event_name: str, labels: list[str]) -> bool:
74+
"""Return True iff this run should fan out across the full OS/Python matrix."""
75+
# Any push that reaches this workflow is a push to `dev` (ci.yaml pins
76+
# on.push.branches: [dev]), so the branch is implied and not re-checked
77+
# here. If more push branches are ever added there, revisit this.
78+
if event_name == "push":
79+
return True
80+
return FULL_CI_LABEL in labels
81+
82+
83+
def parse_labels(raw: str) -> list[str]:
84+
"""Parse the ``LABELS_JSON`` env var into a list of label names.
85+
86+
Returns an empty list when the value is missing, ``"null"`` (the
87+
``toJSON`` rendering of a missing PR object), malformed, or not a
88+
JSON array of strings.
89+
"""
90+
if not raw:
91+
return []
92+
try:
93+
decoded = json.loads(raw)
94+
except json.JSONDecodeError:
95+
return []
96+
if not isinstance(decoded, list):
97+
return []
98+
return [item for item in decoded if isinstance(item, str)]
99+
100+
101+
def main() -> int:
102+
"""Compute matrix from env, log a summary, write outputs; return exit code."""
103+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
104+
105+
event_name = os.environ.get("EVENT_NAME", "")
106+
labels = parse_labels(os.environ.get("LABELS_JSON", ""))
107+
108+
if event_name == DISPATCH_EVENT:
109+
full = False
110+
matrix = dispatch_matrix(os.environ.get("DISPATCH_PYTHON", ""))
111+
else:
112+
full = is_full(event_name, labels)
113+
matrix = FULL_MATRIX if full else MINIMAL_MATRIX
114+
warm_os = collect_os_list(matrix)
115+
116+
payload = {
117+
"matrix": json.dumps(matrix),
118+
"warm_os": json.dumps(warm_os),
119+
"full": "true" if full else "false",
120+
}
121+
122+
logger.info("event_name=%s", event_name)
123+
logger.info("labels=%s", labels)
124+
logger.info("full=%s", full)
125+
logger.info("matrix=%s", payload["matrix"])
126+
logger.info("warm_os=%s", payload["warm_os"])
127+
128+
output_path = os.environ.get("GITHUB_OUTPUT")
129+
if not output_path:
130+
logger.error("GITHUB_OUTPUT is not set; cannot emit step outputs")
131+
return 1
132+
lines = "".join(f"{key}={value}\n" for key, value in payload.items())
133+
with Path(output_path).open("a", encoding="utf-8") as fh:
134+
fh.write(lines)
135+
return 0
136+
137+
138+
if __name__ == "__main__":
139+
sys.exit(main())

.ci/coverage_report.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# ruff: noqa: INP001
2+
# .ci/ is a top-level script directory invoked by GitHub Actions, not a
3+
# Python package, so no __init__.py here (mirrors .ci/compute_matrix.py).
4+
"""Combine per-group coverage data files and publish a combined report.
5+
6+
The coverage run (``ci.yaml`` dispatched with ``coverage=true``) has every test
7+
job upload a ``.coverage.<group>`` data file as an artifact. This script runs in
8+
the ``coverage-report`` job after those artifacts are downloaded into the
9+
working directory. It combines them into one dataset, prints the table to the
10+
job log, writes ``coverage.xml`` + an HTML report (uploaded as an artifact), and
11+
appends a compact summary to ``GITHUB_STEP_SUMMARY`` so the total is visible in
12+
the Actions UI without opening the logs.
13+
14+
Run via ``uv run --no-project --with 'coverage[toml]' python .ci/coverage_report.py``;
15+
coverage settings are read from ``[tool.coverage.*]`` in ``pyproject.toml``.
16+
17+
The script also enforces a regression floor on the *combined* total
18+
(``MIN_TOTAL_COVERAGE``): a dispatch whose total drops below it fails this job.
19+
The threshold lives here rather than in ``[tool.coverage.report] fail_under``
20+
on purpose — pytest-cov reads that key, and each per-job ``--cov`` run measures
21+
only a slice of the package, so a config-level ``fail_under`` would fail every
22+
partial run. Enforcing here gates the combined total only.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import io
28+
import logging
29+
import os
30+
import sys
31+
from pathlib import Path
32+
33+
import coverage
34+
35+
logger = logging.getLogger("coverage_report")
36+
37+
# Minimum acceptable combined coverage (%). Bump this as coverage improves to
38+
# ratchet the floor up; keep it a few points below the current total so normal
39+
# churn doesn't trip it.
40+
MIN_TOTAL_COVERAGE = 85.0
41+
42+
43+
def main() -> int:
44+
"""Combine coverage data, emit reports, write the GitHub step summary, gate the total."""
45+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
46+
47+
cov = coverage.Coverage()
48+
cov.combine() # merge the downloaded `.coverage.*` files into the data file
49+
cov.load()
50+
51+
total = cov.report() # full table (config `show_missing`) -> job log
52+
cov.xml_report()
53+
cov.html_report()
54+
55+
logger.info("Total coverage: %.2f%%", total)
56+
passed = total >= MIN_TOTAL_COVERAGE
57+
58+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
59+
if summary_path:
60+
compact = io.StringIO()
61+
cov.report(file=compact, show_missing=False)
62+
gate_icon = "✅" if passed else "❌"
63+
gate_line = f"{gate_icon} Gate: {total:.2f}% vs {MIN_TOTAL_COVERAGE:.2f}% minimum\n"
64+
body = f"### Test coverage: {total:.2f}%\n\n{gate_line}\n```\n{compact.getvalue()}```\n"
65+
with Path(summary_path).open("a", encoding="utf-8") as fh:
66+
fh.write(body)
67+
68+
if not passed:
69+
logger.error("Coverage %.2f%% is below the required minimum of %.2f%%", total, MIN_TOTAL_COVERAGE)
70+
return 1
71+
72+
return 0
73+
74+
75+
if __name__ == "__main__":
76+
sys.exit(main())

.github/workflows/build-docs.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ on:
1010
pull_request:
1111
branches:
1212
- dev
13+
# `labeled` lets adding the `full-ci` label re-trigger the docs build on
14+
# an open PR; ordinary PR commits skip it (see build-docs.if) to avoid the
15+
# ~20 min `make test-docs` run on every push.
16+
types: [opened, synchronize, reopened, labeled]
1317
workflow_dispatch:
1418

1519
concurrency:
@@ -23,6 +27,14 @@ jobs:
2327
build-docs:
2428
name: Build Documentation
2529
runs-on: ubuntu-latest
30+
# The docs build (~20 min) is gated behind `full-ci` on PRs: it still runs
31+
# on every push to dev, on releases, and on manual dispatch, but on a PR it
32+
# only runs when the `full-ci` label is present. build-docs is not a
33+
# required check (only ruff/mypy/all-tests are), so skipping it on a regular
34+
# PR does not block merge; docs breakage is caught on the push to dev.
35+
if: >-
36+
github.event_name != 'pull_request'
37+
|| contains(github.event.pull_request.labels.*.name, 'full-ci')
2638
2739
steps:
2840
- name: Checkout code

0 commit comments

Comments
 (0)