PR Coverage Reporter #18293
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Coverage Reporter | |
| # Reuses coverage artifacts produced by the CI umbrella's `test` job (ci-test.yml) to render | |
| # the coverage table in the PR description, instead of re-running the entire test suite a | |
| # second time (which was the historical bottleneck). | |
| # | |
| # `workflow_run` listens for the umbrella's name. Coverage artifacts are uploaded by | |
| # ci-test.yml's `server-tests` job under the umbrella's run id, so artifact lookups via | |
| # `workflow_run.id` continue to work after the migration. | |
| # | |
| # FORK PRs ONLY. Internal (same-repo) PRs get their coverage table from ci.yml's in-run | |
| # `coverage-report` job, which posts as soon as `test` finishes (~28 min) instead of waiting for the | |
| # whole CI run (paced by the advisory e2e tail, up to ~120 min). This workflow_run path remains for | |
| # FORK PRs, where the in-run job's GITHUB_TOKEN is force-read-only and cannot post — fork runs have no | |
| # e2e, so this path is already timely for them. | |
| # | |
| # SECURITY: a `workflow_run` job runs in the base-repo context with write permissions. To avoid a | |
| # "pwn request", this workflow NEVER checks out or executes PR/fork code — it checks out the trusted | |
| # default branch, derives changed files from the GitHub API, and runs the coverage tooling with | |
| # `node` (no `pnpm install`, whose postinstall hook is fork-editable) against downloaded artifacts. | |
| on: | |
| workflow_run: | |
| # Must match the `name:` field of .github/workflows/ci.yml. | |
| workflows: ["CI"] | |
| types: [completed] | |
| # Group by PR number when available, falling back to head_sha — head_branch alone collides | |
| # across PRs that share a branch name on a shared fork. | |
| concurrency: | |
| group: coverage-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_sha }} | |
| cancel-in-progress: true | |
| env: | |
| CI: true | |
| node: 24 | |
| permissions: | |
| contents: read | |
| jobs: | |
| report-coverage: | |
| name: Report PR Coverage | |
| if: | | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.head_repository.full_name != github.repository && | |
| github.event.workflow_run.conclusion != 'cancelled' && | |
| github.event.workflow_run.conclusion != 'skipped' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| actions: read | |
| steps: | |
| - name: Find PR number | |
| id: find-pr | |
| uses: actions/github-script@v9 | |
| continue-on-error: true | |
| env: | |
| HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| HEAD_REPO_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} | |
| with: | |
| result-encoding: string | |
| script: | | |
| const branch = process.env.HEAD_BRANCH; | |
| const headOwner = process.env.HEAD_REPO_OWNER; | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head: `${headOwner}:${branch}`, | |
| state: 'open' | |
| }); | |
| if (!prs.length) { | |
| console.log(`No open PR found for branch ${headOwner}:${branch}`); | |
| return ''; | |
| } | |
| core.setOutput('author', prs[0].user.login); | |
| return prs[0].number; | |
| - name: Exit if no PR found | |
| if: steps.find-pr.outputs.result == '' | |
| run: | | |
| echo "No matching open PR; skipping coverage report." | |
| exit 0 | |
| # Trusted default branch only (never the PR head) — see the SECURITY note at the top of the | |
| # file. Changed files come from the API below, not a git diff of a checked-out PR tree. | |
| - name: Checkout coverage tooling (trusted default branch — never the PR head) | |
| uses: actions/checkout@v6 | |
| if: steps.find-pr.outputs.result != '' | |
| - name: Detect changed files (via API — no PR checkout) | |
| if: steps.find-pr.outputs.result != '' | |
| id: changes | |
| # A transient listFiles blip must not red this advisory job (skips downstream, retries next run). | |
| continue-on-error: true | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ steps.find-pr.outputs.result }} | |
| with: | |
| script: await require('./.github/scripts/detect-pr-coverage-changes.js')({ github, context, core }) | |
| - name: Overlay PR source for accurate line/expect counts (data only) | |
| if: steps.find-pr.outputs.result != '' | |
| env: | |
| PR_NUMBER: ${{ steps.find-pr.outputs.result }} | |
| run: | | |
| set -uo pipefail | |
| # Overlay ONLY the PR head's source trees as DATA so the per-file Lines/Expects columns | |
| # count the PR's code, not the default branch we run. The tooling under supporting_scripts/ | |
| # is never overlaid and is what executes; these files are read as text only. Best-effort — | |
| # on failure the counts fall back to default-branch values. | |
| if git fetch --depth=1 origin "pull/${PR_NUMBER}/head" 2>/dev/null \ | |
| && git checkout FETCH_HEAD -- src/main/webapp src/main/java 2>/dev/null; then | |
| echo "Overlaid PR source for accurate line/expect counts." | |
| else | |
| echo "::warning::Could not overlay PR source; line/expect counts may be default-branch-relative." | |
| fi | |
| - name: Download server JaCoCo XML | |
| if: steps.changes.outputs.has_server_changes == 'true' | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: Server JaCoCo XML | |
| path: build/reports/jacoco/ | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| - name: Download client coverage summaries | |
| if: steps.changes.outputs.has_client_changes == 'true' | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: Client Coverage Summaries | |
| # Nested path is deliberate: the single-file artifact unpacks flat, and it must land where | |
| # local-pr-coverage.mjs reads it (VITEST_COVERAGE_SUMMARY). Don't shorten to build/test-results/. | |
| path: build/test-results/vitest/coverage/ | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| - name: Setup Node.js | |
| if: steps.changes.outputs.has_client_changes == 'true' || steps.changes.outputs.has_server_changes == 'true' | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '${{ env.node }}' | |
| - name: Generate coverage table | |
| id: coverage | |
| if: steps.changes.outputs.has_client_changes == 'true' || steps.changes.outputs.has_server_changes == 'true' | |
| env: | |
| CLIENT_MODULES: ${{ steps.changes.outputs.client_modules }} | |
| SERVER_MODULES: ${{ steps.changes.outputs.server_modules }} | |
| CHANGED_FILES: ${{ steps.changes.outputs.changed_files }} | |
| HAS_CLIENT: ${{ steps.changes.outputs.has_client_changes }} | |
| HAS_SERVER: ${{ steps.changes.outputs.has_server_changes }} | |
| run: .github/scripts/generate-coverage-table.sh | |
| - name: Update PR description with coverage | |
| # Skip when detection failed (don't post a misleading "no changes"); a transient write blip | |
| # must not red this advisory job either. | |
| if: steps.find-pr.outputs.result != '' && steps.changes.outcome == 'success' | |
| continue-on-error: true | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ steps.find-pr.outputs.result }} | |
| PR_AUTHOR: ${{ steps.find-pr.outputs.author }} | |
| HAS_CLIENT: ${{ steps.changes.outputs.has_client_changes }} | |
| HAS_SERVER: ${{ steps.changes.outputs.has_server_changes }} | |
| COVERAGE_TABLE: ${{ steps.coverage.outputs.coverage_table }} | |
| COVERAGE_SUCCESS: ${{ steps.coverage.outputs.success }} | |
| TEST_CONCLUSION: ${{ github.event.workflow_run.conclusion }} | |
| TEST_RUN_URL: ${{ github.event.workflow_run.html_url }} | |
| POST_AUTHOR_COMMENT: 'true' | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: await require('./.github/scripts/update-pr-coverage.js')({ github, context, core }) |