Files
v10/.github/workflows/bundle-size.yml
T

237 lines
8.7 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Bundle Size
on:
pull_request:
branches:
- main
permissions:
pull-requests: write
jobs:
size:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Install and build PR
run: pnpm install && pnpm build:packages
- name: Measure PR bundle size
run: pnpm -w exec size-limit --json > /tmp/pr-size.json
- name: Measure base bundle size
run: |
git fetch origin ${{ github.base_ref }} --depth=1
git checkout -f FETCH_HEAD
pnpm install
if [ -f .size-limit.json ]; then
pnpm build:packages
pnpm -w exec size-limit --json > /tmp/base-size.json
else
echo '[]' > /tmp/base-size.json
fi
- name: Report
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const pr = JSON.parse(fs.readFileSync('/tmp/pr-size.json', 'utf8'));
const base = JSON.parse(fs.readFileSync('/tmp/base-size.json', 'utf8'));
const baseMap = Object.fromEntries(base.map(e => [e.name, e.size]));
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(2)} kB`;
}
function formatDelta(current, previous) {
if (previous === undefined) return { bytes: '—', pct: '' };
const diff = current - previous;
if (diff === 0) return { bytes: '0 B', pct: '0%' };
const sign = diff > 0 ? '+' : '-';
const pct = Math.abs((diff / previous) * 100).toFixed(1);
return { bytes: `${sign}${formatBytes(Math.abs(diff))}`, pct: `${sign}${pct}%` };
}
function statusIcon(current, previous) {
if (previous === undefined) return '🆕';
const diff = current - previous;
if (diff === 0) return '✅';
if (diff < 0) return '🔽';
const pct = (diff / previous) * 100;
return pct > 10 ? '🔴' : '🔺';
}
// Delta bar: visualizes % change relative to the largest change
function deltaBar(current, previous, maxAbsPct) {
const width = 8;
if (previous === undefined || maxAbsPct === 0) return '░'.repeat(width);
const pct = Math.abs(((current - previous) / previous) * 100);
const filled = Math.round((pct / maxAbsPct) * width);
return '█'.repeat(filled) + '░'.repeat(width - filled);
}
// Group entries by package: @videojs/utils/* -> utils, @videojs/store/* -> store, etc.
const groups = new Map();
for (const entry of pr) {
const match = entry.name.match(/^@videojs\/([^/]+)/);
const pkg = match ? match[1] : 'other';
if (!groups.has(pkg)) groups.set(pkg, []);
groups.get(pkg).push(entry);
}
// First pass: compute per-package totals
const pkgData = [];
let grandTotalPr = 0;
let grandTotalBase = 0;
for (const [pkg, entries] of groups) {
const pkgTotalPr = entries.reduce((s, e) => s + e.size, 0);
const pkgTotalBase = entries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0);
const hasBase = entries.some(e => baseMap[e.name] !== undefined);
grandTotalPr += pkgTotalPr;
grandTotalBase += pkgTotalBase;
const pkgPrev = entries.length > 1 ? pkgTotalBase : baseMap[entries[0].name];
const pkgCur = entries.length > 1 ? pkgTotalPr : entries[0].size;
const changed = hasBase && pkgCur !== pkgPrev;
pkgData.push({ pkg, entries, pkgTotalPr, pkgTotalBase, hasBase, pkgPrev, pkgCur, changed });
}
const maxAbsPct = Math.max(
...pkgData.map(p => {
if (!p.hasBase || p.pkgPrev === 0) return 0;
return Math.abs(((p.pkgCur - p.pkgPrev) / p.pkgPrev) * 100);
})
);
// Overview table
const overview = [];
overview.push('| Package | Size | Diff | | % | |');
overview.push('|---|--:|--:|---|--:|:-:|');
for (const p of pkgData) {
const d = formatDelta(p.pkgCur, p.pkgPrev);
const icon = p.hasBase ? statusIcon(p.pkgCur, p.pkgPrev) : '';
const bar = `\`${deltaBar(p.pkgCur, p.pkgPrev, maxAbsPct)}\``;
overview.push(
`| **@videojs/${p.pkg}** | **${formatBytes(p.pkgCur)}** | ${p.hasBase ? d.bytes : '—'} | ${bar} | ${p.hasBase ? d.pct : ''} | ${icon} |`
);
}
// Detail sections
const details = [];
details.push('#### Subpath Breakdown');
details.push('');
for (const p of pkgData) {
const { pkg, entries, pkgTotalPr, pkgTotalBase, hasBase } = p;
// Subpath display: @videojs/utils/dom -> ./dom, @videojs/store -> .
const displayName = (name) => {
const sub = name.replace(`@videojs/${pkg}`, '');
return sub ? `.${sub}` : '.';
};
details.push(`<details>`);
details.push(`<summary><code>@videojs/${pkg}</code></summary>`);
details.push('');
details.push('| Subpath | Base | PR | Diff | % | |');
details.push('|---|--:|--:|--:|--:|:-:|');
for (const entry of entries) {
const prev = baseMap[entry.name];
const d = formatDelta(entry.size, prev);
details.push(
`| \`${displayName(entry.name)}\` | ${prev !== undefined ? formatBytes(prev) : '—'} | **${formatBytes(entry.size)}** | ${d.bytes} | ${d.pct} | ${statusIcon(entry.size, prev)} |`
);
}
if (entries.length > 1) {
const d = formatDelta(pkgTotalPr, pkgTotalBase);
details.push(
`| **total** | **${hasBase ? formatBytes(pkgTotalBase) : '—'}** | **${formatBytes(pkgTotalPr)}** | **${hasBase ? d.bytes : '—'}** | **${hasBase ? d.pct : ''}** | |`
);
}
details.push('');
details.push('</details>');
details.push('');
}
const grandDelta = formatDelta(grandTotalPr, grandTotalBase);
const marker = '<!-- bundle-size-report -->';
const body = [
marker,
'### 📦 Bundle Size Report',
'',
...overview,
'',
`**Total: ${formatBytes(grandTotalPr)}**${grandTotalBase ? ` · ${grandDelta.bytes} · ${grandDelta.pct}` : ''}`,
'',
'---',
'',
...details,
'---',
'',
'<details>',
'<summary>️ How to interpret</summary>',
'',
'Each package shows its own code size with workspace and peer dependencies externalized.',
'Sizes are minified + brotli, measured via [size-limit](https://github.com/ai/size-limit) with esbuild.',
'',
'| Icon | Meaning |',
'|---|---|',
'| ✅ | No change |',
'| 🔺 | Increased ≤ 10% |',
'| 🔴 | Increased > 10% |',
'| 🔽 | Decreased |',
'| 🆕 | New (no baseline) |',
'',
'Run `pnpm size` locally to check current sizes.',
'</details>',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body?.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}