diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d9b9fbdad286..a7f3e0aafb24c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,3 +68,11 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' # We want to ensure that static exports for all locales do not occur on `pull_request` events NEXT_PUBLIC_STATIC_EXPORT_LOCALE: ${{ github.event_name == 'push' }} + # See https://github.com/vercel/next.js/pull/81318 + TURBOPACK_STATS: ${{ matrix.os == 'ubuntu-latest' }} + + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: matrix.os == 'ubuntu-latest' + with: + name: webpack-stats + path: apps/site/.next/server/webpack-stats.json diff --git a/.github/workflows/bundle-compare.yml b/.github/workflows/bundle-compare.yml new file mode 100644 index 0000000000000..b32a848e9882a --- /dev/null +++ b/.github/workflows/bundle-compare.yml @@ -0,0 +1,68 @@ +name: Compare Bundle Size + +on: + workflow_run: + workflows: ['Build'] + types: [completed] + +permissions: + contents: read + actions: read + # To create the comment + pull-requests: write + +jobs: + compare: + name: Compare Bundle Stats + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Git Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Download Stats (HEAD) + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: webpack-stats + path: head-stats + run-id: ${{ github.event.workflow_run.workflow_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get Run ID from BASE + id: base-run + env: + WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} + GH_TOKEN: ${{ github.token }} + run: | + ID=$(gh run list -c $GITHUB_SHA -w $WORKFLOW_ID -L 1 --json databaseId --jq ".[].databaseId") + echo "run_id=$ID" >> $GITHUB_OUTPUT + + - name: Download Stats (BASE) + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: webpack-stats + path: base-stats + run-id: ${{ steps.base-run.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Compare Bundle Size + id: compare-bundle-size + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + HEAD_STATS_PATH: ./head-stats/webpack-stats.json + BASE_STATS_PATH: ./base-stats/webpack-stats.json + with: + script: | + const { compare } = await import('${{github.workspace}}/apps/site/scripts/compare-size/index.mjs') + await compare({core}) + + - name: Add Comment to PR + uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 # v3.0.0 + with: + comment-tag: 'compare_bundle_size' + message: ${{ steps.compare-bundle-size.outputs.comment }} diff --git a/apps/site/scripts/compare-size/index.mjs b/apps/site/scripts/compare-size/index.mjs new file mode 100644 index 0000000000000..f67b78a1a4c1a --- /dev/null +++ b/apps/site/scripts/compare-size/index.mjs @@ -0,0 +1,152 @@ +import { readFile } from 'node:fs/promises'; + +/** + * Formats bytes into human-readable format + * @param {number} bytes - Number of bytes + * @returns {string} Formatted string (e.g., "1.5 KB") + */ +const formatBytes = bytes => { + if (bytes === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)); + return (bytes / Math.pow(1024, index)).toFixed(2) + ' ' + units[index]; +}; + +/** + * Calculates percentage change + * @param {number} oldValue - Original value + * @param {number} newValue - New value + * @returns {string} Formatted percentage + */ +const formatPercent = (oldValue, newValue) => { + const percent = (((newValue - oldValue) / oldValue) * 100).toFixed(2); + return `${percent > 0 ? '+' : ''}${percent}%`; +}; + +/** + * Categorizes asset changes + */ +const categorizeChanges = (oldAssets, newAssets) => { + const oldMap = new Map(oldAssets.map(a => [a.name, a])); + const newMap = new Map(newAssets.map(a => [a.name, a])); + const changes = { added: [], removed: [], modified: [] }; + + for (const [name, oldAsset] of oldMap) { + const newAsset = newMap.get(name); + if (!newAsset) { + changes.removed.push({ name, size: oldAsset.size }); + } else if (oldAsset.size !== newAsset.size) { + changes.modified.push({ + name, + oldSize: oldAsset.size, + newSize: newAsset.size, + delta: newAsset.size - oldAsset.size, + }); + } + } + + for (const [name, newAsset] of newMap) { + if (!oldMap.has(name)) { + changes.added.push({ name, size: newAsset.size }); + } + } + + return changes; +}; + +/** + * Builds a collapsible table section + */ +const tableSection = (title, items, columns, icon) => { + if (!items.length) { + return ''; + } + const header = `| ${columns.map(c => c.label).join(' | ')} |\n`; + const separator = `| ${columns.map(() => '---').join(' | ')} |\n`; + const rows = items + .map(item => `| ${columns.map(c => c.format(item)).join(' | ')} |`) + .join('\n'); + return `
\n${icon} ${title} (${items.length})\n\n${header}${separator}${rows}\n\n
\n\n`; +}; + +/** + * Compares old and new assets and returns a markdown report + */ +function reportDiff({ assets: oldAssets }, { assets: newAssets }) { + const changes = categorizeChanges(oldAssets, newAssets); + + const oldTotal = oldAssets.reduce((sum, a) => sum + a.size, 0); + const newTotal = newAssets.reduce((sum, a) => sum + a.size, 0); + const totalDelta = newTotal - oldTotal; + + // Summary table + let report = `# 📦 Build Size Comparison\n\n## Summary\n\n| Metric | Value |\n|--------|-------|\n`; + report += `| Old Total Size | ${formatBytes(oldTotal)} |\n`; + report += `| New Total Size | ${formatBytes(newTotal)} |\n`; + report += `| Delta | ${formatBytes(totalDelta)} (${formatPercent( + oldTotal, + newTotal + )}) |\n\n`; + + // Changes + if ( + changes.added.length || + changes.removed.length || + changes.modified.length + ) { + report += `### Changes\n\n`; + + // Asset tables + report += tableSection( + 'Added Assets', + changes.added, + [ + { label: 'Name', format: a => `\`${a.name}\`` }, + { label: 'Size', format: a => formatBytes(a.size) }, + ], + '➕' + ); + + report += tableSection( + 'Removed Assets', + changes.removed, + [ + { label: 'Name', format: a => `\`${a.name}\`` }, + { label: 'Size', format: a => formatBytes(a.size) }, + ], + '➖' + ); + + report += tableSection( + 'Modified Assets', + changes.modified, + [ + { label: 'Name', format: a => `\`${a.name}\`` }, + { label: 'Old Size', format: a => formatBytes(a.oldSize) }, + { label: 'New Size', format: a => formatBytes(a.newSize) }, + { + label: 'Delta', + format: a => + `${a.delta > 0 ? '📈' : '📉'} ${formatBytes( + a.delta + )} (${formatPercent(a.oldSize, a.newSize)})`, + }, + ], + '🔄' + ); + } + + return report; +} + +export async function compare({ core }) { + const [oldAssets, newAssets] = await Promise.all([ + readFile(process.env.BASE_STATS_PATH).then(f => JSON.parse(f)), + readFile(process.env.HEAD_STATS_PATH).then(f => JSON.parse(f)), + ]); + + const comment = reportDiff(oldAssets, newAssets); + core.setOutput('comment', comment); +} diff --git a/apps/site/turbo.json b/apps/site/turbo.json index a42602d86ff53..8cbaee9d8800e 100644 --- a/apps/site/turbo.json +++ b/apps/site/turbo.json @@ -19,7 +19,8 @@ "NEXT_PUBLIC_ORAMA_ENDPOINT", "NEXT_PUBLIC_DATA_URL", "TURBO_CACHE", - "TURBO_TELEMETRY_DISABLED" + "TURBO_TELEMETRY_DISABLED", + "TURBOPACK_STATS" ] }, "build": { @@ -45,7 +46,8 @@ "NEXT_PUBLIC_ORAMA_ENDPOINT", "NEXT_PUBLIC_DATA_URL", "TURBO_CACHE", - "TURBO_TELEMETRY_DISABLED" + "TURBO_TELEMETRY_DISABLED", + "TURBOPACK_STATS" ] }, "start": { @@ -64,7 +66,8 @@ "NEXT_PUBLIC_ORAMA_ENDPOINT", "NEXT_PUBLIC_DATA_URL", "TURBO_CACHE", - "TURBO_TELEMETRY_DISABLED" + "TURBO_TELEMETRY_DISABLED", + "TURBOPACK_STATS" ] }, "deploy": { @@ -89,7 +92,8 @@ "NEXT_PUBLIC_ORAMA_ENDPOINT", "NEXT_PUBLIC_DATA_URL", "TURBO_CACHE", - "TURBO_TELEMETRY_DISABLED" + "TURBO_TELEMETRY_DISABLED", + "TURBOPACK_STATS" ] }, "lint:js": {