CI: Track SWC native binary size in PR stats comment (#92938)

### What?

Adds a new "Native Binary" section to the `Stats from current PR`
comment posted by the `generate-pull-request-stats` GitHub Action. The
section reports the size of the SWC native binary
(`packages/next-swc/native/*.node`) built for the PR and the change
relative to the latest value recorded on `canary`.

Example row:

> | Metric | Canary | PR | Change | Trend |
> |:-------|-------:|---:|-------:|:-----:|
> | SWC Binary Size | 113 MB | 121 MB | 🔴 +7.34 MB (+6%) | ▁▃▅▇█ |

### Why?

The `.node` binary is a sizable, user-facing artifact (it ships with
`next`). Today there's no visibility on its size in PRs, so regressions
from Rust/Cargo changes are easy to miss. Surfacing the size (and a diff
vs. canary) in the existing stats comment gives immediate feedback on
any change that affects the binary.

### How?

- **Measurement
(`.github/actions/next-stats-action/src/run/index.js`):** After the
action copies the pre-built native binary into the working dirs, sum the
size of every `*.node` file under the action's `native/` directory and
store it as `General.swcBinarySize` on both the canary and PR stat sets.
- **Canary baseline via KV history
(`.github/actions/next-stats-action/src/add-comment.js`):** The workflow
downloads a single pre-built binary and copies it into both the canary
and PR checkouts, so an in-run diff would always be zero. Instead, for
PR runs we override `mainRepoStats.General.swcBinarySize` with the most
recent `swcBinarySize` from the Vercel KV history (the same store
already used for other canary metrics). Canary runs keep the measured
value, and the existing persistence path (`saveToHistory` / `General`)
writes it back so future PRs see an updated baseline. If no history
exists yet, the Canary column renders `N/A` and no change is shown.
- **Rendering:** New `generateNativeBinarySection` emits a
`<details>`-wrapped section styled like Bundle Sizes / All Metrics,
rendered right after the Bundle Sizes block. It reuses `prettify`,
`formatChange`, and `generateTrendBar` so formatting, thresholds, and
sparklines are consistent with the rest of the comment. A tight
threshold entry (`swcBinarySize: { absoluteMin: 10 KB, percentMin: 0.5%,
percentOnly: 0.05% }`) is added so meaningful movement is flagged while
tiny determinism noise is ignored.
- **Top-line summary:** Because `swcBinarySize` lives on `General`, the
existing `generateChangeSummary` automatically promotes significant
changes into the headline regression/improvement table at the top of the
comment, using the label `SWC Binary Size`.
- **Sharded runs:** The workflow runs two sharded jobs (Webpack and
Turbopack). The binary is bundler-independent, so both shards report the
same value; `aggregate-results.js` merges `General` via `Object.assign`,
which is correct here (last-writer-wins on an identical value).

No workflow changes are required — the existing `pull_request_stats.yml`
already downloads the `next-swc-binary` artifact and copies it into the
action's `native/` directory.

### Verification

Smoke-tested `add-comment.js` locally with `LOCAL_STATS=1` in two
scenarios:

- No KV history configured → Canary column renders `N/A`, PR value
renders, Change is `-`, section still shown.
- Mocked KV history containing prior `swcBinarySize` values → full diff
rendered in both the Native Binary section and the top-level
significance summary, with a sparkline of the last 5 historical entries.

Closes NEXT-

<!-- NEXT_JS_LLM_PR -->

---------

Co-authored-by: Tobias Koppers <[email protected]>
Co-authored-by: Claude <[email protected]>
This commit is contained in:
Tobias Koppers
2026-04-18 20:58:47 +02:00
committed by GitHub
co-authored by Tobias Koppers Claude
parent 87e7b083b8
commit bb2a4457b6
2 changed files with 109 additions and 0 deletions
@@ -55,6 +55,7 @@ const METRIC_LABELS = {
buildDurationCachedTurbo: 'Turbo Build Time (cached)',
// General metrics
nodeModulesSize: 'node_modules Size',
swcBinarySize: 'SWC Binary Size',
}
// Group configuration for organizing the comment
@@ -209,6 +210,10 @@ const METRIC_THRESHOLDS = {
// <10KB AND <1%, OR <0.01%
nodeModulesSize: { absoluteMin: 10240, percentMin: 1, percentOnly: 0.01 },
// SWC native binary (~tens of MB): deterministic, but smaller baseline
// <10KB AND <0.5%, OR <0.05%
swcBinarySize: { absoluteMin: 10240, percentMin: 0.5, percentOnly: 0.05 },
// Bundle sizes (KB-MB): deterministic
// <2KB AND <1%, OR <0.1%
bytes: { absoluteMin: 2048, percentMin: 1, percentOnly: 0.1 },
@@ -973,6 +978,57 @@ function generateDiffsSection(result) {
return content
}
// Find the most recent value for a metric in the KV history.
function getLatestHistoricalValue(history, metricKey) {
if (!history?.entries?.length) return undefined
for (let i = history.entries.length - 1; i >= 0; i--) {
const val = history.entries[i].metrics?.[metricKey]
if (typeof val === 'number') return val
}
return undefined
}
// Generate the dedicated Native Binary section shown after Bundle Sizes.
function generateNativeBinarySection(mainStats, diffStats, history) {
const mainGeneral = mainStats?.General || {}
const diffGeneral = diffStats?.General || {}
const mainVal = mainGeneral.swcBinarySize
const diffVal = diffGeneral.swcBinarySize
// Nothing to show if we don't have any measurement
if (typeof mainVal !== 'number' && typeof diffVal !== 'number') return ''
const mainStr = prettify(mainVal, 'bytes')
const diffStr = prettify(diffVal, 'bytes')
const change = formatChange(mainVal, diffVal, 'bytes', 'swcBinarySize')
const histValues = getHistoricalValues(history, 'swcBinarySize')
const sparkline = generateTrendBar(histValues)
const hasTrend = Boolean(sparkline)
const header = hasTrend
? `| Metric | Canary | PR | Change | Trend |
|:-------|-------:|---:|-------:|:-----:|`
: `| Metric | Canary | PR | Change |
|:-------|-------:|---:|-------:|`
const row = hasTrend
? `| SWC Binary Size | ${mainStr} | ${diffStr} | ${change.text} | ${sparkline} |`
: `| SWC Binary Size | ${mainStr} | ${diffStr} | ${change.text} |`
return `<details>
<summary><strong>🦀 Native Binary</strong></summary>
Size of the native SWC binary (\`packages/next-swc/native/*.node\`). The Canary column is the most recent value recorded on the canary branch.
${header}
${row}
</details>
`
}
function generatePrTarballSection(actionInfo) {
if (actionInfo.isRelease || !actionInfo.githubHeadSha) return ''
@@ -1016,6 +1072,24 @@ module.exports = async function addComment(
const result = results[i]
const isLastResult = i === results.length - 1
// The native SWC binary is shared between the canary and PR checkouts in
// a single run (the workflow downloads it once and copies it into both),
// so the in-run "canary" value is identical to the PR value. Override the
// canary baseline with the last recorded value from KV history so the
// diff is meaningful. Canary runs skip this and keep the measured value.
if (!actionInfo.isRelease && result.mainRepoStats?.General) {
const historicalSwcSize = getLatestHistoricalValue(
history,
'swcBinarySize'
)
if (typeof historicalSwcSize === 'number') {
result.mainRepoStats.General.swcBinarySize = historicalSwcSize
} else {
// No history yet — hide the canary value so the table renders N/A
delete result.mainRepoStats.General.swcBinarySize
}
}
// Add summary showing only significant changes (not collapsed)
if (i === 0) {
comment += generateChangeSummary(
@@ -1041,6 +1115,13 @@ module.exports = async function addComment(
comment += `<details>\n<summary><strong>📦 Bundle Sizes</strong></summary>\n\n${bundleSection}</details>\n\n`
}
// Add native binary size section (not collapsed, small)
comment += generateNativeBinarySection(
result.mainRepoStats,
result.diffRepoStats,
history
)
// Add diffs (already collapsed)
comment += generateDiffsSection(result)
@@ -9,6 +9,32 @@ const collectDiffs = require('./collect-diffs')
const { statsAppDir, diffRepoDir } = require('../constants')
const { calcStats } = require('../util/stats')
// Location of the native binary that the workflow copies into the action dir.
// From src/run/index.js → .github/actions/next-stats-action/native
const nativeBinaryDir = path.join(__dirname, '../../native')
// Sum the size of all *.node files in the action's native/ directory.
async function getSwcBinarySize() {
try {
const entries = await fs.readdir(nativeBinaryDir)
let total = 0
let found = 0
for (const entry of entries) {
if (!entry.endsWith('.node')) continue
const stat = await fs.stat(path.join(nativeBinaryDir, entry))
if (stat.isFile()) {
total += stat.size
found++
}
}
if (found === 0) return null
return total
} catch (err) {
logger(`Unable to measure SWC binary size: ${err.message}`)
return null
}
}
// Number of iterations for build benchmarks to get stable median
const BUILD_BENCHMARK_ITERATIONS = 5
@@ -57,6 +83,7 @@ async function runConfigs(
let curStats = {
General: {
nodeModulesSize: null,
swcBinarySize: null,
},
}
@@ -83,6 +110,7 @@ async function runConfigs(
curStats.General.nodeModulesSize = await getDirSize(
path.join(statsAppDir, 'node_modules')
)
curStats.General.swcBinarySize = await getSwcBinarySize()
}
// Run builds for selected bundler(s) and collect stats separately