From cae4d7b88583743d724e78f6eee2dc5e1540211b Mon Sep 17 00:00:00 2001 From: Tristan Escalada <355457+tescalada@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:13:32 -0400 Subject: [PATCH] Post PR coverage comments from a workflow_run workflow Fork PRs run CI with a read-only GITHUB_TOKEN: GitHub caps the token for pull_request events from forks regardless of the permissions declared in the workflow, so the comment steps in the test job failed the job on every fork PR while the suite itself passed. The test job now uploads pytest-coverage.txt, junit.xml, and diff-cover-report.md as a coverage-report artifact instead of commenting. The new coverage-comment workflow triggers on CI completion, runs in the base repo's context with pull-requests: write, resolves the PR number from the API by head SHA, and posts the two sticky comments. It follows the same pattern as publish-pr.yml: it downloads report data only and never checks out or executes fork code. Each comment step is gated on its report file existing, since a failed run uploads only the files it produced. --- .github/workflows/ci.yml | 37 +++++----- .github/workflows/coverage-comment.yml | 96 ++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/coverage-comment.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58e6d91..298745a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,8 +68,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # Needed to post/update the coverage report comment on the PR. - pull-requests: write steps: - uses: actions/checkout@v4 with: @@ -81,27 +79,24 @@ jobs: run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Run tests and diff-cover run: bash scripts/run_coverage.sh - # Render coverage + test results as a single sticky PR comment. Runs even - # when diff-cover fails the gate above, so the report is always posted; the - # coverage.xml and junit.xml it reads are written before diff-cover runs. - - name: Coverage report comment + # The coverage reports are posted as PR comments by the + # coverage-comment workflow. It runs in the base repo's context + # because this job cannot comment: fork PRs get a read-only + # GITHUB_TOKEN, which caps the pull-requests permission no matter + # what is declared here. Upload runs even when the suite or the + # coverage gate fails, so the comment explaining a red check is + # still posted; whichever report files a failure suppressed are + # simply absent from the artifact. + - name: Upload coverage reports if: ${{ always() && github.event_name == 'pull_request' }} - uses: MishaKav/pytest-coverage-comment@v1.10.0 - with: - pytest-coverage-path: pytest-coverage.txt - junitxml-path: junit.xml - title: Coverage report - # Post diff-cover's own report as a second sticky comment. This is the one - # that explains a red check: it shows the changed-line coverage and the - # specific uncovered lines that fell below the threshold. run_coverage.sh - # writes diff-cover-report.md before the gate exits. - - name: Diff coverage comment - if: ${{ always() && github.event_name == 'pull_request' }} - uses: marocchino/sticky-pull-request-comment@v3.0.5 + uses: actions/upload-artifact@v4 with: - header: diff-coverage - path: diff-cover-report.md - ignore_empty: true + name: coverage-report + path: | + pytest-coverage.txt + junit.xml + diff-cover-report.md + retention-days: 7 test-js: runs-on: ubuntu-latest diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml new file mode 100644 index 0000000..93a379f --- /dev/null +++ b/.github/workflows/coverage-comment.yml @@ -0,0 +1,96 @@ +# Second stage of PR coverage reporting. The CI workflow's test job runs +# with a read-only token (fork PRs cannot get more) and uploads the +# coverage reports as an artifact; this workflow runs in the base repo's +# context and posts them as sticky PR comments. It resolves the PR number +# from the GitHub API — never from anything the PR controls — and it +# downloads report files but never checks out or executes fork code. +name: Coverage comment + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + comment: + # Comment regardless of the CI run's conclusion: a red suite is exactly + # when the coverage/test report on the PR matters most. + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + # Non-PR runs and runs that died before the upload step leave no + # artifact; skip everything in that case. + - name: Check for coverage artifact + id: artifact + uses: actions/github-script@v7 + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + const found = artifacts.data.artifacts.some(a => a.name === 'coverage-report'); + core.setOutput('found', found ? 'true' : 'false'); + # Resolve the PR number from the API by head SHA, not from run + # contents, so a malicious PR cannot direct the comment elsewhere. + - name: Find pull request + if: steps.artifact.outputs.found == 'true' + id: pr + uses: actions/github-script@v7 + with: + script: | + const headSha = context.payload.workflow_run.head_sha; + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: headSha, + }); + const pr = prs.data.find(p => p.head.sha === headSha && p.state === 'open'); + if (!pr) { + core.setFailed(`No open PR found with head ${headSha}`); + return; + } + core.setOutput('number', pr.number); + - name: Download coverage artifact + if: steps.artifact.outputs.found == 'true' + uses: actions/download-artifact@v4 + with: + name: coverage-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + # A failure can suppress individual report files before the CI job + # uploads (a red suite writes no diff-cover report), so each comment + # step is gated on its own file being present. + - name: Check report files + if: steps.artifact.outputs.found == 'true' + id: reports + run: | + echo "pytest=$( [ -f pytest-coverage.txt ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + echo "diff=$( [ -f diff-cover-report.md ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + # Render coverage + test results as a single sticky PR comment. + - name: Coverage report comment + if: steps.reports.outputs.pytest == 'true' + uses: MishaKav/pytest-coverage-comment@v1.10.0 + with: + issue-number: ${{ steps.pr.outputs.number }} + pytest-coverage-path: pytest-coverage.txt + junitxml-path: junit.xml + title: Coverage report + # Post diff-cover's own report as a second sticky comment. This is the + # one that explains a red coverage gate: it shows the changed-line + # coverage and the specific uncovered lines that fell below the + # threshold. + - name: Diff coverage comment + if: steps.reports.outputs.diff == 'true' + uses: marocchino/sticky-pull-request-comment@v3.0.5 + with: + number: ${{ steps.pr.outputs.number }} + header: diff-coverage + path: diff-cover-report.md + ignore_empty: true