From 1db4b4994b71e01bb52bc10e6c618469c8cf9312 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:09:03 +0100 Subject: [PATCH] fix: publish successful coverage independently of other CI jobs --- .github/workflows/pages.yml | 22 +++++++++++++++++----- CHANGELOG.md | 3 +++ docs/infrastructure.md | 7 ++++++- tools/coverage_index.py | 2 +- tools/coverage_index_test.py | 4 ++-- tools/infrastructure_test.py | 35 ++++++++++++++++++++++++++++++++--- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 23f8d5a..cc50de8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -167,8 +167,7 @@ jobs: if: >- github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.coverage_refresh) || - (github.event_name == 'workflow_run' && github.event.workflow_run.name == 'CI' && - github.event.workflow_run.conclusion == 'success') + (github.event_name == 'workflow_run' && github.event.workflow_run.name == 'CI') runs-on: ubuntu-latest permissions: actions: read @@ -199,8 +198,21 @@ jobs: - uses: actions/setup-python@v7 with: python-version: '3.13' - - name: Download immutable coverage attempt + - name: Check coverage job result + id: coverage-result if: github.event_name == 'workflow_run' + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + run: | + set -euo pipefail + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" > coverage-jobs.json + eligible="$(jq 'any(.[].jobs[]; .name == "Rust coverage" and .conclusion == "success")' coverage-jobs.json)" + echo "eligible=${eligible}" >> "${GITHUB_OUTPUT}" + - name: Download immutable coverage attempt + if: steps.coverage-result.outputs.eligible == 'true' env: GH_TOKEN: ${{ github.token }} RUN_ID: ${{ github.event.workflow_run.id }} @@ -210,7 +222,7 @@ jobs: gh run download "${RUN_ID}" --repo "${GITHUB_REPOSITORY}" \ --name "coverage-${RUN_ID}-${RUN_ATTEMPT}" --dir report - name: Add target and reference metadata - if: github.event_name == 'workflow_run' + if: steps.coverage-result.outputs.eligible == 'true' env: RUN_ID: ${{ github.event.workflow_run.id }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} @@ -250,7 +262,7 @@ jobs: head_sha:$sha}' report/metadata.json > report/metadata.tmp mv report/metadata.tmp report/metadata.json - name: Archive incoming coverage report - if: github.event_name == 'workflow_run' + if: steps.coverage-result.outputs.eligible == 'true' run: | set -euo pipefail python3 source/tools/coverage_index.py archive site/coverage --incoming report diff --git a/CHANGELOG.md b/CHANGELOG.md index 792e8cd..ed9e75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html). ### Changed +- Publish successful coverage despite unrelated CI failures, and put post-merge before pre-merge + within each PR in the two-phase coverage view. + - 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. diff --git a/docs/infrastructure.md b/docs/infrastructure.md index a3ce83b..8885df8 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -43,7 +43,7 @@ are read without modification. The overview shows one result per PR, ordered by 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 +`history.html` shows at most one result per phase for each PR, with post-merge first. 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 @@ -55,6 +55,11 @@ replacing measured coverage. Manual refreshes use `gh workflow run pages.yml -f the default manual dispatch still publishes the requested release. Refreshes, CI report ingestion, and release publishing all share the `coverage-pages` queue and read current PR state after acquiring it. A refresh waits for any active publication but does not wait for main CI to finish. +Coverage ingestion requires the source attempt's `Rust coverage` job to succeed, independently +of other CI jobs. The publisher queries all pages of that attempt's jobs, matching the attempt +used in the artifact name. Failed, cancelled, skipped, or missing coverage leaves retained +reports intact while PR metadata still refreshes. API failures stop publication. Historical +runs suppressed by the old whole-workflow gate are not automatically replayed. ## Release publication diff --git a/tools/coverage_index.py b/tools/coverage_index.py index 7691e16..74e6844 100644 --- a/tools/coverage_index.py +++ b/tools/coverage_index.py @@ -149,7 +149,7 @@ def regenerate(root: Path) -> int: 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")), - report["phase"], _run_order(report)), reverse=True) + report["phase"] == "post-merge", _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( diff --git a/tools/coverage_index_test.py b/tools/coverage_index_test.py index c74f63d..7cf3c4f 100644 --- a/tools/coverage_index_test.py +++ b/tools/coverage_index_test.py @@ -304,8 +304,8 @@ def links(page): self.assertEqual(links("index.html"), ["runs/6/1/html/index.html", "runs/5/1/html/index.html", "runs/4/10/html/index.html"]) self.assertEqual(links("history.html"), ["runs/6/1/html/index.html", - "runs/2/1/html/index.html", "runs/5/1/html/index.html", - "runs/9/1/html/index.html", "runs/4/10/html/index.html"]) + "runs/5/1/html/index.html", "runs/2/1/html/index.html", + "runs/4/10/html/index.html", "runs/9/1/html/index.html"]) self.assertIn("PR 12 (post-merge)", (root / "index.html").read_text()) self.assertIn("PR 12 (pre-merge)", (root / "history.html").read_text()) snapshots = {path.relative_to(root): path.read_bytes() diff --git a/tools/infrastructure_test.py b/tools/infrastructure_test.py index 2b99563..794af9d 100644 --- a/tools/infrastructure_test.py +++ b/tools/infrastructure_test.py @@ -64,6 +64,35 @@ def test_coverage_download_shell_preserves_exact_arguments(self): self.assertIn(" actions: read\n", permissions) self.assertIn(" pull-requests: read\n", permissions) + def test_coverage_job_gate_only_successful_source_attempt_is_eligible(self): + script = self.workflow_script("pages", "Check coverage job result") + for conclusion in ("success", "failure", "cancelled", "skipped", None, "missing"): + with self.subTest(conclusion=conclusion), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + jobs = [{"name": "Rust workspace", "conclusion": "failure"}] + if conclusion != "missing": + jobs.append({"name": "Rust coverage", "conclusion": conclusion}) + # Exercise pagination: the coverage job is on the second page. + (root / "jobs.json").write_text(json.dumps([{"jobs": jobs[:1]}, {"jobs": jobs[1:]}])) + mock = 'gh() { printf "%s\\n" "$@" > arguments; cat jobs.json; };\n' + subprocess.run(["bash", "-c", mock + script], cwd=root, check=True, + env={**os.environ, "RUN_ID": "123", "RUN_ATTEMPT": "2", + "GITHUB_REPOSITORY": "mboworks/coderef", + "GITHUB_OUTPUT": str(root / "outputs")}) + self.assertEqual((root / "outputs").read_text(), + f"eligible={str(conclusion == 'success').lower()}\n") + self.assertEqual((root / "arguments").read_text().splitlines(), [ + "api", "--paginate", "--slurp", + "repos/mboworks/coderef/actions/runs/123/attempts/2/jobs?per_page=100"]) + # API errors must fail closed, never consume an artifact. + (root / "outputs").unlink() + result = subprocess.run(["bash", "-c", 'gh() { return 1; };\n' + script], + cwd=root, env={**os.environ, "RUN_ID": "123", "RUN_ATTEMPT": "2", + "GITHUB_REPOSITORY": "mboworks/coderef", + "GITHUB_OUTPUT": str(root / "outputs")}) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / "outputs").exists()) + def test_coverage_generation_shell_produces_expected_layout(self): script = self.workflow_script("ci", "Generate LCOV and HTML coverage") script = re.sub(r"\$\{\{.*?\}\}", "123", script) @@ -185,8 +214,8 @@ def test_coverage_refresh_workflow_routes_events_to_trusted_publisher(self): ("workflow_dispatch", "", "", "", False, (True, False)), ("workflow_dispatch", "", "", "", True, (False, True)), ("workflow_run", "CI", "success", "pull_request", False, (False, True)), - ("workflow_run", "CI", "failure", "push", False, (False, False)), - ("workflow_run", "CI", "cancelled", "push", False, (False, False)), + ("workflow_run", "CI", "failure", "push", False, (False, True)), + ("workflow_run", "CI", "cancelled", "push", False, (False, True)), ("workflow_run", "Release", "success", "push", False, (True, False)), ("workflow_run", "Other", "success", "push", False, (False, False)), ): @@ -206,7 +235,7 @@ def test_coverage_refresh_workflow_routes_events_to_trusted_publisher(self): self.assertNotIn("github.event.pull_request.head", coverage) for step in ("Download immutable coverage attempt", "Add target and reference metadata", "Archive incoming coverage report"): - self.assertIn(f" - name: {step}\n if: github.event_name == 'workflow_run'", coverage) + self.assertIn(f" - name: {step}\n if: steps.coverage-result.outputs.eligible == 'true'", coverage) refresh = coverage.split(" - name: Refresh metadata and publish retained reports", 1)[1] self.assertNotIn("workflow_run", refresh) self.assertNotIn("--incoming", refresh)