diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b268c3..792e8cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,8 +12,8 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
- Refresh coverage overview ordering and visibility on PR merge, closure, or reopening,
without waiting for main CI; support manual coverage-only refreshes.
- Add bounded Rust coverage CI with immutable per-run/per-attempt LCOV and HTML artifacts.
-- Publish retained coverage history to Pages, including late-attempt archives and pull-request
- state filtering.
+- Publish one coverage result per PR, preferring exact merge-commit coverage, with a separate
+ pre-/post-merge view and immutable archives for all runs and attempts.
- Restrict CI cache writes to main, preserve Cargo build timings, verify draft release assets
before immutable publication, and add deployment-only website artwork and shared contributor rules.
diff --git a/docs/infrastructure.md b/docs/infrastructure.md
index d4ce2fe..a3ce83b 100644
--- a/docs/infrastructure.md
+++ b/docs/infrastructure.md
@@ -39,12 +39,16 @@ Completed, Commit, Workflow, Lines, Branches, and Functions. Coderef's Python ge
rates from summed LCOV counters, links each immutable report and its LCOV/metadata downloads,
and displays completion times in UTC. Zero or unavailable metric totals display `n/a`, including
branch coverage when Rust instrumentation produces no branch measurements. Existing archives
-are read without modification. The overview shows the latest run per target, with main first;
-`history.html` retains every run and retry, including closed PRs. Run creation time determines
-which run is latest, so retrying an older run cannot replace a newer run. Attempts within a run
-are compared numerically. PR rows retain their own PR CI result, sorted by merge time after merging;
-the main row contains the latest main CI result. Open PRs follow merged PRs. The coverage figures
-describe Rust, not the Python or TypeScript scripts.
+are read without modification. The overview shows one result per PR, ordered by merge time,
+then open PRs. Pre-merge coverage is replaced only when a main report tests that PR's exact
+merge commit; unrelated main runs are omitted. Aggregation PR coverage is never attributed to
+its constituent PRs. PRs without pre-merge reports can still show post-merge coverage.
+`history.html` shows at most one pre-merge and one post-merge result per PR. Run creation time
+selects the newest run within each phase, with numeric attempt ordering for retries. Late
+pre-merge reports cannot displace post-merge results. Closed unmerged PRs are omitted from both
+views and reappear when reopened. All immutable run URLs remain available, including omitted
+runs and retries. A minimal `pull-requests.json` registry records merge SHAs for exact attribution.
+The coverage figures describe Rust, not the Python or TypeScript scripts.
PR closure (including merge) and reopening trigger a metadata-only refresh using trusted `main`
publisher code. This updates overview ordering and visibility without downloading artifacts or
replacing measured coverage. Manual refreshes use `gh workflow run pages.yml -f coverage_refresh=true`;
diff --git a/tools/coverage_index.py b/tools/coverage_index.py
index d9b58b7..7691e16 100644
--- a/tools/coverage_index.py
+++ b/tools/coverage_index.py
@@ -93,6 +93,9 @@ def _pulls(path: Path) -> dict[int, dict]:
def history(root: Path, pulls_path: Path | None = None) -> list[dict]:
root.mkdir(parents=True, exist_ok=True)
pulls = _pulls(pulls_path) if pulls_path else {}
+ registry = [{key: pull.get(key) for key in ("number", "state", "merged_at", "merge_commit_sha")}
+ for pull in pulls.values()]
+ (root / "pull-requests.json").write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8")
reports = []
for report in _reports(root):
target = report.get("target", "main")
@@ -118,24 +121,47 @@ def _run_order(report: dict) -> tuple:
def regenerate(root: Path) -> int:
reports = _read(root / "history.json") if (root / "history.json").exists() else _reports(root)
- reports.sort(key=_run_order, reverse=True)
+ pulls = _pulls(root / "pull-requests.json")
+ merges = {pull["merge_commit_sha"]: number for number, pull in pulls.items()
+ if pull.get("merged_at") and pull.get("merge_commit_sha")}
latest = {}
- for report in reports:
- if report.get("visible", True):
- latest.setdefault(report.get("target", "main"), report)
- visible = sorted(latest.values(), key=lambda report: (
- report.get("target", "main") == "main",
+ for original in sorted(reports, key=_run_order, reverse=True):
+ report = dict(original)
+ target = report.get("target", "main")
+ phase = ""
+ if target == "main":
+ number = merges.get(report.get("head_sha") or report.get("sha"))
+ if number is None:
+ continue
+ target = f"pr/{number}"
+ phase = "post-merge"
+ elif target.startswith("pr/"):
+ phase = "pre-merge"
+ if target.startswith("pr/"):
+ pull = pulls.get(int(target[3:]))
+ if pull is not None:
+ report["visible"] = pull.get("state") != "closed" or bool(pull.get("merged_at"))
+ report["reference_time"] = pull.get("merged_at")
+ if not report.get("visible", True):
+ continue
+ report.update(target=target, phase=phase)
+ latest.setdefault((target, phase), report)
+ phases = sorted(latest.values(), key=lambda report: (
bool(report.get("reference_time")),
_time(report.get("reference_time") or report.get("created_at") or report.get("completed_at")),
- _run_order(report)), reverse=True)
+ report["phase"], _run_order(report)), reverse=True)
+ visible = [report for report in phases
+ if report["phase"] != "pre-merge" or (report["target"], "post-merge") not in latest]
(root / "index.html").write_text(_render(
root, visible, "coderef coverage reports",
- "Latest report per target: main first, then merged PRs by merge time, then open PRs. "
- "PR rows show PR CI coverage; main shows post-merge CI coverage. Closed unmerged PRs are omitted.",
- _link("history.html", "All retained runs and attempts")), encoding="utf-8")
+ "One result per PR, ordered by merge time, then open PRs. Pre-merge coverage is replaced "
+ "only by coverage of the exact merge commit. Closed unmerged PRs are omitted; "
+ "their direct report URLs remain available.",
+ _link("history.html", "Pre-merge and post-merge results")), encoding="utf-8")
(root / "history.html").write_text(_render(
- root, reports, "coderef coverage run history",
- "All retained CI runs and attempts, newest run first, including closed PRs.",
+ root, phases, "coderef pre-merge and post-merge coverage",
+ "One result per PR phase. Post-merge results test the exact merge commit; "
+ "retries and unrelated main runs are omitted.",
_link("index.html", "Current coverage overview")), encoding="utf-8")
return len(visible)
@@ -146,6 +172,8 @@ def _render(root: Path, reports: list[dict], title: str, description: str, navig
target = report.get("target", "main")
path = report["path"]
label = f"PR {target[3:]}" if target.startswith("pr/") else target
+ if report.get("phase"):
+ label += f" ({report['phase']})"
if target == "main":
source = _link(f"{_REPOSITORY}/tree/main", "main branch")
elif target.startswith("pr/"):
diff --git a/tools/coverage_index_test.py b/tools/coverage_index_test.py
index d18632c..c74f63d 100644
--- a/tools/coverage_index_test.py
+++ b/tools/coverage_index_test.py
@@ -43,7 +43,7 @@ def test_closed_unmerged_report_hidden(self):
self.assertFalse(next(item for item in reports if item["target"] == "pr/12")["visible"])
regenerate(root)
self.assertNotIn("PR 12", (root / "index.html").read_text())
- self.assertIn("PR 12", (root / "history.html").read_text())
+ self.assertNotIn("PR 12", (root / "history.html").read_text())
def test_coverage_cli_archive_history_regenerate_links_resolve(self):
script = Path(__file__).with_name("coverage_index.py")
@@ -140,17 +140,17 @@ def test_coverage_overview_empty_and_legacy_metadata_render_safely(self):
self.assertEqual(regenerate(root), 0)
self.assertIn("No coverage reports", (root / "index.html").read_text())
records = [{"path": "runs/7/1", "target": ''},
- {"path": "runs/8/1", "target": "main", "sha": "123456789"}]
+ {"path": "runs/8/1", "target": "pr/8", "sha": "123456789"}]
(root / "history.json").write_text(json.dumps(records))
self.assertEqual(regenerate(root), 2)
html = (root / "index.html").read_text()
self.assertNotIn('