From 28dbe54821ff1cc37dd0fc35388dcad263ab2340 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sat, 29 Aug 2026 17:29:59 +1000 Subject: [PATCH 1/5] ci: add coverage reporting and harden workflow --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba3a4fb..dce770a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,13 @@ on: push: branches: [main] +permissions: + contents: read + jobs: tests: runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -38,3 +42,43 @@ jobs: - name: Verify package build run: uv build + + coverage: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + UV_NO_PROGRESS: "1" + SEMAPACT_RUNTIME_CONTEXT: "auto" + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --all-extras --group dev --frozen + + - name: Run tests with coverage + run: >- + uv run --with pytest-cov pytest + --cov=semapact + --cov-report=term-missing + --cov-report=xml:coverage.xml + --cov-report=html:htmlcov + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v6 + with: + name: coverage-report + path: | + coverage.xml + htmlcov/ + if-no-files-found: error + retention-days: 14 From d2f52d8a581d2fec483b7d25d005da6efe440479 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sat, 29 Aug 2026 17:55:03 +1000 Subject: [PATCH 2/5] ci: publish coverage in GitHub Actions summary --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dce770a..7e93d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,18 @@ jobs: --cov-report=xml:coverage.xml --cov-report=html:htmlcov + - name: Publish coverage summary + if: always() + shell: bash + run: | + echo "## Test Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ -f .coverage ]; then + uv run --with coverage coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" + else + echo "_Coverage data was not generated._" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Upload coverage report if: always() uses: actions/upload-artifact@v6 From d1cc330435a2fcdc0220cbecad10fc5eeb10385e Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sat, 29 Aug 2026 20:08:13 +1000 Subject: [PATCH 3/5] ci: add module-level coverage summary --- .github/scripts/coverage_summary.py | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/scripts/coverage_summary.py diff --git a/.github/scripts/coverage_summary.py b/.github/scripts/coverage_summary.py new file mode 100644 index 0000000..e7cfae8 --- /dev/null +++ b/.github/scripts/coverage_summary.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path + + +def _top_level_module(package_name: str) -> str: + parts = package_name.split(".") + if len(parts) >= 2 and parts[0] == "semapact": + return parts[1] + if package_name == "semapact": + return "root" + return package_name or "root" + + +def build_summary(xml_path: Path) -> str: + root = ET.parse(xml_path).getroot() + stats: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + for package in root.findall(".//package"): + module = _top_level_module(package.get("name", "")) + for line in package.findall("./classes/class/lines/line"): + stats[module][0] += 1 + if int(line.get("hits", "0")) > 0: + stats[module][1] += 1 + + rows = [ + "## Test Coverage", + "", + "| Module | Statements | Missed | Coverage |", + "| --- | ---: | ---: | ---: |", + ] + + total_statements = 0 + total_covered = 0 + for module in sorted(stats): + statements, covered = stats[module] + missed = statements - covered + pct = (covered / statements * 100) if statements else 100.0 + rows.append(f"| `{module}` | {statements} | {missed} | {pct:.1f}% |") + total_statements += statements + total_covered += covered + + total_missed = total_statements - total_covered + total_pct = (total_covered / total_statements * 100) if total_statements else 100.0 + rows.extend( + [ + f"| **TOTAL** | **{total_statements}** | **{total_missed}** | **{total_pct:.1f}%** |", + "", + "Detailed per-file and line coverage is available in the `coverage-report` artifact.", + ] + ) + return "\n".join(rows) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: coverage_summary.py ", file=sys.stderr) + return 2 + + xml_path = Path(sys.argv[1]) + if not xml_path.is_file(): + print(f"coverage XML not found: {xml_path}", file=sys.stderr) + return 1 + + print(build_summary(xml_path)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f561a28e715f065710cbabedc9646803557d7f60 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sat, 29 Aug 2026 20:08:24 +1000 Subject: [PATCH 4/5] ci: show concise module coverage in job summary --- .github/workflows/ci.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e93d4b..f7af123 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,20 +68,19 @@ jobs: run: >- uv run --with pytest-cov pytest --cov=semapact - --cov-report=term-missing --cov-report=xml:coverage.xml --cov-report=html:htmlcov - - name: Publish coverage summary + - name: Publish module coverage summary if: always() shell: bash run: | - echo "## Test Coverage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - if [ -f .coverage ]; then - uv run --with coverage coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" + if [ -f coverage.xml ]; then + uv run python .github/scripts/coverage_summary.py coverage.xml | tee -a "$GITHUB_STEP_SUMMARY" else - echo "_Coverage data was not generated._" >> "$GITHUB_STEP_SUMMARY" + echo "## Test Coverage" | tee -a "$GITHUB_STEP_SUMMARY" + echo "" | tee -a "$GITHUB_STEP_SUMMARY" + echo "_Coverage data was not generated._" | tee -a "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage report From 94408c2a45ae68e3b70489257d33f15f9b648945 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Sat, 29 Aug 2026 20:11:20 +1000 Subject: [PATCH 5/5] ci: collapse coverage summary to top-level modules --- .github/scripts/coverage_summary.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/scripts/coverage_summary.py b/.github/scripts/coverage_summary.py index e7cfae8..e289f85 100644 --- a/.github/scripts/coverage_summary.py +++ b/.github/scripts/coverage_summary.py @@ -7,12 +7,12 @@ def _top_level_module(package_name: str) -> str: - parts = package_name.split(".") - if len(parts) >= 2 and parts[0] == "semapact": - return parts[1] - if package_name == "semapact": + normalized = package_name.strip(".") + if normalized in {"", "semapact"}: return "root" - return package_name or "root" + if normalized.startswith("semapact."): + normalized = normalized.removeprefix("semapact.") + return normalized.split(".", 1)[0] def build_summary(xml_path: Path) -> str: