From f7d78293450da1482a845d2c2c8e47e5481c79a8 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 21 Aug 2026 17:19:14 -0700 Subject: [PATCH 1/2] [ci] Track the /flow route bundle size against main Builds the nextjs-turbopack and hono workbench apps on every PR, measures the /.well-known/workflow/v1/flow route, and posts a sticky comment with the delta against main. Pushes to main produce the baseline artifacts. Two numbers per app, because neither app emits an isolable function bundle for that route: Next.js emits a ~1 KB turbopack chunk loader pointing at chunks shared with other routes, and nitro inlines the handler into a single server entry. The gated number is what the workflow builders emit before the framework bundles it; the framework's own output is reported but never gates, since unrelated changes move it. Verified before wiring the gate: two clean builds of each app produce byte-identical reports, so a delta means a real change. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/red-masks-rule.md | 4 + .github/scripts/measure-flow-bundle.mjs | 464 ++++++++++++++++++ .../scripts/render-bundle-size-comment.mjs | 407 +++++++++++++++ .../render-bundle-size-comment.test.js | 277 +++++++++++ .github/workflows/bundle-size.yml | 261 ++++++++++ AGENTS.md | 15 + 6 files changed, 1428 insertions(+) create mode 100644 .changeset/red-masks-rule.md create mode 100644 .github/scripts/measure-flow-bundle.mjs create mode 100644 .github/scripts/render-bundle-size-comment.mjs create mode 100644 .github/scripts/render-bundle-size-comment.test.js create mode 100644 .github/workflows/bundle-size.yml diff --git a/.changeset/red-masks-rule.md b/.changeset/red-masks-rule.md new file mode 100644 index 0000000000..66f34d14a6 --- /dev/null +++ b/.changeset/red-masks-rule.md @@ -0,0 +1,4 @@ +--- +--- + +Track the `/flow` route bundle size for the Next.js and Hono workbench apps in CI, failing a PR when it grows past a threshold against `main`. diff --git a/.github/scripts/measure-flow-bundle.mjs b/.github/scripts/measure-flow-bundle.mjs new file mode 100644 index 0000000000..b7609f09db --- /dev/null +++ b/.github/scripts/measure-flow-bundle.mjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node +/** + * Measures the size of a workbench app's `/.well-known/workflow/v1/flow` route + * after that app has been built, and writes a JSON report consumed by + * render-bundle-size-comment.mjs. + * + * Two tiers are reported, because neither supported app emits an isolable + * function bundle for the flow route: + * + * Tier 1 (gated) - the bundle the workflow builders emit before the + * framework bundles it. One file per metric, deterministic, and it moves + * only when the SDK moves. This is what the CI gate compares. + * + * Tier 2 (informational) - the framework's own deployable output. For + * Next.js the built route file is a ~1 KB turbopack chunk loader, so the + * real number is the sum of the chunks it pulls in; those chunks are + * shared with other routes, so the figure over-counts what the flow route + * exclusively owns. For nitro the flow handler is inlined into the single + * server entry, so there is no per-route file at all and the whole server + * output is reported. + * + * The two tiers are NOT comparable to each other, and neither alone is the + * deployed flow function. Tier 1 is the VM bundle the route carries as an + * inline string; the code that hosts it, including the world adapter, lives in + * Tier 2. Each tier is only ever compared against its own baseline. + * + * What Tier 1 therefore does NOT cover: the world adapters. Measured on + * nextjs-turbopack, building with WORKFLOW_TARGET_WORLD=local and =vercel + * produces byte-identical reports across all three metrics, because every + * world the app depends on is bundled into the framework output regardless and + * the choice is made at runtime. A change confined to @workflow/world-vercel + * will not move the gated numbers. + * + * Every failure path here exits non-zero with the path it looked at. A + * plausible-looking wrong number (the 1 KB chunk loader, a months-old stale + * bundle) is worse than a red job, because it reports green while measuring + * nothing. + * + * Usage: + * node .github/scripts/measure-flow-bundle.mjs --app hono --out sizes.json + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; + +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); + +/** Bump when the shape changes incompatibly; the renderer refuses older reports. */ +const SCHEMA_VERSION = 1; + +/** + * Build-time env that changes the measured bytes. Recorded into every report + * so the renderer can refuse to diff two reports that were not produced the + * same way. Without this, editing the workflow file silently invalidates every + * historical comparison and nothing tells you. + * + * WORKFLOW_SOURCEMAP is the one that dominates the numbers. It defaults to + * inline outside a production build, and an inline-sourcemap build of + * nextjs-turbopack measures 5.85 MB against 1.38 MB with sourcemaps off. + * + * WORKFLOW_TARGET_WORLD is pinned and fingerprinted despite having no measured + * effect today: local and vercel builds of nextjs-turbopack come out + * byte-identical. packages/next/src/index.ts does branch on it, so it is the + * kind of input that can start mattering; recording it means a build that + * changes worlds refuses to diff against an old baseline instead of silently + * reporting the difference as a code change. + */ +const FINGERPRINT_ENV = [ + 'WORKFLOW_TARGET_WORLD', + 'WORKFLOW_SOURCEMAP', + 'WORKFLOW_PUBLIC_MANIFEST', +]; + +/** + * Generated sidecars that sit next to the real bundles and must never be + * counted. All of these exist in a working tree today, including leftover + * `steps.tmp..mjs.debug.json` files from interrupted builds. + */ +const EXCLUDED_SUFFIXES = ['.debug.json', '.map']; +const EXCLUDED_PATTERNS = [/\.__wf_tmp\./, /\.tmp\.[0-9a-f-]{8,}\./]; + +/** + * A build stamp whose mtime marks when the framework build ran. A Tier-1 + * bundle far older than this stamp means the build did not regenerate it and + * we are about to measure a stale artifact. + */ +const BUILD_STAMPS = { + 'nextjs-turbopack': '.next/BUILD_ID', + hono: '.output/nitro.json', +}; + +/** How far a Tier-1 bundle may predate the build stamp before we call it stale. */ +const STALENESS_SLACK_MS = 60 * 60 * 1000; + +class MeasureError extends Error {} + +function fail(message) { + throw new MeasureError(message); +} + +function parseArgs(argv) { + const args = { app: null, out: null, commit: null }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (flag === '--app') args.app = argv[++i]; + else if (flag === '--out') args.out = argv[++i]; + else if (flag === '--commit') args.commit = argv[++i]; + else fail(`Unknown argument: ${flag}`); + } + if (!args.app) fail('Missing required --app '); + if (!args.out) fail('Missing required --out '); + return args; +} + +function isExcluded(filePath) { + const base = path.basename(filePath); + if (EXCLUDED_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + return EXCLUDED_PATTERNS.some((pattern) => pattern.test(base)); +} + +/** + * Sizes one file. gzip uses level 9 so the number is reproducible for a given + * zlib build; the Node major version is part of the fingerprint because zlib + * ships with Node and its output can shift across releases. + */ +function measureFile(absPath, relPath) { + let buf; + try { + buf = fs.readFileSync(absPath); + } catch (error) { + fail(`Could not read ${relPath} (${absPath}): ${error.message}`); + } + return { + path: relPath, + raw: buf.byteLength, + gzip: zlib.gzipSync(buf, { level: 9 }).byteLength, + }; +} + +/** + * Totals a set of measured files. Per-file gzip is summed rather than gzipping + * the concatenation: it keeps each file's contribution meaningful and does not + * change with file order. + */ +function total(files, extra = {}) { + return { + raw: files.reduce((sum, f) => sum + f.raw, 0), + gzip: files.reduce((sum, f) => sum + f.gzip, 0), + files, + ...extra, + }; +} + +function measureFiles(appDir, relPaths) { + const files = []; + for (const rel of relPaths) { + if (isExcluded(rel)) continue; + const abs = path.join(appDir, rel); + files.push(measureFile(abs, path.relative(REPO_ROOT, abs))); + } + if (files.length === 0) fail(`No files to measure under ${appDir}`); + return total(files); +} + +function walkFiles(dir) { + const out = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch (error) { + fail(`Could not read directory ${current}: ${error.message}`); + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile() && !isExcluded(full)) out.push(full); + } + } + return out.sort(); +} + +/** + * Reads the per-app generated bundle paths out of scripts/create-test-matrix.mjs + * rather than restating them here. That script is the repo's single source of + * truth for where each framework adapter writes its flow bundle (it feeds the + * E2E dev-test matrix). It exports nothing and prints `{"app":[...]}` to + * stdout, so we spawn it. Entries are duplicated across the canary and VM axes + * with identical paths, so the first name match is fine. + */ +function loadMatrixEntry(app) { + let stdout; + try { + stdout = execFileSync('node', ['scripts/create-test-matrix.mjs'], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + } catch (error) { + fail(`Could not run scripts/create-test-matrix.mjs: ${error.message}`); + } + + let matrix; + try { + matrix = JSON.parse(stdout); + } catch (error) { + fail(`scripts/create-test-matrix.mjs did not emit JSON: ${error.message}`); + } + + const entry = matrix.app?.find((candidate) => candidate.name === app); + if (!entry) { + const known = [...new Set((matrix.app ?? []).map((a) => a.name))].join( + ', ' + ); + fail(`App "${app}" is not in the test matrix. Known apps: ${known}`); + } + if (!entry.generatedWorkflowPath || !entry.generatedStepRegistrationPath) { + fail( + `Test matrix entry for "${app}" is missing generatedWorkflowPath or ` + + 'generatedStepRegistrationPath; this script cannot locate the flow bundle.' + ); + } + return entry; +} + +/** + * Guards against measuring a bundle the build did not regenerate. A working + * tree can hold a flow bundle from an older nitro layout months out of date + * next to a fresh `.output`, and it is the same size order of magnitude, so + * nothing about the number itself looks wrong. + */ +function assertFresh(app, appDir, tier1RelPaths) { + const stampRel = BUILD_STAMPS[app]; + if (!stampRel) return null; + + const stampAbs = path.join(appDir, stampRel); + if (!fs.existsSync(stampAbs)) { + fail( + `Build stamp ${stampRel} is missing under ${path.relative(REPO_ROOT, appDir)}. ` + + 'The app does not look built; run its build before measuring.' + ); + } + const stampMtime = fs.statSync(stampAbs).mtimeMs; + + for (const rel of tier1RelPaths) { + const abs = path.join(appDir, rel); + if (!fs.existsSync(abs)) { + fail( + `Flow bundle ${rel} is missing under ${path.relative(REPO_ROOT, appDir)}. ` + + 'Either the build failed or the prebuild step that generates the ' + + 'workflow registry did not run.' + ); + } + const age = stampMtime - fs.statSync(abs).mtimeMs; + if (age > STALENESS_SLACK_MS) { + fail( + `Flow bundle ${rel} is ${Math.round(age / 60000)} minutes older than the ` + + `build stamp ${stampRel}. This is a stale artifact from an earlier ` + + 'build, not the one just produced. Clean the app and rebuild.' + ); + } + } + return stampRel; +} + +/** + * Next.js/turbopack Tier 2. The built route file is a chunk loader: + * + * var R=require("../../../../../chunks/[turbopack]_runtime.js")("server/app/.../route.js") + * R.c("server/chunks/[root-of-the-server]__00shnjs._.js") + * ... one R.c per chunk ... + * R.m(628681) + * + * `R.c` paths resolve relative to `.next/`; the runtime chunk in the leading + * `require` resolves relative to the route file. Chunk filenames are + * content-hashed, so only totals are stable - never key a baseline on a chunk + * name. The syntax is a Next internal and will break on some future upgrade, + * which is why this metric is informational and why an unparseable stub is a + * hard error rather than a 1 KB answer. + */ +function nextTurbopackTier2(appDir) { + const nextDir = path.join(appDir, '.next'); + const stubRel = '.next/server/app/.well-known/workflow/v1/flow/route.js'; + const stubAbs = path.join(appDir, stubRel); + + if (!fs.existsSync(stubAbs)) { + fail( + `Built flow route ${stubRel} is missing. Expected next build to emit it.` + ); + } + const stub = fs.readFileSync(stubAbs, 'utf8'); + + const chunkRels = [...stub.matchAll(/R\.c\("([^"]+)"\)/g)].map((m) => m[1]); + if (chunkRels.length === 0) { + fail( + `Could not find any R.c("...") chunk references in ${stubRel}. The ` + + 'turbopack chunk-loader format has probably changed; this metric needs ' + + 'updating rather than trusting the stub size on its own.' + ); + } + + const absPaths = new Set([stubAbs]); + for (const rel of chunkRels) { + const abs = path.join(nextDir, rel); + if (!fs.existsSync(abs)) { + fail( + `Chunk "${rel}" referenced by ${stubRel} does not resolve under .next/. ` + + 'The chunk path convention has changed.' + ); + } + absPaths.add(abs); + } + + // The turbopack runtime is pulled in by the leading require(), not an R.c(). + const runtimeMatch = stub.match(/require\("([^"]*_runtime\.js)"\)/); + if (runtimeMatch) { + const runtimeAbs = path.resolve(path.dirname(stubAbs), runtimeMatch[1]); + if (fs.existsSync(runtimeAbs)) absPaths.add(runtimeAbs); + } + + const files = [...absPaths] + .sort() + .filter((abs) => !isExcluded(abs)) + .map((abs) => measureFile(abs, path.relative(REPO_ROOT, abs))); + + return total(files, { + note: `${chunkRels.length} chunks, shared with other routes`, + }); +} + +/** + * Nitro Tier 2. The flow handler is inlined into the single server entry, so + * there is nothing route-specific to isolate: report the whole server output. + */ +function nitroOutputTier2(appDir) { + const serverRel = '.output/server'; + const serverAbs = path.join(appDir, serverRel); + if (!fs.existsSync(serverAbs)) { + fail(`Built server output ${serverRel} is missing under ${appDir}.`); + } + + const files = walkFiles(serverAbs).map((abs) => + measureFile(abs, path.relative(REPO_ROOT, abs)) + ); + if (files.length === 0) fail(`${serverRel} contains no files.`); + + return total(files, { + // The per-file list for a whole server tree is long and not useful in a PR + // comment; keep the total only. + fileCount: files.length, + files: [], + note: `${files.length} files, flow handler inlined into the server entry`, + }); +} + +const TIER2_COLLECTORS = { + 'nextjs-turbopack': nextTurbopackTier2, + hono: nitroOutputTier2, +}; + +function measureApp(app) { + const appDir = path.join(REPO_ROOT, 'workbench', app); + if (!fs.existsSync(appDir)) fail(`No workbench app at workbench/${app}`); + + const collectTier2 = TIER2_COLLECTORS[app]; + if (!collectTier2) { + fail( + `App "${app}" has no Tier 2 collector. Supported: ` + + `${Object.keys(TIER2_COLLECTORS).join(', ')}.` + ); + } + + // The prebuild hook generates this registry; without it the builders + // discover no workflows and emit a trivially small bundle that still looks + // like a valid measurement. + if (!fs.existsSync(path.join(appDir, '_workflows.ts'))) { + fail( + `workbench/${app}/_workflows.ts is missing. The prebuild step ` + + '(generate:workflows) did not run, so the flow bundle would be empty.' + ); + } + + const entry = loadMatrixEntry(app); + const flowRel = entry.generatedWorkflowPath; + const stepsRel = entry.generatedStepRegistrationPath; + + assertFresh(app, appDir, [flowRel, stepsRel]); + + const metrics = [ + { + id: 'flow-bundle', + label: 'Flow route bundle', + tier: 1, + gated: true, + ...measureFiles(appDir, [flowRel]), + }, + { + id: 'step-registrations', + label: 'Step registrations', + tier: 1, + gated: true, + ...measureFiles(appDir, [stepsRel]), + }, + { + id: 'framework-output', + label: 'Framework output', + tier: 2, + gated: false, + ...collectTier2(appDir), + }, + ]; + + return metrics; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const metrics = measureApp(args.app); + + const report = { + schemaVersion: SCHEMA_VERSION, + app: args.app, + commit: args.commit ?? null, + fingerprint: { + nodeMajor: process.versions.node.split('.')[0], + ...Object.fromEntries( + FINGERPRINT_ENV.map((key) => [key, process.env[key] ?? null]) + ), + }, + metrics, + }; + + fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true }); + fs.writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`); + + for (const metric of metrics) { + const kb = (metric.raw / 1024).toFixed(1); + const gzipKb = (metric.gzip / 1024).toFixed(1); + console.log( + `${args.app} ${metric.id}: ${kb} KiB raw, ${gzipKb} KiB gzip` + + `${metric.note ? ` (${metric.note})` : ''}` + ); + } + console.log(`Wrote ${args.out}`); +} + +try { + main(); +} catch (error) { + if (error instanceof MeasureError) { + console.error(`measure-flow-bundle: ${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/.github/scripts/render-bundle-size-comment.mjs b/.github/scripts/render-bundle-size-comment.mjs new file mode 100644 index 0000000000..4116189cef --- /dev/null +++ b/.github/scripts/render-bundle-size-comment.mjs @@ -0,0 +1,407 @@ +#!/usr/bin/env node +/** + * Renders the sticky PR comment for the Bundle Size workflow and emits a + * machine-readable gate verdict. + * + * Reads the JSON reports produced by measure-flow-bundle.mjs for the PR head, + * plus the same reports downloaded from the most recent successful run on + * main, and diffs them. Tier-1 metrics are gated: the verdict marks a + * regression when a gated metric's raw size grew by more than + * max(--threshold-pct, --threshold-bytes) against the baseline. + * + * Reports whose build fingerprints disagree are never diffed. The fingerprint + * records the env that changes the measured bytes (target world, sourcemap + * mode, public manifest, Node major); comparing across a change to any of + * those produces a large, entirely meaningless delta. + * + * Usage: + * node .github/scripts/render-bundle-size-comment.mjs \ + * --results-dir size-results --baseline-dir baseline-results \ + * --commit "$SHA" --run-url "$URL" \ + * --output comment.md --gate-output gate.json + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export const COMMENT_MARKER = ''; + +/** Matches the workflow defaults; both are overridable on the command line. */ +export const DEFAULT_THRESHOLD_PCT = 2; +export const DEFAULT_THRESHOLD_BYTES = 50 * 1024; + +const SUPPORTED_SCHEMA_VERSION = 1; + +const STRING_FLAGS = { + '--results-dir': 'resultsDir', + '--baseline-dir': 'baselineDir', + '--commit': 'commit', + '--run-url': 'runUrl', + '--output': 'output', + '--gate-output': 'gateOutput', + '--status': 'status', +}; + +const NUMBER_FLAGS = { + '--threshold-pct': 'thresholdPct', + '--threshold-bytes': 'thresholdBytes', +}; + +export function parseArgs(argv) { + const args = { + resultsDir: null, + baselineDir: null, + commit: null, + runUrl: null, + output: null, + gateOutput: null, + status: 'completed', + thresholdPct: DEFAULT_THRESHOLD_PCT, + thresholdBytes: DEFAULT_THRESHOLD_BYTES, + }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (STRING_FLAGS[flag]) args[STRING_FLAGS[flag]] = argv[++i]; + else if (NUMBER_FLAGS[flag]) args[NUMBER_FLAGS[flag]] = Number(argv[++i]); + else throw new Error(`Unknown argument: ${flag}`); + } + if (!args.resultsDir) throw new Error('Missing required --results-dir'); + if (!args.output) throw new Error('Missing required --output'); + if (!Number.isFinite(args.thresholdPct) || args.thresholdPct < 0) { + throw new Error('--threshold-pct must be a non-negative number'); + } + if (!Number.isFinite(args.thresholdBytes) || args.thresholdBytes < 0) { + throw new Error('--threshold-bytes must be a non-negative number'); + } + return args; +} + +function findJsonFiles(dir) { + const out = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile() && entry.name.endsWith('.json')) out.push(full); + } + } + return out.sort(); +} + +/** + * Loads every *.json report under a directory, keyed by app. + * + * Recursive on purpose: `actions/download-artifact` with `merge-multiple` + * flattens reports into one directory, but `gh run download` puts each + * artifact in its own subdirectory, and the baseline arrives via the latter. + * + * A directory that does not exist yields an empty map: no baseline is a valid + * state (first run, expired artifact, fork PR). A malformed or future-schema + * report is skipped rather than crashing the comment, so the rest of the + * numbers still get posted. + */ +export function loadReports(dir) { + const reports = new Map(); + const skipped = []; + if (!dir || !fs.existsSync(dir)) return { reports, skipped }; + + for (const full of findJsonFiles(dir)) { + const name = path.relative(dir, full); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(full, 'utf8')); + } catch (error) { + skipped.push(`${name} (unparseable: ${error.message})`); + continue; + } + if (parsed?.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { + skipped.push(`${name} (schemaVersion ${parsed?.schemaVersion})`); + continue; + } + if (typeof parsed.app !== 'string' || !Array.isArray(parsed.metrics)) { + skipped.push(`${name} (missing app or metrics)`); + continue; + } + reports.set(parsed.app, parsed); + } + return { reports, skipped }; +} + +export function fingerprintsMatch(a, b) { + if (!a || !b) return false; + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])]; + return keys.every((key) => (a[key] ?? null) === (b[key] ?? null)); +} + +export function formatBytes(bytes) { + const abs = Math.abs(bytes); + if (abs < 1024) return `${bytes} B`; + if (abs < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} + +function formatSignedBytes(bytes) { + return bytes >= 0 ? `+${formatBytes(bytes)}` : `-${formatBytes(-bytes)}`; +} + +export function formatDelta(delta, baseline) { + if (delta === null || delta === undefined) return 'โ€”'; + if (delta === 0) return 'no change'; + const pct = baseline > 0 ? ((delta / baseline) * 100).toFixed(2) : null; + const suffix = pct === null ? '' : ` (${delta > 0 ? '+' : ''}${pct}%)`; + return `${formatSignedBytes(delta)}${suffix}`; +} + +/** + * Diffs one app's report against its baseline and decides which gated metrics + * regressed. A metric absent from the baseline (newly added) is reported with + * no delta and never gates, since there is nothing to compare against. + */ +export function compareApp(current, baseline, thresholds) { + const mismatch = + baseline && !fingerprintsMatch(current.fingerprint, baseline.fingerprint); + const baselineMetrics = new Map( + (baseline?.metrics ?? []).map((metric) => [metric.id, metric]) + ); + const comparable = Boolean(baseline) && !mismatch; + + const rows = current.metrics.map((metric) => { + const base = comparable ? baselineMetrics.get(metric.id) : undefined; + const row = { + id: metric.id, + label: metric.label ?? metric.id, + tier: metric.tier, + gated: Boolean(metric.gated), + note: metric.note ?? null, + raw: metric.raw, + gzip: metric.gzip, + baselineRaw: base?.raw ?? null, + baselineGzip: base?.gzip ?? null, + rawDelta: null, + gzipDelta: null, + threshold: null, + regression: false, + }; + if (base) { + row.rawDelta = metric.raw - base.raw; + row.gzipDelta = metric.gzip - base.gzip; + row.threshold = Math.max( + (base.raw * thresholds.pct) / 100, + thresholds.bytes + ); + row.regression = row.gated && row.rawDelta > row.threshold; + } + return row; + }); + + return { + app: current.app, + hasBaseline: Boolean(baseline), + fingerprintMismatch: mismatch + ? { current: current.fingerprint, baseline: baseline.fingerprint } + : null, + rows, + }; +} + +export function compareAll(currentReports, baselineReports, thresholds) { + const apps = [...currentReports.keys()] + .sort() + .map((app) => + compareApp(currentReports.get(app), baselineReports.get(app), thresholds) + ); + const regressions = apps.flatMap((entry) => + entry.rows + .filter((row) => row.regression) + .map((row) => ({ + app: entry.app, + id: row.id, + label: row.label, + rawDelta: row.rawDelta, + threshold: row.threshold, + baselineRaw: row.baselineRaw, + raw: row.raw, + })) + ); + return { apps, regressions }; +} + +function renderAppSection(entry) { + const lines = [`### ${entry.app}`, '']; + + if (entry.fingerprintMismatch) { + const diff = Object.keys(entry.fingerprintMismatch.current) + .filter( + (key) => + (entry.fingerprintMismatch.current[key] ?? null) !== + (entry.fingerprintMismatch.baseline[key] ?? null) + ) + .map( + (key) => + `\`${key}\`: main \`${entry.fingerprintMismatch.baseline[key]}\` โ†’ ` + + `PR \`${entry.fingerprintMismatch.current[key]}\`` + ); + lines.push( + '> Build fingerprint differs from the baseline, so sizes are shown ' + + 'without a comparison. Any delta across this change would be ' + + 'meaningless.', + '>', + ...diff.map((line) => `> ${line}`), + '' + ); + } else if (!entry.hasBaseline) { + lines.push( + '> No baseline on `main` yet, so sizes are shown without a comparison.', + '' + ); + } + + lines.push( + '| Metric | Raw | Raw vs main | Gzip | Gzip vs main |', + '| --- | ---: | ---: | ---: | ---: |' + ); + for (const row of entry.rows) { + const label = `${row.label}${row.gated ? ' ๐Ÿ”’' : ''}${ + row.regression ? ' โš ๏ธ' : '' + }`; + lines.push( + `| ${label} | ${formatBytes(row.raw)} | ` + + `${formatDelta(row.rawDelta, row.baselineRaw)} | ` + + `${formatBytes(row.gzip)} | ` + + `${formatDelta(row.gzipDelta, row.baselineGzip)} |` + ); + } + lines.push(''); + + const notes = entry.rows.filter((row) => row.note); + if (notes.length > 0) { + for (const row of notes) lines.push(`- ${row.label}: ${row.note}`); + lines.push(''); + } + + return lines; +} + +export function renderComment({ + comparison, + commit, + runUrl, + status = 'completed', + thresholds, + skipped = [], +}) { + const lines = [COMMENT_MARKER, '## Flow route bundle size', '']; + + if (status === 'failed') { + lines.push( + '๐Ÿ”ด One or more measurement jobs failed; the numbers below may be ' + + 'incomplete. See the [run log](' + + `${runUrl}).`, + '' + ); + } + + if (comparison.apps.length === 0) { + lines.push( + 'No measurements were produced. See the ' + + `[run log](${runUrl}) for what went wrong.`, + '' + ); + return `${lines.join('\n')}\n`; + } + + if (comparison.regressions.length > 0) { + lines.push( + `๐Ÿ”ด **${comparison.regressions.length} gated bundle(s) grew past the ` + + 'threshold.** Add the `allow-bundle-size-growth` label to accept the ' + + 'growth and pass the check.', + '' + ); + } + + for (const entry of comparison.apps) { + lines.push(...renderAppSection(entry)); + } + + lines.push( + '
How to read this', + '', + '- ๐Ÿ”’ marks a **gated** metric: the job fails when its raw size grows by ' + + `more than ${thresholds.pct}% or ` + + `${formatBytes(thresholds.bytes)}, whichever is larger.`, + '- **Flow route bundle** and **Step registrations** are what the workflow ' + + 'builders emit for `/.well-known/workflow/v1/flow` before the framework ' + + 'bundles them. They move only when the SDK moves, which is why they are ' + + 'the gated numbers.', + "- **Framework output** is the framework's own build output. It is " + + 'informational: for Next.js the chunks are shared with other routes, and ' + + 'for nitro the flow handler is inlined into a single server entry, so ' + + 'unrelated changes move it.', + '- The two are **not comparable to each other**, and neither alone is the ' + + 'deployed function. Each is only ever compared against its own baseline ' + + 'from `main`.', + '- The gated numbers **exclude the world adapters**: every world the app ' + + 'depends on is bundled into the framework output regardless, and the ' + + 'choice is made at runtime. A change confined to a world package will ' + + 'not move them.', + '- Raw bytes are what the runtime reads and parses on a cold start; gzip is ' + + 'the deployment payload. Only raw is gated.', + '', + '
', + '' + ); + + if (skipped.length > 0) { + lines.push(`Skipped unreadable reports: ${skipped.join(', ')}.`, ''); + } + + const shortCommit = commit ? commit.slice(0, 7) : 'unknown'; + lines.push(`Measured at \`${shortCommit}\` ยท [run](${runUrl})`); + + return `${lines.join('\n')}\n`; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const thresholds = { pct: args.thresholdPct, bytes: args.thresholdBytes }; + + const current = loadReports(args.resultsDir); + const baseline = loadReports(args.baselineDir); + const comparison = compareAll(current.reports, baseline.reports, thresholds); + + const markdown = renderComment({ + comparison, + commit: args.commit, + runUrl: args.runUrl, + status: args.status, + thresholds, + skipped: current.skipped, + }); + + fs.writeFileSync(args.output, markdown); + process.stdout.write(markdown); + + if (args.gateOutput) { + fs.writeFileSync( + args.gateOutput, + `${JSON.stringify( + { + regressions: comparison.regressions, + measuredApps: comparison.apps.map((entry) => entry.app), + thresholds, + }, + null, + 2 + )}\n` + ); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === new URL(import.meta.url).pathname +) { + main(); +} diff --git a/.github/scripts/render-bundle-size-comment.test.js b/.github/scripts/render-bundle-size-comment.test.js new file mode 100644 index 0000000000..b96b6ac424 --- /dev/null +++ b/.github/scripts/render-bundle-size-comment.test.js @@ -0,0 +1,277 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +// The script is ESM; load it lazily from the CJS test file. +const loadModule = () => import('./render-bundle-size-comment.mjs'); + +const THRESHOLDS = { pct: 2, bytes: 50 * 1024 }; + +function fingerprint(overrides = {}) { + return { + nodeMajor: '22', + WORKFLOW_TARGET_WORLD: 'vercel', + WORKFLOW_SOURCEMAP: 'false', + WORKFLOW_PUBLIC_MANIFEST: '1', + ...overrides, + }; +} + +function report({ app = 'hono', metrics, fingerprint: fp } = {}) { + return { + schemaVersion: 1, + app, + commit: 'abc1234', + fingerprint: fp ?? fingerprint(), + metrics: metrics ?? [ + { + id: 'flow-bundle', + label: 'Flow route bundle', + tier: 1, + gated: true, + raw: 5_000_000, + gzip: 1_000_000, + }, + { + id: 'framework-output', + label: 'Framework output', + tier: 2, + gated: false, + raw: 3_000_000, + gzip: 700_000, + }, + ], + }; +} + +function withMetricRaw(base, id, raw) { + return { + ...base, + metrics: base.metrics.map((metric) => + metric.id === id ? { ...metric, raw } : metric + ), + }; +} + +function mapOf(...reports) { + return new Map(reports.map((r) => [r.app, r])); +} + +function tmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-size-test-')); +} + +test('no baseline renders sizes and gates nothing', async () => { + const { compareAll, renderComment } = await loadModule(); + const comparison = compareAll(mapOf(report()), new Map(), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + assert.strictEqual(comparison.apps[0].hasBaseline, false); + assert.strictEqual(comparison.apps[0].rows[0].rawDelta, null); + + const md = renderComment({ + comparison, + commit: 'abc1234', + runUrl: 'https://example.test/run', + thresholds: THRESHOLDS, + }); + assert.match(md, /No baseline on `main` yet/); +}); + +test('growth just over the percentage threshold is a regression', async () => { + const { compareAll } = await loadModule(); + // 2% of 5,000,000 is 100,000, which exceeds the 51,200-byte floor. + const current = withMetricRaw(report(), 'flow-bundle', 5_100_001); + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + assert.strictEqual(comparison.regressions.length, 1); + assert.strictEqual(comparison.regressions[0].id, 'flow-bundle'); + assert.strictEqual(comparison.regressions[0].rawDelta, 100_001); +}); + +test('growth exactly at the threshold is not a regression', async () => { + const { compareAll } = await loadModule(); + const current = withMetricRaw(report(), 'flow-bundle', 5_100_000); + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + assert.strictEqual(comparison.apps[0].rows[0].rawDelta, 100_000); +}); + +test('the absolute floor protects small bundles from percentage noise', async () => { + const { compareAll } = await loadModule(); + const small = report({ + metrics: [ + { + id: 'step-registrations', + label: 'Step registrations', + tier: 1, + gated: true, + raw: 1_000, + gzip: 400, + }, + ], + }); + // +20,000 bytes is 2000% growth, but still under the 51,200-byte floor. + const current = withMetricRaw(small, 'step-registrations', 21_000); + const comparison = compareAll(mapOf(current), mapOf(small), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + assert.strictEqual(comparison.apps[0].rows[0].threshold, 51_200); +}); + +test('a non-gated metric never regresses however much it grows', async () => { + const { compareAll } = await loadModule(); + const current = withMetricRaw(report(), 'framework-output', 30_000_000); + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + const row = comparison.apps[0].rows.find((r) => r.id === 'framework-output'); + assert.strictEqual(row.rawDelta, 27_000_000); + assert.strictEqual(row.regression, false); +}); + +test('a fingerprint mismatch suppresses the diff and the gate', async () => { + const { compareAll, renderComment } = await loadModule(); + const current = { + ...withMetricRaw(report(), 'flow-bundle', 50_000_000), + fingerprint: fingerprint({ WORKFLOW_TARGET_WORLD: 'local' }), + }; + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + assert.ok(comparison.apps[0].fingerprintMismatch); + assert.strictEqual(comparison.apps[0].rows[0].rawDelta, null); + + const md = renderComment({ + comparison, + commit: 'abc1234', + runUrl: 'https://example.test/run', + thresholds: THRESHOLDS, + }); + assert.match(md, /Build fingerprint differs/); + assert.match(md, /WORKFLOW_TARGET_WORLD/); +}); + +test('a metric missing from the baseline reports no delta and does not gate', async () => { + const { compareAll } = await loadModule(); + const baseline = report({ + metrics: report().metrics.filter((m) => m.id !== 'flow-bundle'), + }); + const comparison = compareAll(mapOf(report()), mapOf(baseline), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + const row = comparison.apps[0].rows.find((r) => r.id === 'flow-bundle'); + assert.strictEqual(row.rawDelta, null); +}); + +test('an improvement renders as a negative delta', async () => { + const { compareAll, renderComment } = await loadModule(); + const current = withMetricRaw(report(), 'flow-bundle', 4_000_000); + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + assert.deepStrictEqual(comparison.regressions, []); + const md = renderComment({ + comparison, + commit: 'abc1234', + runUrl: 'https://example.test/run', + thresholds: THRESHOLDS, + }); + assert.match(md, /-976\.6 KiB \(-20\.00%\)/); +}); + +test('loadReports skips unparseable and future-schema files', async () => { + const { loadReports } = await loadModule(); + const dir = tmpdir(); + fs.writeFileSync(path.join(dir, 'good.json'), JSON.stringify(report())); + fs.writeFileSync(path.join(dir, 'broken.json'), '{not json'); + fs.writeFileSync( + path.join(dir, 'future.json'), + JSON.stringify({ ...report({ app: 'other' }), schemaVersion: 99 }) + ); + + const { reports, skipped } = loadReports(dir); + assert.deepStrictEqual([...reports.keys()], ['hono']); + assert.strictEqual(skipped.length, 2); +}); + +test('loadReports finds reports nested one artifact-dir deep', async () => { + // `gh run download` puts each artifact in its own subdirectory, which is how + // the main baseline arrives; `download-artifact --merge-multiple` flattens. + const { loadReports } = await loadModule(); + const dir = tmpdir(); + const sub = path.join(dir, 'size-results-hono'); + fs.mkdirSync(sub); + fs.writeFileSync( + path.join(sub, 'size-results-hono.json'), + JSON.stringify(report()) + ); + + const { reports } = loadReports(dir); + assert.deepStrictEqual([...reports.keys()], ['hono']); +}); + +test('loadReports treats a missing directory as no baseline', async () => { + const { loadReports } = await loadModule(); + const { reports, skipped } = loadReports( + path.join(tmpdir(), 'does-not-exist') + ); + assert.strictEqual(reports.size, 0); + assert.deepStrictEqual(skipped, []); +}); + +test('the comment flags regressions and names the override label', async () => { + const { compareAll, renderComment } = await loadModule(); + const current = withMetricRaw(report(), 'flow-bundle', 6_000_000); + const comparison = compareAll(mapOf(current), mapOf(report()), THRESHOLDS); + + const md = renderComment({ + comparison, + commit: 'abc1234', + runUrl: 'https://example.test/run', + thresholds: THRESHOLDS, + }); + assert.match(md, /1 gated bundle\(s\) grew past the threshold/); + assert.match(md, /allow-bundle-size-growth/); + assert.match(md, /โš ๏ธ/); +}); + +test('renderComment reports a failed measurement run', async () => { + const { compareAll, renderComment } = await loadModule(); + const md = renderComment({ + comparison: compareAll(new Map(), new Map(), THRESHOLDS), + commit: 'abc1234', + runUrl: 'https://example.test/run', + status: 'failed', + thresholds: THRESHOLDS, + }); + assert.match(md, /No measurements were produced/); +}); + +test('parseArgs rejects a negative threshold', async () => { + const { parseArgs } = await loadModule(); + assert.throws( + () => + parseArgs([ + '--results-dir', + 'a', + '--output', + 'b', + '--threshold-pct', + '-1', + ]), + /non-negative/ + ); +}); + +test('formatBytes switches units and keeps the sign in deltas', async () => { + const { formatBytes, formatDelta } = await loadModule(); + assert.strictEqual(formatBytes(512), '512 B'); + assert.strictEqual(formatBytes(2048), '2.0 KiB'); + assert.strictEqual(formatBytes(5 * 1024 * 1024), '5.00 MiB'); + assert.strictEqual(formatDelta(0, 100), 'no change'); + assert.strictEqual(formatDelta(null, 100), 'โ€”'); + assert.strictEqual(formatDelta(1024, 102400), '+1.0 KiB (+1.00%)'); +}); diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..54b0ba40c5 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,261 @@ +name: Bundle Size + +# Tracks the size of the /.well-known/workflow/v1/flow route for two workbench +# apps and compares it against main. The flow route carries the whole workflow +# runtime, so it is the cold-start payload for every invocation; without this +# job a lost tree-shake or a new transitive dependency only shows up as TTFS +# drift in the benchmark workflow weeks later. +# +# Two numbers per app (see .github/scripts/measure-flow-bundle.mjs for why +# neither app emits an isolable function bundle): +# - Tier 1, GATED: what the workflow builders emit for the flow route. +# - Tier 2, informational: the framework's own build output. +# +# PR runs download the most recent main baseline and fail when a gated bundle +# grew more than the threshold below. Pushes to main exist to produce those +# baseline artifacts. Deliberately in its own workflow file rather than a job +# in tests.yml: "E2E Required Check" asserts on each of its dependencies +# explicitly, so a job here cannot accidentally gate the E2E aggregate. + +on: + pull_request: + branches: [main] + push: + branches: [main] # produces the baseline artifacts PR runs compare against + workflow_dispatch: # seeds a baseline on main without waiting for a push + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + actions: read # gh run list / gh run download for the baseline + pull-requests: write + issues: write + +env: + # Growth beyond max(THRESHOLD_PCT, THRESHOLD_BYTES) on a gated bundle fails + # the job. The absolute floor stops small bundles (step registrations are + # ~1.7 KiB on Next.js) from tripping on percentage noise. + THRESHOLD_PCT: '2' + THRESHOLD_BYTES: '51200' + OVERRIDE_LABEL: allow-bundle-size-growth + +jobs: + ci-scope: + name: Detect CI Scope + runs-on: ubuntu-latest + outputs: + fast-path: ${{ steps.scope.outputs.runtime-fast-path }} + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Classify changed files + id: scope + uses: ./.github/actions/detect-ci-scope + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + measure: + name: Measure (${{ matrix.target.app }}) + runs-on: ubuntu-latest + needs: ci-scope + if: >- + !startsWith(github.head_ref, 'changeset-release/') && + needs.ci-scope.outputs.fast-path != 'true' + timeout-minutes: 25 + + strategy: + fail-fast: false + matrix: + target: + - app: nextjs-turbopack + package: nextjs-turbopack + - app: hono + package: '@workflow/example-hono' + + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + # Everything below is recorded into the report's fingerprint. The + # renderer refuses to diff two reports whose fingerprints disagree, so + # changing any of these invalidates comparisons against older baselines + # rather than silently corrupting them. Keep them identical on the PR and + # push-to-main paths. + # + # Pinned and fingerprinted despite having no measured effect: local and + # vercel builds of nextjs-turbopack come out byte-identical, because + # every world the app depends on is bundled either way and the choice is + # made at runtime. packages/next does branch on it, so recording it means + # a future change that makes it matter shows up as a refused diff rather + # than as a phantom code change. + WORKFLOW_TARGET_WORLD: vercel + # The pin that actually moves the numbers: sourcemap mode defaults to + # inline outside a production build, which took nextjs-turbopack's flow + # bundle from 1.38 MB to 5.85 MB when measured unpinned. + WORKFLOW_SOURCEMAP: 'false' + # Set in both apps' vercel.json, so production builds have it. + WORKFLOW_PUBLIC_MANIFEST: '1' + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup environment and build packages + uses: ./.github/actions/setup-workflow-dev + with: + build-packages: 'true' + + # hono's workflows, plugins, turbo.json, tsconfig.json and index.html are + # all symlinks into ../nitro-v3. Effectively a no-op for turbopack, whose + # only symlink is LICENSE.md. + - name: Resolve workbench symlinks + env: + CI: 'true' + run: ./scripts/resolve-symlinks.sh "workbench/${{ matrix.target.app }}" + + # Deliberately the package script rather than `turbo run build`. hono's + # turbo.json declares outputs `.output/**` and `.nitro/**`, but under + # nitro v3 the gated flow bundle lands in node_modules/.nitro/, which is + # not an output: a turbo cache hit would restore `.output` and leave the + # measured file absent. The packages still come from the turbo cache in + # the setup step above. Matches how packages/core/e2e/local-build.test.ts + # builds workbench apps. + - name: Build ${{ matrix.target.app }} + run: pnpm --filter '${{ matrix.target.package }}' run build + + - name: Measure flow bundle + run: | + node .github/scripts/measure-flow-bundle.mjs \ + --app '${{ matrix.target.app }}' \ + --commit '${{ github.event.pull_request.head.sha || github.sha }}' \ + --out 'size-results-${{ matrix.target.app }}.json' + + # Default retention (90 days) on purpose: this artifact is the baseline + # every later PR compares against, so it must outlive the 1-day retention + # used for the shared E2E package builds. + - name: Upload size results + uses: actions/upload-artifact@v4 + with: + name: size-results-${{ matrix.target.app }} + path: size-results-*.json + if-no-files-found: error + + report: + name: Bundle Size Report + runs-on: ubuntu-latest + needs: [ci-scope, measure] + if: >- + always() && !cancelled() && + !startsWith(github.head_ref, 'changeset-release/') && + needs.ci-scope.outputs.fast-path != 'true' + timeout-minutes: 10 + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Download size results + continue-on-error: true + uses: actions/download-artifact@v4 + with: + pattern: size-results-* + path: size-results + merge-multiple: true + + # Baseline for the delta columns: results from the most recent successful + # run of this workflow on main. Uses gh (first-party) rather than a + # third-party cross-run artifact action. Filtering to push / + # workflow_dispatch events matters for more than tidiness: it excludes + # artifacts uploaded by fork PR runs, whose head branch can also be named + # "main" (artifact poisoning). + - name: Download baseline results from main + if: github.event_name == 'pull_request' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + run_id=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow bundle-size.yml \ + --branch main \ + --status success \ + --limit 20 \ + --json databaseId,event \ + --jq '[.[] | select(.event == "push" or .event == "workflow_dispatch")][0].databaseId // empty') + if [ -z "$run_id" ]; then + echo "No successful bundle-size run found on main; skipping baseline" + exit 0 + fi + echo "Using baseline artifacts from run $run_id" + gh run download "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'size-results-*' \ + --dir baseline-results \ + || echo "Run $run_id has no size artifacts; skipping baseline" + + - name: Render report + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RENDER_STATUS: ${{ needs.measure.result == 'success' && 'completed' || 'failed' }} + run: | + node .github/scripts/render-bundle-size-comment.mjs \ + --results-dir size-results \ + --baseline-dir baseline-results \ + --status "$RENDER_STATUS" \ + --threshold-pct "$THRESHOLD_PCT" \ + --threshold-bytes "$THRESHOLD_BYTES" \ + --commit "$HEAD_SHA" \ + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --output "$RUNNER_TEMP/comment.md" \ + --gate-output "$RUNNER_TEMP/gate.json" > /dev/null + cat "$RUNNER_TEMP/comment.md" >> "$GITHUB_STEP_SUMMARY" + + # Skipped on forks, where `pull_request` grants read-only permissions and + # the write would fail. The step summary above is the fallback there. + - name: Update PR comment + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 + with: + header: bundle-size-results + path: ${{ runner.temp }}/comment.md + + # Last, so the numbers are already posted when this fails. + - name: Enforce size threshold + if: github.event_name == 'pull_request' + env: + MEASURE_RESULT: ${{ needs.measure.result }} + # The label list is compared against $OVERRIDE_LABEL in the script + # below rather than in a `contains()` expression here, so the label + # name is defined exactly once, at the top of this workflow. + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if [ "$MEASURE_RESULT" != "success" ]; then + echo "Measurement job did not succeed ($MEASURE_RESULT); it reports its own failure." + exit 0 + fi + + count=$(jq '.regressions | length' "$RUNNER_TEMP/gate.json") + if [ "$count" -eq 0 ]; then + echo "No gated bundle grew past the threshold." + exit 0 + fi + + echo "Gated bundles that grew past max(${THRESHOLD_PCT}%, ${THRESHOLD_BYTES} bytes):" + jq -r '.regressions[] | " \(.app) \(.id): +\(.rawDelta) bytes (threshold \(.threshold | floor) bytes)"' \ + "$RUNNER_TEMP/gate.json" + + if jq -e --arg label "$OVERRIDE_LABEL" 'index($label) != null' \ + <<< "$PR_LABELS" > /dev/null; then + echo "The '${OVERRIDE_LABEL}' label is present; accepting the growth." + exit 0 + fi + echo "Add the '${OVERRIDE_LABEL}' label to accept this growth." + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 573d600925..c875b8892c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,6 +356,21 @@ This project uses pnpm with workspace configuration. The required version is spe Linting, formatting, and typechecking (`pnpm lint`, `pnpm format`, `pnpm typecheck`) are all facets of the same static-quality gate, and CI runs them on every PR. Treat them as **advisory** while working locally: run them and fix obvious issues when it's convenient, but a failure in any of them should **not** block you from committing, pushing, or opening a PR. CI is the source of truth and will report anything that matters. Don't get stuck iterating locally to make these pass before handing off. +## Bundle size + +`.github/workflows/bundle-size.yml` builds the `nextjs-turbopack` and `hono` workbench apps on every PR, measures the `/.well-known/workflow/v1/flow` route, and posts a sticky comment with the delta against `main`. Pushes to `main` exist to produce the baseline artifacts that PR runs download. + +It reports two numbers per app, because neither app emits an isolable function bundle for that route (Next.js emits a ~1 KB turbopack chunk loader pointing at chunks shared with other routes; nitro inlines the handler into a single server entry): + +- **Gated**: what the workflow builders emit for the flow route, before the framework bundles it. Growth beyond `max(2%, 50 KiB)` raw fails the job. Add the `allow-bundle-size-growth` label to accept it. +- **Informational**: the framework's own build output. Unrelated changes move it, so it never gates. + +The two are only ever compared against their own baselines, never against each other, and neither alone is the deployed function: the gated bundle is the VM code the route carries as an inline string, while the code hosting it sits in the framework output. + +**The gate does not cover the world adapters.** Building `nextjs-turbopack` with `WORKFLOW_TARGET_WORLD=local` and `=vercel` produces byte-identical reports on all three metrics, because every world the app depends on is bundled into the framework output either way and the choice is made at runtime. A change confined to `@workflow/world-vercel` will not move the gated numbers. + +The job pins `WORKFLOW_SOURCEMAP`, `WORKFLOW_PUBLIC_MANIFEST`, and `WORKFLOW_TARGET_WORLD`, and records them in each report's fingerprint; the renderer refuses to diff reports whose fingerprints disagree. `WORKFLOW_SOURCEMAP=false` is the one that moves the numbers, since sourcemap mode defaults to inline outside a production build and that alone takes the Next flow bundle from 1.38 MB to 5.85 MB. Changing any pin invalidates comparisons against older baselines. + ## Documentation standards - README.md files in each package must accurately reflect the current functionality and purpose of that package From c9af771e54d5223c8c49bb70e38faeecb889cd2e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 21 Aug 2026 17:27:38 -0700 Subject: [PATCH 2/2] [ci] Fingerprint the OS and arch in bundle-size reports The same hono build produces 55 files under .output/server on a Linux runner and 54 on macOS, so a report measured off-runner is not comparable to one measured on it. Node's major version was already recorded because zlib ships with Node and moves the gzip numbers; platform and arch cover the raw ones. Cheap to add now: no baseline exists on main yet, so nothing is invalidated. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/measure-flow-bundle.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/scripts/measure-flow-bundle.mjs b/.github/scripts/measure-flow-bundle.mjs index b7609f09db..1366b0be61 100644 --- a/.github/scripts/measure-flow-bundle.mjs +++ b/.github/scripts/measure-flow-bundle.mjs @@ -431,7 +431,14 @@ function main() { app: args.app, commit: args.commit ?? null, fingerprint: { + // Node ships zlib, so its major version can move the gzip numbers, and + // the OS moves the raw ones: the same hono build produces 55 files under + // .output/server on a Linux runner and 54 on macOS. CI always runs + // ubuntu-latest, so these only ever differ when someone measures + // somewhere else, which is exactly the comparison worth refusing. nodeMajor: process.versions.node.split('.')[0], + platform: process.platform, + arch: process.arch, ...Object.fromEntries( FINGERPRINT_ENV.map((key) => [key, process.env[key] ?? null]) ),