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.push(`@videojs/${pkg}`); 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.push(''); } const grandDelta = formatDelta(grandTotalPr, grandTotalBase); const marker = ''; const body = [ marker, '### ๐Ÿ“ฆ Bundle Size Report', '', ...overview, '', `**Total: ${formatBytes(grandTotalPr)}**${grandTotalBase ? ` ยท ${grandDelta.bytes} ยท ${grandDelta.pct}` : ''}`, '', '---', '', ...details, '---', '', '
', 'โ„น๏ธ How to interpret', '', '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.', '
', ].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, }); }