Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tools/coverage_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tools/coverage_index_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 32 additions & 3 deletions tools/infrastructure_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)),
):
Expand All @@ -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)
Expand Down
Loading