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
61 changes: 24 additions & 37 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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: |
Expand Down
80 changes: 80 additions & 0 deletions .github/workflows/comment-on-pr.yml
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading