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)}`,