mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
237 lines
8.7 KiB
YAML
237 lines
8.7 KiB
YAML
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,
|
||
});
|
||
}
|