mirror of
https://github.com/ankitects/anki.git
synced 2026-09-11 12:24:43 -04:00
## Linked issue Fixes #5418 ## Summary / motivation Every push to `main` creates a new `coverage-baseline-linux-<sha>` cache entry. Without a pruning step, these entries accumulate until GitHub's LRU eviction removes old ones. When the baseline a PR needs has been evicted, `check-coverage-regression.py` silently skips the check instead of failing, which could mask genuine coverage regressions. Two changes together close the gap: 1. **`prune-coverage-cache.yml`**: a new workflow triggered by `workflow_run` on every successful CI run on `main`. It keeps exactly one `coverage-baseline-linux-*` cache (the one just created) and deletes all older entries via the GitHub Actions API. With a single, always-fresh entry, eviction becomes practically impossible. 2. **`check-coverage-regression.py`**: turns the silent "no baseline — skipping" path into an explicit failure (`exit 2`). If somehow a baseline is still absent, the check now surfaces the problem visibly instead of passing silently. ## Steps to reproduce 1. Allow several pushes to `main` to accumulate `coverage-baseline-linux-*` cache entries until GitHub evicts the most recent one. 2. Open a PR: `check-coverage-regression.py` prints `no baseline — skipping` and exits 0, even if coverage dropped. ## How to test ### Details - After merging, verify via **Actions → Prune coverage baseline caches** that the job runs after a `CI` completion on `main` and leaves exactly one `coverage-baseline-linux-*` entry (`gh cache list -R ankitects/anki --key coverage-baseline-linux-`). - Confirm that a PR with a missing baseline now receives exit code 2 (visible failure) instead of exit code 0. ## Before / after behavior **Before:** evicted baseline → `check-coverage-regression.py` skips silently, coverage regressions go undetected. **After:** only one baseline exists at a time (no eviction risk); if it is somehow absent, the script exits with code 2 and the CI step fails visibly.
127 lines
3.9 KiB
Python
Executable File
127 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
"""
|
|
Compare line coverage percentages between a baseline and the current run.
|
|
|
|
Usage:
|
|
check-coverage-regression <baseline-dir> <current-dir>
|
|
|
|
baseline-dir Directory containing coverage-summary.json files from main
|
|
(e.g. out/coverage-baseline)
|
|
current-dir Directory containing coverage-summary.json files from the
|
|
current PR run (e.g. out/coverage)
|
|
|
|
Exits with code 1 if any stack's line coverage is below the baseline, or code 2
|
|
if a required coverage summary is missing.
|
|
|
|
Tolerance: 0.20% — small decreases within this margin are ignored to absorb
|
|
instrumentation noise across runs. Anything beyond this is treated as a
|
|
regression. If a decrease is acceptable (e.g. dead code removed), update
|
|
the baseline by merging to main.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Callable, TypedDict
|
|
|
|
TOLERANCE = 0.2 # percentage points; absorbs instrumentation noise across runs
|
|
# (0.1 produced false positives: llvm-cov/coverage.py fluctuate up to ~0.15pp
|
|
# between identical runs due to non-deterministic macro/generic instrumentation)
|
|
|
|
|
|
class Stack(TypedDict):
|
|
name: str
|
|
path: str
|
|
extract: Callable[[Any], float]
|
|
|
|
|
|
STACKS: list[Stack] = [
|
|
{
|
|
"name": "rust",
|
|
"path": "rust/coverage-summary.json",
|
|
"extract": lambda d: d["data"][0]["totals"]["lines"]["percent"],
|
|
},
|
|
{
|
|
"name": "python-pylib",
|
|
"path": "python-pylib/coverage-summary.json",
|
|
"extract": lambda d: d["totals"]["percent_covered"],
|
|
},
|
|
{
|
|
"name": "python-qt",
|
|
"path": "python-qt/coverage-summary.json",
|
|
"extract": lambda d: d["totals"]["percent_covered"],
|
|
},
|
|
{
|
|
"name": "typescript",
|
|
"path": "typescript/coverage-summary.json",
|
|
"extract": lambda d: d["total"]["lines"]["pct"],
|
|
},
|
|
]
|
|
|
|
|
|
def load_pct(directory: Path, stack: Stack) -> float | None:
|
|
path = directory / stack["path"]
|
|
if not path.exists():
|
|
return None
|
|
return stack["extract"](json.loads(path.read_text()))
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) != 3:
|
|
print(f"Usage: {sys.argv[0]} <baseline-dir> <current-dir>", file=sys.stderr)
|
|
return 2
|
|
|
|
baseline_dir = Path(sys.argv[1])
|
|
current_dir = Path(sys.argv[2])
|
|
|
|
regressions: list[tuple[str, float, float]] = []
|
|
|
|
for stack in STACKS:
|
|
name = stack["name"]
|
|
baseline_pct = load_pct(baseline_dir, stack)
|
|
current_pct = load_pct(current_dir, stack)
|
|
|
|
if baseline_pct is None:
|
|
print(
|
|
f"[{name}] coverage-summary.json not found in {baseline_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
if current_pct is None:
|
|
print(
|
|
f"[{name}] coverage-summary.json not found in {current_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
delta = current_pct - baseline_pct
|
|
if delta < -TOLERANCE:
|
|
regressions.append((name, baseline_pct, current_pct))
|
|
print(
|
|
f"[{name}] REGRESSION: {baseline_pct:.2f}% -> {current_pct:.2f}%"
|
|
f" (delta: {delta:+.2f}%, tolerance: {TOLERANCE:.2f}%)"
|
|
)
|
|
else:
|
|
print(
|
|
f"[{name}] ok: {current_pct:.2f}%"
|
|
f" (baseline: {baseline_pct:.2f}%, delta: {delta:+.2f}%)"
|
|
)
|
|
|
|
if regressions:
|
|
names = ", ".join(r[0] for r in regressions)
|
|
print(
|
|
f"\n{len(regressions)} stack(s) with coverage regression: {names}\n"
|
|
f"Configured tolerance: {TOLERANCE:.2f}%\n"
|
|
"To accept a legitimate decrease (e.g. dead code removed),\n"
|
|
"merge to main — this will update the baseline automatically."
|
|
)
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|