Repository navigation
ci: report bundle size changes on pull requests #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| name: bundle-size-comment | ||
|
|
||
| # Runs from the default branch with a write token, even for fork PRs: the artifact it reads is validated, never executed. | ||
| # GitHub only triggers `workflow_run` from the default branch, so the first comment appears once this file is on `main`. | ||
| on: | ||
| workflow_run: | ||
| workflows: | ||
| - bundle-size | ||
| types: | ||
| - completed | ||
|
|
||
| permissions: | ||
| actions: read | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| comment: | ||
| if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| steps: | ||
| - name: Validate triggering workflow | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const run = context.payload.workflow_run | ||
| const shaPattern = /^[0-9a-f]{40}$/ | ||
|
|
||
| if (run.name !== 'bundle-size') { | ||
| core.setFailed('The triggering workflow name is invalid.') | ||
| return | ||
| } | ||
|
|
||
| if (!Number.isSafeInteger(run.id) || run.id <= 0 || !shaPattern.test(run.head_sha) || !Number.isSafeInteger(run.head_repository?.id) || run.head_repository.id <= 0) { | ||
| core.setFailed('The workflow payload contains invalid head repository metadata.') | ||
| return | ||
| } | ||
| - name: Resolve bundle artifact | ||
| id: artifact | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const run = context.payload.workflow_run | ||
| const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| run_id: run.id, | ||
| per_page: 100 | ||
| }) | ||
| const matches = artifacts.filter(artifact => artifact.name === 'bundle-size-report' && !artifact.expired) | ||
|
|
||
| if (matches.length !== 1) { | ||
| core.setFailed('Expected exactly one bundle report artifact from the triggering run.') | ||
| return | ||
| } | ||
|
|
||
| const artifact = matches[0] | ||
| if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0 || !Number.isSafeInteger(artifact.size_in_bytes) || artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 12 * 1024 * 1024) { | ||
| core.setFailed('The bundle report artifact metadata is invalid or too large.') | ||
| return | ||
| } | ||
|
|
||
| core.setOutput('id', String(artifact.id)) | ||
| - name: Download untrusted bundle snapshots | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | ||
| with: | ||
| artifact-ids: ${{ steps.artifact.outputs.id }} | ||
| path: ${{ runner.temp }}/bundle-size-report | ||
| merge-multiple: true | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| run-id: ${{ github.event.workflow_run.id }} | ||
| - name: Resolve triggering pull request | ||
| id: pull | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| BUNDLE_CONTEXT_PATH: ${{ runner.temp }}/bundle-size-report/context.json | ||
| with: | ||
| script: | | ||
| const fs = require('node:fs') | ||
| const path = require('node:path') | ||
| const run = context.payload.workflow_run | ||
| const repository = context.payload.repository | ||
| const candidates = run.pull_requests || [] | ||
| const contextPath = process.env.BUNDLE_CONTEXT_PATH | ||
| const shaPattern = /^[0-9a-f]{40}$/ | ||
|
|
||
| core.setOutput('should-comment', 'false') | ||
|
|
||
| const artifactDirectory = path.dirname(contextPath) | ||
| const files = fs.readdirSync(artifactDirectory).sort() | ||
| if (files.length !== 3 || files[0] !== 'base.json' || files[1] !== 'context.json' || files[2] !== 'pr.json') { | ||
| core.setFailed('The bundle artifact contains unexpected files.') | ||
| return | ||
| } | ||
|
|
||
| const contextStats = fs.lstatSync(contextPath) | ||
| const baseStats = fs.lstatSync(path.join(artifactDirectory, 'base.json')) | ||
| const headStats = fs.lstatSync(path.join(artifactDirectory, 'pr.json')) | ||
| if (!contextStats.isFile() || contextStats.isSymbolicLink() || contextStats.size > 1_024 || | ||
| !baseStats.isFile() || baseStats.isSymbolicLink() || baseStats.size > 5 * 1024 * 1024 || | ||
| !headStats.isFile() || headStats.isSymbolicLink() || headStats.size > 5 * 1024 * 1024) { | ||
| core.setFailed('The bundle artifact does not contain bounded regular files.') | ||
| return | ||
| } | ||
|
|
||
| const bundleContext = JSON.parse(fs.readFileSync(contextPath, 'utf8')) | ||
| const contextKeys = Object.keys(bundleContext) | ||
| if (contextKeys.length !== 1 || contextKeys[0] !== 'prNumber' || !Number.isSafeInteger(bundleContext.prNumber) || bundleContext.prNumber <= 0) { | ||
| core.setFailed('The bundle context contains an invalid pull request number.') | ||
| return | ||
| } | ||
|
|
||
| if (candidates.length > 1 || (candidates.length === 1 && candidates[0].number !== bundleContext.prNumber)) { | ||
| core.setFailed('The bundle context does not match the workflow payload.') | ||
| return | ||
| } | ||
|
|
||
| const { data: pull } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: bundleContext.prNumber | ||
| }) | ||
|
|
||
| if (pull.state !== 'open' || pull.head.sha !== run.head_sha) { | ||
| core.info(`Skipping stale or closed PR #${bundleContext.prNumber}.`) | ||
| return | ||
| } | ||
|
|
||
| if (pull.head.repo?.id !== run.head_repository.id) { | ||
| core.setFailed('The pull request head repository does not match the triggering workflow.') | ||
| return | ||
| } | ||
|
|
||
| if (pull.base.repo.id !== repository.id || pull.base.ref !== repository.default_branch || !shaPattern.test(pull.base.sha)) { | ||
| core.setFailed('The pull request does not target the expected default branch.') | ||
| return | ||
| } | ||
|
|
||
| // `main` can move between the snapshot and this run: skip like a stale head, the next push reports again. | ||
| const baseSnapshot = JSON.parse(fs.readFileSync(path.join(artifactDirectory, 'base.json'), 'utf8')) | ||
| if (baseSnapshot?.sha !== pull.base.sha) { | ||
| core.info(`Skipping PR #${bundleContext.prNumber}: its base moved since the snapshot.`) | ||
| return | ||
| } | ||
|
|
||
| core.setOutput('should-comment', 'true') | ||
| core.setOutput('number', String(bundleContext.prNumber)) | ||
| core.setOutput('base-sha', pull.base.sha) | ||
| core.setOutput('head-sha', run.head_sha) | ||
| - name: Check out the trusted reporter | ||
| if: steps.pull.outputs.should-comment == 'true' | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | ||
| with: | ||
| persist-credentials: false | ||
| ref: ${{ github.sha }} | ||
| - if: steps.pull.outputs.should-comment == 'true' | ||
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | ||
| with: | ||
| node-version: lts/-1 | ||
| - name: Validate snapshots and render report | ||
| if: steps.pull.outputs.should-comment == 'true' | ||
| env: | ||
| BASE_SHA: ${{ steps.pull.outputs.base-sha }} | ||
| BASE_SNAPSHOT: ${{ runner.temp }}/bundle-size-report/base.json | ||
| HEAD_SHA: ${{ steps.pull.outputs.head-sha }} | ||
| HEAD_SNAPSHOT: ${{ runner.temp }}/bundle-size-report/pr.json | ||
| REPORT_PATH: ${{ runner.temp }}/bundle-size-comment.md | ||
| run: | | ||
| node scripts/bundle-size/report.mjs compare \ | ||
| --base "$BASE_SNAPSHOT" \ | ||
| --head "$HEAD_SNAPSHOT" \ | ||
| --base-sha "$BASE_SHA" \ | ||
| --head-sha "$HEAD_SHA" \ | ||
| --output "$REPORT_PATH" | ||
| - name: Update PR comment | ||
| if: steps.pull.outputs.should-comment == 'true' | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| BUNDLE_REPORT_PATH: ${{ runner.temp }}/bundle-size-comment.md | ||
| PR_NUMBER: ${{ steps.pull.outputs.number }} | ||
| with: | ||
| script: | | ||
| const fs = require('node:fs') | ||
| const marker = '<!-- comark-docs-bundle-size-report -->' | ||
| const run = context.payload.workflow_run | ||
| const prNumber = Number(process.env.PR_NUMBER) | ||
|
|
||
| if (!Number.isSafeInteger(prNumber) || prNumber <= 0) { | ||
| core.setFailed('The validated pull request number is invalid.') | ||
| return | ||
| } | ||
|
|
||
| const report = fs.readFileSync(process.env.BUNDLE_REPORT_PATH, 'utf8') | ||
| if (Buffer.byteLength(report, 'utf8') > 60_000) { | ||
| core.setFailed('The bundle report is too large to post safely.') | ||
| return | ||
| } | ||
|
|
||
| const body = `${marker}\n${report}\n[Workflow run](${run.html_url})` | ||
|
|
||
| try { | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: prNumber, | ||
| per_page: 100 | ||
| }) | ||
| const previous = comments.find(comment => comment.user?.login?.endsWith('[bot]') && comment.body?.includes(marker)) | ||
|
|
||
| if (previous) { | ||
| await github.rest.issues.updateComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| comment_id: previous.id, | ||
| body | ||
| }) | ||
| } else { | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: prNumber, | ||
| body | ||
| }) | ||
| } | ||
| } catch (error) { | ||
| core.warning(`Unable to post the bundle report comment: ${error.message}`) | ||
| await core.summary.addRaw(report).write() | ||
| core.info('The bundle report was written to the workflow run summary instead.') | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| name: bundle-size | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: | ||
| - main | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: bundle-size-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| # Builds and measures `main` and the PR side by side, posted by `bundle-size-comment.yml`. | ||
| analyze: | ||
| name: analyze (${{ matrix.target }}) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
|
|
||
| strategy: | ||
| matrix: | ||
| target: [base, pr] | ||
|
|
||
| steps: | ||
| - name: Check out the PR merge | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| persist-credentials: false | ||
| # Both snapshots come from the PR's script, so they share one format even when it changes. | ||
| - name: Keep the PR's report script | ||
| run: cp scripts/bundle-size/report.mjs "$RUNNER_TEMP/report.mjs" | ||
| - name: Check out the base revision | ||
| if: matrix.target == 'base' | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| persist-credentials: false | ||
| ref: ${{ github.event.pull_request.base.sha }} | ||
| - uses: ./.github/actions/setup | ||
| - name: Build with the analyzer | ||
| run: | | ||
| { | ||
| echo "build.analyze.enabled=true" | ||
| echo "build.analyze.template=raw-data" | ||
| echo "build.analyze.filename=$GITHUB_WORKSPACE/playground/.nuxt/analyze/{name}.json" | ||
| echo "build.analyze.projectRoot=$GITHUB_WORKSPACE/" | ||
| } > playground/.nuxtrc | ||
| pnpm build | ||
| - name: Snapshot the bundle | ||
| env: | ||
| GITHUB_TOKEN: ${{ github.token }} | ||
| # Both sides render the same content, so the comparison only reflects code. | ||
| NUXT_DOCS_GITHUB_BRANCH: ${{ github.event.pull_request.base.sha }} | ||
| SNAPSHOT_SHA: ${{ matrix.target == 'base' && github.event.pull_request.base.sha || github.event.pull_request.head.sha }} | ||
| run: | | ||
| # nuxt-og-image signs its image URLs with it. | ||
| NUXT_APP_SECRET="$(openssl rand -base64 32)" PORT=3000 node playground/.output/server/index.mjs & | ||
| timeout 60 bash -c 'until curl -sf -o /dev/null http://localhost:3000/robots.txt; do sleep 1; done' | ||
| node "$RUNNER_TEMP/report.mjs" snapshot \ | ||
| --root playground \ | ||
| --analyze playground/.nuxt/analyze/client.json \ | ||
| --url http://localhost:3000 \ | ||
| --label ${{ matrix.target }} \ | ||
| --sha "$SNAPSHOT_SHA" \ | ||
| --output "$RUNNER_TEMP/bundle-size/${{ matrix.target }}.json" | ||
| - name: Upload the snapshot | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: bundle-size-${{ matrix.target }} | ||
| path: ${{ runner.temp }}/bundle-size/${{ matrix.target }}.json | ||
| if-no-files-found: error | ||
| retention-days: 1 | ||
|
|
||
| # One artifact with both snapshots and the PR number, for the comment workflow to validate. | ||
| report: | ||
| needs: analyze | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| steps: | ||
| - name: Download the snapshots | ||
| uses: actions/download-artifact@v8 | ||
| with: | ||
| pattern: bundle-size-* | ||
| path: ${{ runner.temp }}/bundle-size | ||
| merge-multiple: true | ||
| - name: Write the bundle context | ||
| env: | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| run: printf '{"prNumber":%s}\n' "$PR_NUMBER" > "$RUNNER_TEMP/bundle-size/context.json" | ||
| - name: Upload the report | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: bundle-size-report | ||
| path: ${{ runner.temp }}/bundle-size | ||
| if-no-files-found: error | ||
| retention-days: 7 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.