diff --git a/.github/workflows/bench-comment.yml b/.github/workflows/bench-comment.yml new file mode 100644 index 0000000..684c756 --- /dev/null +++ b/.github/workflows/bench-comment.yml @@ -0,0 +1,103 @@ +# Posts the benchmark comparison comment produced by bench-compare.yml. +# +# bench-compare.yml runs on `pull_request`, where fork PRs get a read-only +# token that cannot post comments. This workflow runs on `workflow_run` with +# write permissions, downloads the comment artifact from the triggering run, +# and creates/updates the marker-managed PR comment — for fork and same-repo +# PRs alike. +# +# Security: this workflow has a write token but never checks out or executes +# PR code. The artifact content is treated as data only; the PR number it +# names is validated against the triggering run's head SHA before posting. +name: Benchmark Comment + +on: + workflow_run: + workflows: ['Benchmark Comparison'] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + post-comment: + name: 'Post benchmark comment' + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - uses: actions/download-artifact@v4 + with: + name: bench-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: comment-artifact + + - name: Post PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + + let body = fs.readFileSync('comment-artifact/bench-comment.md', 'utf8'); + const prNumber = parseInt( + fs.readFileSync('comment-artifact/pr-number.txt', 'utf8').trim(), + 10 + ); + + if (!Number.isInteger(prNumber) || prNumber <= 0) { + core.setFailed(`Invalid PR number in artifact: ${prNumber}`); + return; + } + + // The artifact was produced by an untrusted run; confirm the PR it + // names is the one the triggering run actually built. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pr.head.sha !== context.payload.workflow_run.head_sha) { + core.info( + `PR #${prNumber} head (${pr.head.sha}) does not match the ` + + `triggering run (${context.payload.workflow_run.head_sha}) — ` + + 'stale or mismatched artifact, skipping.' + ); + return; + } + + // GitHub comments cap at 65536 characters; keep the summary + // tables and truncate the tail (full output stays in the job + // summary of the triggering run). + const limit = 65000; + if (body.length > limit) { + body = + body.slice(0, limit) + + '\n```\n\n\n\n> Output truncated — full results in the workflow job summary.\n'; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const existing = comments.find((c) => c.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: prNumber, + body, + }); + } diff --git a/.github/workflows/bench-compare.yml b/.github/workflows/bench-compare.yml index dc7eb94..0a00125 100644 --- a/.github/workflows/bench-compare.yml +++ b/.github/workflows/bench-compare.yml @@ -57,46 +57,19 @@ jobs: if: always() && steps.checkout.outcome == 'success' run: cat "$RUNNER_TEMP/bench-comment.md" >> "$GITHUB_STEP_SUMMARY" - - name: Post PR comment + # This job runs with a read-only token for fork PRs, so it cannot post + # the comment itself. It uploads the comment body as an artifact and the + # privileged bench-comment.yml workflow (workflow_run) posts it — same + # path for fork and same-repo PRs. + - name: Upload comment artifact if: always() && steps.checkout.outcome == 'success' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const marker = ''; - - const body = fs.readFileSync(process.env.RUNNER_TEMP + '/bench-comment.md', 'utf8'); - - const headFullName = context.payload.pull_request?.head?.repo?.full_name; - const isFork = !headFullName || headFullName !== context.repo.owner + '/' + context.repo.repo; - - if (isFork) { - core.info('PR is from a fork — skipping PR comment (results are in the job summary).'); - core.info('--- Comment body start ---'); - core.info(body); - core.info('--- Comment body end ---'); - } else { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existing = comments.find(c => c.body.includes(marker)); + run: | + mkdir -p "$RUNNER_TEMP/comment-artifact" + cp "$RUNNER_TEMP/bench-comment.md" "$RUNNER_TEMP/comment-artifact/bench-comment.md" + echo "${{ github.event.pull_request.number }}" > "$RUNNER_TEMP/comment-artifact/pr-number.txt" - 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, - }); - } - } + - uses: actions/upload-artifact@v4 + if: always() && steps.checkout.outcome == 'success' + with: + name: bench-comment + path: ${{ runner.temp }}/comment-artifact/