Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions .github/workflows/bundle-size-comment.yml
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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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.')
}
98 changes: 98 additions & 0 deletions .github/workflows/bundle-size.yml
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
Loading
Loading