From 90a87d3cccaac89806f3155f8402e2b34cedfca1 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:13:50 +0100 Subject: [PATCH 1/2] Derive PR number from the triggering run, not the artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-size-labeler.yml runs on pull_request, so a pull request supplies the workflow definition that writes the artifact — including one from a fork. pr-size-label-apply.yml then consumed that artifact in the base repository, with pull-requests: write, taking both the target pull request number and the label name from it verbatim. The number is now derived from this job's own workflow_run event and the label is checked against the five known size labels. The artifact no longer carries a pull request number at all, so there is nothing left to trust in it beyond a value that must match a fixed list. workflow_run.pull_requests cannot be used on its own: it is empty for every fork pull request and for most same-repository ones, so it is a fast path with a head-commit lookup behind it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr-size-label-apply.yml | 59 ++++++++++++++++++----- .github/workflows/pr-size-labeler.yml | 10 +++- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-size-label-apply.yml b/.github/workflows/pr-size-label-apply.yml index 9c6285ff78..5c94faa490 100644 --- a/.github/workflows/pr-size-label-apply.yml +++ b/.github/workflows/pr-size-label-apply.yml @@ -23,19 +23,56 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - - name: Read PR number and size label - id: read - run: | - PR_NUMBER=$(cat pr-size/pr-number.txt) - SIZE_LABEL=$(cat pr-size/label.txt | tr -d '"') - echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "size_label=$SIZE_LABEL" >> $GITHUB_OUTPUT - echo "PR #$PR_NUMBER should get label: $SIZE_LABEL" + # The artifact is produced by a `pull_request` workflow, so its contents + # come from the pull request author — including authors from forks. This + # job runs in the base repository with `pull-requests: write`, so the + # artifact is treated as untrusted input: the label must be one we + # recognise, and the pull request number is derived from this job's own + # trigger event rather than read from the artifact. + - name: Resolve pull request and validate label + id: resolve + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + const run = context.payload.workflow_run; + + const ALLOWED = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + const label = fs.readFileSync('pr-size/label.txt', 'utf8').trim().replace(/^"|"$/g, ''); + if (!ALLOWED.includes(label)) { + core.setFailed(`Refusing to apply unrecognised label ${JSON.stringify(label)}`); + return; + } + + // workflow_run.pull_requests is empty for pull requests from forks, + // and frequently empty for same-repository ones too, so treat it as + // a fast path and fall back to looking the head commit up. + let number = run.pull_requests?.[0]?.number; + if (!number) { + const { data: associated } = + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: run.head_sha, + }); + const open = associated.filter((pr) => pr.state === 'open'); + if (open.length !== 1) { + core.setFailed( + `Expected exactly one open pull request for ${run.head_sha}, found ${open.length}` + ); + return; + } + number = open[0].number; + } + + console.log(`PR #${number} should get label: ${label}`); + core.setOutput('pr_number', String(number)); + core.setOutput('size_label', label); - name: Remove old size labels uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - PR_NUMBER: ${{ steps.read.outputs.pr_number }} + PR_NUMBER: ${{ steps.resolve.outputs.pr_number }} with: script: | const prNumber = parseInt(process.env.PR_NUMBER); @@ -62,8 +99,8 @@ jobs: - name: Add new size label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - PR_NUMBER: ${{ steps.read.outputs.pr_number }} - SIZE_LABEL: ${{ steps.read.outputs.size_label }} + PR_NUMBER: ${{ steps.resolve.outputs.pr_number }} + SIZE_LABEL: ${{ steps.resolve.outputs.size_label }} with: script: | const prNumber = parseInt(process.env.PR_NUMBER); diff --git a/.github/workflows/pr-size-labeler.yml b/.github/workflows/pr-size-labeler.yml index 39504b1d45..f1e5ba8061 100644 --- a/.github/workflows/pr-size-labeler.yml +++ b/.github/workflows/pr-size-labeler.yml @@ -62,11 +62,17 @@ jobs: console.log(`PR size: ${total} lines -> ${sizeLabel}`); return sizeLabel; + # Only the label goes in the artifact. The PR number is derived by the + # consumer from its own trigger event: this workflow runs on + # `pull_request`, so a pull request from a fork supplies the definition + # that produces these files, and nothing in them can be trusted to say + # which pull request to act on. - name: Save size label to artifact + env: + SIZE_LABEL: ${{ steps.size.outputs.result }} run: | mkdir -p pr-size - echo "${{ steps.size.outputs.result }}" > pr-size/label.txt - echo "${{ github.event.pull_request.number }}" > pr-size/pr-number.txt + echo "$SIZE_LABEL" > pr-size/label.txt - name: Upload artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 From 916c1db90e660c9ff5ecef671485c11b94b11c5c Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:27:56 +0100 Subject: [PATCH 2/2] Keep writing pr-number.txt until the consumer has landed `workflow_run` consumers always execute the copy of the workflow on the default branch, so a pull request can change the producer but not the consumer reading its output. Dropping pr-number.txt here failed the apply job on every open pull request, because the consumer still on main reads that file. The value is written again, bound through env: so it is not interpolated into the shell. The new consumer ignores it; removing the write is a follow-up once this has merged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr-size-labeler.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-size-labeler.yml b/.github/workflows/pr-size-labeler.yml index f1e5ba8061..56e97d0902 100644 --- a/.github/workflows/pr-size-labeler.yml +++ b/.github/workflows/pr-size-labeler.yml @@ -62,17 +62,24 @@ jobs: console.log(`PR size: ${total} lines -> ${sizeLabel}`); return sizeLabel; - # Only the label goes in the artifact. The PR number is derived by the - # consumer from its own trigger event: this workflow runs on - # `pull_request`, so a pull request from a fork supplies the definition - # that produces these files, and nothing in them can be trusted to say - # which pull request to act on. + # This workflow runs on `pull_request`, so a pull request — including one + # from a fork — supplies the definition that produces these files. + # Nothing in them can be trusted to say which pull request to act on, so + # the consumer derives the number from its own trigger event instead. + # + # pr-number.txt is still written even though the new consumer ignores it. + # `workflow_run` consumers always execute the copy on the default branch, + # so until this change merges the consumer reading it is the old one, and + # dropping the file here would break labelling for every open pull + # request. Removing the write is a follow-up once this has landed. - name: Save size label to artifact env: SIZE_LABEL: ${{ steps.size.outputs.result }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | mkdir -p pr-size echo "$SIZE_LABEL" > pr-size/label.txt + echo "$PR_NUMBER" > pr-size/pr-number.txt - name: Upload artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6