From e1f73fda452ab612f741d81875e04fc87bd9f1c7 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sun, 28 Jun 2026 16:59:15 +0300 Subject: [PATCH] Post CI failure comments on fork PRs via workflow_run The "report" job's PR comment step was failing silently (403 Resource not accessible by integration) for every fork-originated PR, since GITHUB_TOKEN is read-only for pull_request runs triggered by forks. Confirmed against PR #49 (a fork PR): the comment body was built correctly but the POST itself was rejected, and continue-on-error hid the failure entirely -- no comment ever appeared. Split the work in two: "report" now builds the comment body and uploads it as an artifact instead of posting it directly, dropping its pull-requests: write permission since it no longer needs it. A new workflow, comment-on-pr.yml, triggers on workflow_run once CI completes and posts/updates the comment from there -- workflow_run always runs with the base repo's own permissions using this file as committed on the default branch, regardless of which fork triggered the original run, so it works for both fork and same-repo PRs without granting fork code any elevated token access. --- .github/workflows/ci.yaml | 61 +++++++++------------- .github/workflows/comment-on-pr.yml | 80 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/comment-on-pr.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5464da0..7b08f0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -229,7 +229,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write # only this job needs to post/update the coverage comment steps: # Aggregated pass/fail dashboard rendered on the workflow run's Summary tab, # so contributors don't need to open individual job logs to see what failed. @@ -298,21 +297,21 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" - # Posts a sticky comment directly in the PR conversation (not just the Summary - # tab) so a missed coverage threshold, or a failure hidden behind continue-on-error, - # is impossible to miss without opening a job log. Updates the same comment on - # re-runs instead of piling up duplicates. continue-on-error because GITHUB_TOKEN - # is read-only for pull_request workflows triggered by forks, so this step is - # expected to fail for external contributions -- it must not block the "Fail if - # any check failed" gate below, which still enforces the threshold. + # Builds a sticky PR comment and writes it to an artifact + # instead of posting it directly. GITHUB_TOKEN is read-only here for + # pull_request runs triggered by forks (this job has no pull-requests: write + # permission at all now), so this job can never post to the PR itself. The + # actual posting happens in comment-on-pr.yml, triggered by workflow_run, + # which runs with the base repo's permissions regardless of where the PR + # came from -- the standard, GitHub-recommended pattern for commenting on + # fork PRs without handing the fork's code an elevated token. # # Failure output is passed via env vars, not interpolated with ${{ }} into the # script, because it's tool output derived from PR file contents -- a crafted # file name or error message could otherwise break out of the JS template - # literal and run arbitrary code in a job with pull-requests: write permission. - - name: Comment CI results on pull request + # literal and run arbitrary code. + - name: Build PR comment if: always() && github.event_name == 'pull_request' - continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREK_OUTPUT: ${{ needs.lint.outputs.prek_output }} @@ -382,34 +381,22 @@ jobs: : `⚠️ Coverage is below the required 80% threshold. Please add tests for the new or changed code before this pull request can be merged. See [Code Coverage](${contributingUrl}#code-coverage) in CONTRIBUTING.md.`, ].join('\n'); - // paginate() walks every page of comments, not just the first 30, so a - // long-lived PR's earlier sticky comment is never missed; restricting to - // github-actions[bot] avoids matching a human comment that happens to - // quote the marker text. - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find( - (comment) => comment.user.login === 'github-actions[bot]' && comment.body.includes(marker), + // comment-on-pr.yml reads this file by name ("pr-comment.json") out of + // the artifact -- the PR number is recorded here, from this run's own + // trusted event context, rather than trusted from the workflow_run + // event later (whose `pull_requests` field is unreliable for fork PRs). + require('fs').writeFileSync( + 'pr-comment.json', + JSON.stringify({ pr_number: context.issue.number, marker, body }), ); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } + - name: Upload PR comment artifact + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-comment + path: pr-comment.json + retention-days: 1 - name: Fail if any check failed run: | diff --git a/.github/workflows/comment-on-pr.yml b/.github/workflows/comment-on-pr.yml new file mode 100644 index 0000000..6059722 --- /dev/null +++ b/.github/workflows/comment-on-pr.yml @@ -0,0 +1,80 @@ +name: Comment CI results on PR + +# Posting PR comments needs write access, but the CI workflow's "report" job runs +# with a read-only GITHUB_TOKEN whenever the PR comes from a fork -- there's no way +# to grant that job write access without also handing the fork's code an elevated +# token. Instead, "report" builds the comment body and uploads it as an artifact +# (see ci.yaml), and this separate workflow, triggered by workflow_run once CI +# finishes, downloads that artifact and posts it. workflow_run always runs using +# this file as committed on the default branch and with the base repo's own +# permissions, regardless of which branch or fork triggered the original run, which +# is what makes this safe: nothing from the PR branch ever executes here. +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: read + actions: read # needed to download an artifact from another workflow run + pull-requests: write + +jobs: + comment: + name: Post PR comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + steps: + # No artifact means the triggering run wasn't a pull_request run, or its + # "Build PR comment" step didn't execute (e.g. an earlier job setup failure) -- + # continue-on-error skips the rest of this job via the next step's `if` rather + # than failing it. + - name: Download PR comment artifact + id: download + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pr-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: . + + # pr_number comes from the artifact, which "report" populated from its own + # run's trusted event context -- not from this event's workflow_run.pull_requests + # field, which GitHub doesn't reliably populate for fork-triggered runs. + - name: Post or update PR comment + if: steps.download.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const { pr_number, marker, body } = JSON.parse(fs.readFileSync('pr-comment.json', 'utf8')); + + // paginate() walks every page of comments, not just the first 30, so a + // long-lived PR's earlier sticky comment is never missed; restricting to + // github-actions[bot] avoids matching a human comment that happens to + // quote the marker text. + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + }); + const existing = comments.find( + (comment) => comment.user.login === 'github-actions[bot]' && comment.body.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + body, + }); + }