|
| 1 | +name: "python / emitter diff commenter" |
| 2 | + |
| 3 | +# The artifact is untrusted data. Never check out, build, or execute PR code here. |
| 4 | +# Resolve the PR from trusted workflow_run metadata, not artifact fields. |
| 5 | +on: |
| 6 | + workflow_run: |
| 7 | + workflows: ["python / emitter diff"] |
| 8 | + types: |
| 9 | + - completed |
| 10 | + |
| 11 | +permissions: |
| 12 | + actions: read |
| 13 | + pull-requests: write |
| 14 | + |
| 15 | +concurrency: |
| 16 | + group: python-emitter-diff-comment-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} |
| 17 | + cancel-in-progress: false |
| 18 | + |
| 19 | +jobs: |
| 20 | + commenter: |
| 21 | + if: github.event.workflow_run.event == 'pull_request' |
| 22 | + runs-on: ubuntu-latest |
| 23 | + steps: |
| 24 | + - name: Resolve PR and artifact |
| 25 | + id: resolve |
| 26 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 27 | + with: |
| 28 | + script: | |
| 29 | + const { owner, repo } = context.repo; |
| 30 | + const run = context.payload.workflow_run; |
| 31 | + if (run.path !== '.github/workflows/ci-emitter-diff-python.yml') { |
| 32 | + throw new Error('Unexpected producer workflow path.'); |
| 33 | + } |
| 34 | + if (run.conclusion === 'cancelled' || run.conclusion === 'skipped') { |
| 35 | + core.info(`Skipping ${run.conclusion} producer run.`); |
| 36 | + return; |
| 37 | + } |
| 38 | + const headSha = run.head_sha; |
| 39 | + const headBranch = run.head_branch; |
| 40 | + const headRepo = run.head_repository?.full_name; |
| 41 | + const headOwner = run.head_repository?.owner?.login; |
| 42 | + if (!headSha || !headBranch || !headRepo || !headOwner) { |
| 43 | + throw new Error('workflow_run is missing trusted head repository information.'); |
| 44 | + } |
| 45 | + // Fork runs have empty pull_requests; commit association lookup also |
| 46 | + // misses fork commits that have not been merged into the base repo. |
| 47 | + const prs = await github.paginate(github.rest.pulls.list, { |
| 48 | + owner, repo, state: 'open', head: `${headOwner}:${headBranch}`, per_page: 100, |
| 49 | + }); |
| 50 | + const matches = prs.filter((pr) => |
| 51 | + pr.state === 'open' && |
| 52 | + pr.head.sha === headSha && |
| 53 | + pr.head.ref === headBranch && |
| 54 | + pr.head.repo?.full_name === headRepo && |
| 55 | + pr.base.repo?.full_name === `${owner}/${repo}` |
| 56 | + ); |
| 57 | + if (matches.length !== 1) { |
| 58 | + core.info(`Skipping stale, closed, missing, or ambiguous PR: found ${matches.length} exact matches.`); |
| 59 | + return; |
| 60 | + } |
| 61 | + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { |
| 62 | + owner, repo, run_id: run.id, per_page: 100, |
| 63 | + }); |
| 64 | + const comments = artifacts.filter((artifact) => artifact.name === 'emitter-diff-python-comment'); |
| 65 | + if (comments.length === 0 && run.conclusion !== 'success') { |
| 66 | + core.warning('Failed producer run has no comment artifact; see the producer run for the tool/build error.'); |
| 67 | + return; |
| 68 | + } |
| 69 | + if (comments.length !== 1 || comments[0].expired) { |
| 70 | + throw new Error('Expected one unexpired comment artifact from the producer run.'); |
| 71 | + } |
| 72 | + core.setOutput('pr', String(matches[0].number)); |
| 73 | + core.setOutput('artifact', String(comments[0].id)); |
| 74 | +
|
| 75 | + - name: Download comment artifact |
| 76 | + if: steps.resolve.outputs.artifact != '' |
| 77 | + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 |
| 78 | + with: |
| 79 | + artifact-ids: ${{ steps.resolve.outputs.artifact }} |
| 80 | + merge-multiple: true |
| 81 | + run-id: ${{ github.event.workflow_run.id }} |
| 82 | + github-token: ${{ secrets.GITHUB_TOKEN }} |
| 83 | + path: ${{ runner.temp }}/emitter-diff-comment |
| 84 | + |
| 85 | + - name: Post PR comment |
| 86 | + if: steps.resolve.outputs.artifact != '' |
| 87 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 88 | + env: |
| 89 | + PR_NUMBER: ${{ steps.resolve.outputs.pr }} |
| 90 | + with: |
| 91 | + script: | |
| 92 | + const fs = require('node:fs'); |
| 93 | + const path = require('node:path'); |
| 94 | + const { owner, repo } = context.repo; |
| 95 | + const run = context.payload.workflow_run; |
| 96 | + const marker = '<!-- emitter-diff-python -->'; |
| 97 | + const issue_number = Number(process.env.PR_NUMBER); |
| 98 | + if (!Number.isSafeInteger(issue_number) || issue_number <= 0 || |
| 99 | + !Number.isSafeInteger(run.id) || !Number.isSafeInteger(run.run_attempt)) { |
| 100 | + throw new Error('Invalid trusted PR or run identifier.'); |
| 101 | + } |
| 102 | + const file = path.join(process.env.RUNNER_TEMP, 'emitter-diff-comment', 'comment.json'); |
| 103 | + const stat = fs.lstatSync(file); |
| 104 | + if (!stat.isFile() || stat.size > 16384) { |
| 105 | + throw new Error('Comment artifact must be a regular JSON file of at most 16 KiB.'); |
| 106 | + } |
| 107 | + const payload = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 108 | + if (!payload || typeof payload.body !== 'string' || |
| 109 | + !payload.body.trim() || payload.body.length > 8000) { |
| 110 | + throw new Error('Comment body must be a nonempty string of at most 8000 characters.'); |
| 111 | + } |
| 112 | + // Force both control markers from trusted values before the untrusted body. |
| 113 | + const body = `${marker}\n<!-- emitter-diff-python-run: ${run.id}:${run.run_attempt} -->\n${payload.body}`; |
| 114 | + const comments = await github.paginate(github.rest.issues.listComments, { |
| 115 | + owner, repo, issue_number, per_page: 100, |
| 116 | + }); |
| 117 | + const owned = comments.filter((comment) => |
| 118 | + comment.user?.type === 'Bot' && |
| 119 | + comment.user?.login === 'github-actions[bot]' && |
| 120 | + comment.body?.startsWith(marker + '\n') |
| 121 | + ); |
| 122 | + if (owned.length > 1) { |
| 123 | + throw new Error('Multiple bot-owned emitter diff comments; refusing an ambiguous update.'); |
| 124 | + } |
| 125 | + const hit = owned[0]; |
| 126 | + const stamp = hit?.body.match(/^<!-- emitter-diff-python -->\n<!-- emitter-diff-python-run: (\d+):(\d+) -->/); |
| 127 | + if (stamp && (Number(stamp[1]) > run.id || |
| 128 | + (Number(stamp[1]) === run.id && Number(stamp[2]) > run.run_attempt))) { |
| 129 | + core.info('Skipping older run; a newer emitter diff comment is already present.'); |
| 130 | + return; |
| 131 | + } |
| 132 | + // Recheck after download/pagination, immediately before writing. |
| 133 | + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number }); |
| 134 | + if (pr.state !== 'open' || pr.head.sha !== run.head_sha || |
| 135 | + pr.head.ref !== run.head_branch || |
| 136 | + pr.head.repo?.full_name !== run.head_repository.full_name || |
| 137 | + pr.base.repo?.full_name !== `${owner}/${repo}`) { |
| 138 | + core.info('Skipping PR that closed or changed head while preparing the comment.'); |
| 139 | + return; |
| 140 | + } |
| 141 | + if (hit) { |
| 142 | + await github.rest.issues.updateComment({ owner, repo, comment_id: hit.id, body }); |
| 143 | + } else { |
| 144 | + await github.rest.issues.createComment({ owner, repo, issue_number, body }); |
| 145 | + } |
0 commit comments