From 2b935c86e1e2928af18dace7d56d7fe3f88a96bc Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Fri, 12 Jun 2026 11:19:03 -0400 Subject: [PATCH] fix: Make bundle size workflow work on fork PRs The bundle size workflow failed on every fork PR. GitHub downgrades the GITHUB_TOKEN to read-only for pull_request events from forks, so the statuses:write permission the workflow requested was denied, and posting the commit status failed with "Resource not accessible by integration". Split into two workflows with privilege separation: - "Measure bundle size" runs the untrusted PR build with contents:read only and uploads the measured sizes as an artifact. - "Report bundle size" runs on workflow_run (in the base repo context, so it keeps statuses:write even for fork PRs) and posts the commit status. It never checks out or executes PR code. The commit SHA the status is attached to is taken from the trusted workflow_run.head_sha event field, not from the artifact, so untrusted fork code cannot redirect the status onto an arbitrary commit. head_sha is the PR head (not the merge commit the workflow previously used). --- .github/workflows/bundle-size-report.yml | 63 +++++++++++++++++++ .github/workflows/bundle-size.yml | 40 +++++------- .../workflows/bundle-size/status-report.js | 38 +++++------ 3 files changed, 97 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/bundle-size-report.yml diff --git a/.github/workflows/bundle-size-report.yml b/.github/workflows/bundle-size-report.yml new file mode 100644 index 0000000000..e46daf4761 --- /dev/null +++ b/.github/workflows/bundle-size-report.yml @@ -0,0 +1,63 @@ +name: Report bundle size + +# Runs after "Measure bundle size" completes. Because workflow_run executes in +# the context of the base repository, its GITHUB_TOKEN keeps statuses:write +# even when the triggering run came from a fork PR. This workflow posts a commit +# status using the trusted workflow_run head SHA and (on success) the measured +# sizes from the artifact — it never checks out or executes the PR's code. +on: + workflow_run: + workflows: ['Measure bundle size'] + types: + - completed + +permissions: + statuses: write + # Required to download the artifact from the triggering workflow run. + actions: read + +jobs: + report: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.event == 'push' + steps: + # Trusted code only: the reporting script comes from the base repo's + # default branch, not from the artifact produced by the PR build. + - name: Checkout the base repo + uses: actions/checkout@v4 + + # Untrusted DATA only, pinned to the run that triggered this workflow. + # Skipped on a failed run (no artifact is produced); the report workflow + # posts an error status from the run conclusion instead. + - name: Download the size report artifact + if: github.event.workflow_run.conclusion == 'success' + uses: actions/download-artifact@v4 + with: + name: size-report + path: bundle-size-data + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Post the commit status + uses: actions/github-script@v7 + env: + # head_sha comes from the trusted workflow_run event, not from the + # untrusted artifact, so a fork PR cannot redirect the status onto an + # arbitrary commit. It is the PR head (not the merge commit), which is + # what we want to annotate. + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + CONCLUSION: ${{ github.event.workflow_run.conclusion }} + TARGET_URL: ${{ github.event.workflow_run.html_url }} + with: + script: | + const { reportBundleSize } = await import(process.cwd() + '/.github/workflows/bundle-size/status-report.js'); + await reportBundleSize({ + github, + context, + sha: process.env.HEAD_SHA, + dataDir: process.cwd() + '/bundle-size-data', + conclusion: process.env.CONCLUSION, + targetUrl: process.env.TARGET_URL, + }); diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index cef5047a05..fbff9cb8d2 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -8,8 +8,13 @@ on: branches: - main +# This workflow runs untrusted PR code (npm install + build) and therefore +# requests no write permissions. Fork PRs would have their token downgraded to +# read-only anyway. Posting the commit status is delegated to the separate +# "Report bundle size" workflow (bundle-size-report.yml), which runs with +# elevated permissions but never executes PR code. permissions: - statuses: write + contents: read jobs: measure: @@ -27,13 +32,6 @@ jobs: cd bundle-size npm i --force - - uses: actions/github-script@v7 - name: Create a pending status on the commit - with: - script: | - const { statusReportStart } = await import(process.cwd() + '/bundle-size/status-report.js'); - await statusReportStart({ context, github }); - # ------- Base branch size measurement ------------------------------ - name: Checkout the base branch @@ -73,25 +71,15 @@ jobs: cat output.json + # Only the numeric measurements are uploaded. The report workflow derives + # the commit SHA from the trusted workflow_run event, not from this + # artifact, and handles a missing/failed run via the run conclusion — so + # no artifact is needed on the failure path. - name: Upload size report artifact uses: actions/upload-artifact@v4 with: name: size-report - path: bundle-size/output.json - - - name: Update the commit status with calculated results - uses: actions/github-script@v7 - with: - script: | - const { statusReportSuccess } = await import(process.cwd() + '/bundle-size/status-report.js'); - await statusReportSuccess({ context, github }); - - # ------- Error reporting ------------------------------------------- - - - name: Report failure - if: failure() - uses: actions/github-script@v7 - with: - script: | - const { statusReportFailure } = await import(process.cwd() + '/bundle-size/status-report.js'); - await statusReportFailure({ context, github }); + path: | + bundle-size/output.json + bundle-size/output-basebranch.json + if-no-files-found: error diff --git a/.github/workflows/bundle-size/status-report.js b/.github/workflows/bundle-size/status-report.js index 4951dd0577..6eaa36ef58 100644 --- a/.github/workflows/bundle-size/status-report.js +++ b/.github/workflows/bundle-size/status-report.js @@ -22,38 +22,40 @@ function formatDiff(prSize, headSize) { )} KB)`; } -function getStatusMetadata(context) { +function getStatusMetadata(context, sha, targetUrl) { return { owner: context.repo.owner, repo: context.repo.repo, - sha: context.sha, + sha, context: 'Bundle size', - state: 'pending', - target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + target_url: targetUrl, }; } -export async function statusReportStart({ github, context }) { - await github.rest.repos.createCommitStatus({ ...getStatusMetadata(context), state: 'pending' }); -} +// Entry point for the "Report bundle size" workflow. Posts a commit status on +// `sha` (the trusted workflow_run head SHA). On a non-success run it reports an +// error without touching the artifact. On success it reads the measurement +// data, whose values only ever flow into numeric formatting, never execution. +export async function reportBundleSize({ github, context, sha, dataDir, conclusion, targetUrl }) { + const metadata = getStatusMetadata(context, sha, targetUrl); -export async function statusReportFailure({ github, context }) { - await github.rest.repos.createCommitStatus({ - ...getStatusMetadata(context), - state: 'error', - description: 'The workflow encountered an error.', - }); -} + if (conclusion !== 'success') { + await github.rest.repos.createCommitStatus({ + ...metadata, + state: 'error', + description: 'The workflow encountered an error.', + }); + return; + } -export async function statusReportSuccess({ github, context }) { - const basebranch = readJson('./bundle-size/output-basebranch.json'); - const pr = readJson('./bundle-size/output.json'); + const basebranch = readJson(`${dataDir}/output-basebranch.json`); + const pr = readJson(`${dataDir}/output.json`); console.log('Base branch:', basebranch); console.log('This PR:', pr); await github.rest.repos.createCommitStatus({ - ...getStatusMetadata(context), + ...metadata, state: 'success', description: [ `CSS: ${formatDiff(pr.cssCompressedSize, basebranch.cssCompressedSize)}`,