Skip to content

Commit 8504a85

Browse files
authored
feat(cicd): add bundle stats comparison (nodejs#8245)
* feat(cicd): add bundle stats comparison * fixup! * fixup!
1 parent bfaa6f8 commit 8504a85

File tree

4 files changed

+236
-4
lines changed

4 files changed

+236
-4
lines changed

.github/workflows/build.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,11 @@ jobs:
6969
NODE_OPTIONS: '--max_old_space_size=4096'
7070
# We want to ensure that static exports for all locales do not occur on `pull_request` events
7171
NEXT_PUBLIC_STATIC_EXPORT_LOCALE: ${{ github.event_name == 'push' }}
72+
# See https://github.com/vercel/next.js/pull/81318
73+
TURBOPACK_STATS: ${{ matrix.os == 'ubuntu-latest' }}
74+
75+
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
76+
if: matrix.os == 'ubuntu-latest'
77+
with:
78+
name: webpack-stats
79+
path: apps/site/.next/server/webpack-stats.json
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Compare Bundle Size
2+
3+
on:
4+
workflow_run:
5+
workflows: ['Build']
6+
types: [completed]
7+
8+
permissions:
9+
contents: read
10+
actions: read
11+
# To create the comment
12+
pull-requests: write
13+
14+
jobs:
15+
compare:
16+
name: Compare Bundle Stats
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Harden Runner
21+
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
22+
with:
23+
egress-policy: audit
24+
25+
- name: Git Checkout
26+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
27+
28+
- name: Download Stats (HEAD)
29+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
30+
with:
31+
name: webpack-stats
32+
path: head-stats
33+
run-id: ${{ github.event.workflow_run.workflow_id }}
34+
github-token: ${{ secrets.GITHUB_TOKEN }}
35+
36+
- name: Get Run ID from BASE
37+
id: base-run
38+
env:
39+
WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }}
40+
GH_TOKEN: ${{ github.token }}
41+
run: |
42+
ID=$(gh run list -c $GITHUB_SHA -w $WORKFLOW_ID -L 1 --json databaseId --jq ".[].databaseId")
43+
echo "run_id=$ID" >> $GITHUB_OUTPUT
44+
45+
- name: Download Stats (BASE)
46+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
47+
with:
48+
name: webpack-stats
49+
path: base-stats
50+
run-id: ${{ steps.base-run.outputs.run_id }}
51+
github-token: ${{ secrets.GITHUB_TOKEN }}
52+
53+
- name: Compare Bundle Size
54+
id: compare-bundle-size
55+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
56+
env:
57+
HEAD_STATS_PATH: ./head-stats/webpack-stats.json
58+
BASE_STATS_PATH: ./base-stats/webpack-stats.json
59+
with:
60+
script: |
61+
const { compare } = await import('${{github.workspace}}/apps/site/scripts/compare-size/index.mjs')
62+
await compare({core})
63+
64+
- name: Add Comment to PR
65+
uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 # v3.0.0
66+
with:
67+
comment-tag: 'compare_bundle_size'
68+
message: ${{ steps.compare-bundle-size.outputs.comment }}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
/**
4+
* Formats bytes into human-readable format
5+
* @param {number} bytes - Number of bytes
6+
* @returns {string} Formatted string (e.g., "1.5 KB")
7+
*/
8+
const formatBytes = bytes => {
9+
if (bytes === 0) {
10+
return '0 B';
11+
}
12+
const units = ['B', 'KB', 'MB', 'GB'];
13+
const index = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
14+
return (bytes / Math.pow(1024, index)).toFixed(2) + ' ' + units[index];
15+
};
16+
17+
/**
18+
* Calculates percentage change
19+
* @param {number} oldValue - Original value
20+
* @param {number} newValue - New value
21+
* @returns {string} Formatted percentage
22+
*/
23+
const formatPercent = (oldValue, newValue) => {
24+
const percent = (((newValue - oldValue) / oldValue) * 100).toFixed(2);
25+
return `${percent > 0 ? '+' : ''}${percent}%`;
26+
};
27+
28+
/**
29+
* Categorizes asset changes
30+
*/
31+
const categorizeChanges = (oldAssets, newAssets) => {
32+
const oldMap = new Map(oldAssets.map(a => [a.name, a]));
33+
const newMap = new Map(newAssets.map(a => [a.name, a]));
34+
const changes = { added: [], removed: [], modified: [] };
35+
36+
for (const [name, oldAsset] of oldMap) {
37+
const newAsset = newMap.get(name);
38+
if (!newAsset) {
39+
changes.removed.push({ name, size: oldAsset.size });
40+
} else if (oldAsset.size !== newAsset.size) {
41+
changes.modified.push({
42+
name,
43+
oldSize: oldAsset.size,
44+
newSize: newAsset.size,
45+
delta: newAsset.size - oldAsset.size,
46+
});
47+
}
48+
}
49+
50+
for (const [name, newAsset] of newMap) {
51+
if (!oldMap.has(name)) {
52+
changes.added.push({ name, size: newAsset.size });
53+
}
54+
}
55+
56+
return changes;
57+
};
58+
59+
/**
60+
* Builds a collapsible table section
61+
*/
62+
const tableSection = (title, items, columns, icon) => {
63+
if (!items.length) {
64+
return '';
65+
}
66+
const header = `| ${columns.map(c => c.label).join(' | ')} |\n`;
67+
const separator = `| ${columns.map(() => '---').join(' | ')} |\n`;
68+
const rows = items
69+
.map(item => `| ${columns.map(c => c.format(item)).join(' | ')} |`)
70+
.join('\n');
71+
return `<details>\n<summary>${icon} ${title} <strong>(${items.length})</strong></summary>\n\n${header}${separator}${rows}\n\n</details>\n\n`;
72+
};
73+
74+
/**
75+
* Compares old and new assets and returns a markdown report
76+
*/
77+
function reportDiff({ assets: oldAssets }, { assets: newAssets }) {
78+
const changes = categorizeChanges(oldAssets, newAssets);
79+
80+
const oldTotal = oldAssets.reduce((sum, a) => sum + a.size, 0);
81+
const newTotal = newAssets.reduce((sum, a) => sum + a.size, 0);
82+
const totalDelta = newTotal - oldTotal;
83+
84+
// Summary table
85+
let report = `# 📦 Build Size Comparison\n\n## Summary\n\n| Metric | Value |\n|--------|-------|\n`;
86+
report += `| Old Total Size | ${formatBytes(oldTotal)} |\n`;
87+
report += `| New Total Size | ${formatBytes(newTotal)} |\n`;
88+
report += `| Delta | ${formatBytes(totalDelta)} (${formatPercent(
89+
oldTotal,
90+
newTotal
91+
)}) |\n\n`;
92+
93+
// Changes
94+
if (
95+
changes.added.length ||
96+
changes.removed.length ||
97+
changes.modified.length
98+
) {
99+
report += `### Changes\n\n`;
100+
101+
// Asset tables
102+
report += tableSection(
103+
'Added Assets',
104+
changes.added,
105+
[
106+
{ label: 'Name', format: a => `\`${a.name}\`` },
107+
{ label: 'Size', format: a => formatBytes(a.size) },
108+
],
109+
'➕'
110+
);
111+
112+
report += tableSection(
113+
'Removed Assets',
114+
changes.removed,
115+
[
116+
{ label: 'Name', format: a => `\`${a.name}\`` },
117+
{ label: 'Size', format: a => formatBytes(a.size) },
118+
],
119+
'➖'
120+
);
121+
122+
report += tableSection(
123+
'Modified Assets',
124+
changes.modified,
125+
[
126+
{ label: 'Name', format: a => `\`${a.name}\`` },
127+
{ label: 'Old Size', format: a => formatBytes(a.oldSize) },
128+
{ label: 'New Size', format: a => formatBytes(a.newSize) },
129+
{
130+
label: 'Delta',
131+
format: a =>
132+
`${a.delta > 0 ? '📈' : '📉'} ${formatBytes(
133+
a.delta
134+
)} (${formatPercent(a.oldSize, a.newSize)})`,
135+
},
136+
],
137+
'🔄'
138+
);
139+
}
140+
141+
return report;
142+
}
143+
144+
export async function compare({ core }) {
145+
const [oldAssets, newAssets] = await Promise.all([
146+
readFile(process.env.BASE_STATS_PATH).then(f => JSON.parse(f)),
147+
readFile(process.env.HEAD_STATS_PATH).then(f => JSON.parse(f)),
148+
]);
149+
150+
const comment = reportDiff(oldAssets, newAssets);
151+
core.setOutput('comment', comment);
152+
}

apps/site/turbo.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"NEXT_PUBLIC_ORAMA_ENDPOINT",
2020
"NEXT_PUBLIC_DATA_URL",
2121
"TURBO_CACHE",
22-
"TURBO_TELEMETRY_DISABLED"
22+
"TURBO_TELEMETRY_DISABLED",
23+
"TURBOPACK_STATS"
2324
]
2425
},
2526
"build": {
@@ -45,7 +46,8 @@
4546
"NEXT_PUBLIC_ORAMA_ENDPOINT",
4647
"NEXT_PUBLIC_DATA_URL",
4748
"TURBO_CACHE",
48-
"TURBO_TELEMETRY_DISABLED"
49+
"TURBO_TELEMETRY_DISABLED",
50+
"TURBOPACK_STATS"
4951
]
5052
},
5153
"start": {
@@ -64,7 +66,8 @@
6466
"NEXT_PUBLIC_ORAMA_ENDPOINT",
6567
"NEXT_PUBLIC_DATA_URL",
6668
"TURBO_CACHE",
67-
"TURBO_TELEMETRY_DISABLED"
69+
"TURBO_TELEMETRY_DISABLED",
70+
"TURBOPACK_STATS"
6871
]
6972
},
7073
"deploy": {
@@ -89,7 +92,8 @@
8992
"NEXT_PUBLIC_ORAMA_ENDPOINT",
9093
"NEXT_PUBLIC_DATA_URL",
9194
"TURBO_CACHE",
92-
"TURBO_TELEMETRY_DISABLED"
95+
"TURBO_TELEMETRY_DISABLED",
96+
"TURBOPACK_STATS"
9397
]
9498
},
9599
"lint:js": {

0 commit comments

Comments
 (0)