Skip to content

Commit 073d763

Browse files
committed
feat(cicd): add bundle stats comparison
1 parent 7015095 commit 073d763

File tree

4 files changed

+230
-4
lines changed

4 files changed

+230
-4
lines changed

.github/workflows/build.yml

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

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)