mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
ci(bundle): report-only First Load JS budget in the Dockerfile build (Tekton) (#2511)
* ci(bundle): add report-only size-limit bundle budget job Next 16 removed per-route build stats, leaving no bundle-size regression signal. Adds a `bundle-budget` job to pr-check.yml that builds the app (SKIP_ENV_VALIDATION, no secrets) and runs size-limit over the shared client chunks (framework/main/webpack/_app + a coarse total) defined in .size-limit.json. Report-only for now: continue-on-error + intentionally loose limits. This is also the first GH Actions job to run a full `next build` (~8GB heap vs ~7GB standard runner) so early runs probe feasibility. Once a baseline is observed: tighten limits to baseline+headroom, drop continue-on-error, and make "Bundle Budget" a required check to gate. If Build OOMs, move to a larger runner label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * ci(bundle): run size-limit in the Dockerfile build, not GH Actions Switch the bundle-size check from a separate GH Actions job (which would duplicate the full ~8GB next build) to a stage in the Dockerfile builder, right after `pnpm run build` where .next already exists. The Tekton buildkit build (preview + prod) now reports the size-limit numbers with no extra build — consistent with where app builds live. Report-only during the soak via `|| true` (numbers print to the build log). To gate later: drop `|| true` so a bundle regression fails the image build. Reverts the pr-check.yml bundle-budget job; keeps .size-limit.json + the size-limit deps + the `size` script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): fix size-limit globs for Turbopack output The live preview build (next 16.2.7 Turbopack) revealed the webpack-era globs match nothing — Turbopack emits opaque hashed chunks (0--619vzepha0.js, turbopack-*.js), no framework-/main-/webpack-/_app- files. Those 4 entries errored ("can't find files"); only the recursive total worked. Baseline from the build: total client JS = 38.29 MB brotli (3615 chunks). Drop the 4 broken named-chunk entries; keep the working total with a 42 MB limit (~10% headroom). Still report-only (|| true in Dockerfile). Note: the coarse total is a weak regression signal under Turbopack's heavy code-splitting; a per-page First Load JS budget needs parsing .next/build-manifest.json (follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): manifest-based First Load JS budget (replaces size-limit) size-limit's globs can't see Turbopack's opaque hashed chunks, so it could only report a coarse 38 MB total (weak signal). Replace it with scripts/bundle-budget.mjs, which parses .next/build-manifest.json to reconstruct the metric Next used to print: First Load JS(route) = brotli(union(pages[route], pages["/_app"], polyfills)) shared-by-all-pages = brotli(pages["/_app"] + polyfills) Reports shared + total + the heaviest routes, checks .bundle-budget.json (report-only; `--gate` exits non-zero on a breach). No deps (Node stdlib zlib/fs), no extra build — still runs in the Dockerfile builder stage. Removes size-limit + @size-limit/file + .size-limit.json. Budgets are loose placeholders; tighten to baseline+headroom from the first build's printed First Load JS numbers, then add --gate + drop the `|| true` to enforce. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): tighten First Load JS budgets to baseline + headroom From build pr-preview-2511-fzrjd: shared-by-all = 425.9 kB, heaviest route (/user/[username]/models) = 1.13 MB. Set shared 470 kB (~10%) and routeMax 1.3 MB (~15%) so the report-only check is meaningful instead of passing trivially at the 1 MB/3 MB placeholders. Still report-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): bake bundle-budget report into the image for PR surfacing Write the size report to /app/bundle-budget.txt (still report-only, still printed to the build log) and COPY it into the runner image. A new Tekton bundle-comment task surfaces it on the PR via `kubectl exec ... cat` — no duplicate build. Uses redirect+cat instead of `| tee` so the script's exit code is preserved for the future --gate flip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview build (bundle-comment task now live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (bundle-comment rollout-race fix live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (pr-deployer exec RBAC now granted) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): drop pnpm preamble from the bundle report Invoke `node scripts/bundle-budget.mjs` directly instead of `pnpm run size` so pnpm's lifecycle echo (`> model-share@… size /app`) stays out of /app/bundle-budget.txt and the PR comment. The `size` script stays in package.json for local use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (collapsible bundle comment) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"_comment": "First Load JS budgets (brotli). Report-only during the soak: scripts/bundle-budget.mjs runs without --gate and the Dockerfile stage ends in `|| true`. Tightened to baseline + ~10-15% headroom from build pr-preview-2511-fzrjd (shared 425.9 kB, heaviest route /user/[username]/models 1.13 MB). To enforce: add --gate to the `size` script + drop the `|| true` in the Dockerfile. `routes` overrides routeMax for a specific page.",
|
||||
"shared": "470 kB",
|
||||
"routeMax": "1.3 MB",
|
||||
"routes": {}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
+18
@@ -46,6 +46,21 @@ ARG NODE_BUILD_MEM=8192
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
SKIP_ENV_VALIDATION=1 IS_BUILD=true NODE_OPTIONS="--max_old_space_size=${NODE_BUILD_MEM}" pnpm run build
|
||||
|
||||
# Bundle-size budget (report-only during the soak). Next 16 (Turbopack) emits
|
||||
# opaque hashed chunks and removed per-route build stats, so scripts/bundle-budget.mjs
|
||||
# parses .next/build-manifest.json to reconstruct per-page First Load JS (brotli)
|
||||
# + a shared-by-all-pages figure. Runs here because .next exists in this stage
|
||||
# and the build already happened — no duplicate build. `|| true` keeps it
|
||||
# report-only (numbers print to the build log); to GATE, add `--gate` to the
|
||||
# node invocation and replace `|| true; cat ...` with `; rc=$?; cat ...; exit $rc`
|
||||
# so a budget breach fails the image build.
|
||||
# The report is also written to /app/bundle-budget.txt and COPYied into the
|
||||
# runner image so the Tekton bundle-comment task can surface it on the PR
|
||||
# (kubectl exec ... cat) without a duplicate build.
|
||||
# Invoke node directly (not `pnpm run size`) so pnpm's lifecycle preamble
|
||||
# (`> model-share@… size /app`) stays out of the report/comment.
|
||||
RUN node scripts/bundle-budget.mjs > /app/bundle-budget.txt 2>&1 || true; cat /app/bundle-budget.txt
|
||||
|
||||
# Server source maps (.next/server/**/*.js.map) are emitted by the build
|
||||
# (productionBrowserSourceMaps -> turbopackSourceMaps) but @vercel/nft does NOT
|
||||
# trace sibling .map files into .next/standalone, so they never reach runtime.
|
||||
@@ -94,6 +109,9 @@ RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
# Bundle-budget report (report-only) — surfaced on the PR by the Tekton
|
||||
# bundle-comment task via `kubectl exec ... cat /app/bundle-budget.txt`.
|
||||
COPY --from=builder /app/bundle-budget.txt ./bundle-budget.txt
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"build": "next build",
|
||||
"build:dev": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" next build",
|
||||
"build:analyze": "cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ANALYZE=true next build",
|
||||
"size": "node scripts/bundle-budget.mjs",
|
||||
"deploy": "pnpm run build && pnpm run db:deploy",
|
||||
"postinstall": "pnpm run db:generate",
|
||||
"typecheck": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundle budget — per-page First Load JS, brotli-sized.
|
||||
*
|
||||
* Next 16 (Turbopack) emits opaque hashed chunks with no stable
|
||||
* framework-/main-/_app- filenames, and removed the per-route build-stats
|
||||
* table, so a glob tool (size-limit) can only see a coarse total. This script
|
||||
* reads `.next/build-manifest.json` instead and reconstructs the real metric
|
||||
* Next used to print:
|
||||
*
|
||||
* First Load JS for a route = brotli( union( pages[route], pages["/_app"],
|
||||
* polyfillFiles ) )
|
||||
* Shared by all pages = brotli( pages["/_app"] + polyfillFiles )
|
||||
*
|
||||
* It runs inside the Dockerfile builder stage (where `.next` exists), so there
|
||||
* is no extra build. Report-only by default; pass `--gate` to exit non-zero on
|
||||
* a budget breach (for when we promote this to a hard gate).
|
||||
*
|
||||
* Budgets live in `.bundle-budget.json`:
|
||||
* { "shared": "350 kB", "routeMax": "1.5 MB", "routes": { "/x": "2 MB" } }
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { brotliCompressSync, constants as zc } from 'node:zlib';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const NEXT_DIR = process.env.NEXT_DIR || '.next';
|
||||
const MANIFEST = join(NEXT_DIR, 'build-manifest.json');
|
||||
const BUDGET_FILE = process.env.BUNDLE_BUDGET_FILE || '.bundle-budget.json';
|
||||
const GATE = process.argv.includes('--gate');
|
||||
const TOP = Number(process.env.BUNDLE_TOP || 20);
|
||||
|
||||
const isJs = (f) => typeof f === 'string' && f.endsWith('.js');
|
||||
const uniq = (arr) => [...new Set(arr)];
|
||||
|
||||
function parseSize(s) {
|
||||
if (typeof s === 'number') return s;
|
||||
const m = String(s).trim().match(/^([\d.]+)\s*(b|kb|mb|gb)?$/i);
|
||||
if (!m) return NaN;
|
||||
const n = parseFloat(m[1]);
|
||||
const unit = (m[2] || 'b').toLowerCase();
|
||||
return n * { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[unit];
|
||||
}
|
||||
function human(b) {
|
||||
if (b >= 1024 ** 2) return (b / 1024 ** 2).toFixed(2) + ' MB';
|
||||
if (b >= 1024) return (b / 1024).toFixed(1) + ' kB';
|
||||
return b + ' B';
|
||||
}
|
||||
|
||||
if (!existsSync(MANIFEST)) {
|
||||
console.error(`bundle-budget: no manifest at ${MANIFEST} — was the build run?`);
|
||||
process.exit(GATE ? 1 : 0);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
||||
const budget = existsSync(BUDGET_FILE) ? JSON.parse(readFileSync(BUDGET_FILE, 'utf8')) : {};
|
||||
const sharedBudget = budget.shared != null ? parseSize(budget.shared) : Infinity;
|
||||
const routeMax = budget.routeMax != null ? parseSize(budget.routeMax) : Infinity;
|
||||
const routeOverrides = budget.routes || {};
|
||||
|
||||
// Brotli each file once (files are shared across many routes → cache).
|
||||
const sizeCache = new Map();
|
||||
function brSize(file) {
|
||||
if (sizeCache.has(file)) return sizeCache.get(file);
|
||||
const abs = join(NEXT_DIR, file);
|
||||
let size = 0;
|
||||
if (existsSync(abs)) {
|
||||
size = brotliCompressSync(readFileSync(abs), {
|
||||
params: { [zc.BROTLI_PARAM_QUALITY]: 11 },
|
||||
}).length;
|
||||
} else {
|
||||
console.error(`bundle-budget: WARN missing chunk ${file}`);
|
||||
}
|
||||
sizeCache.set(file, size);
|
||||
return size;
|
||||
}
|
||||
const sumBr = (files) => files.reduce((s, f) => s + brSize(f), 0);
|
||||
|
||||
const pages = manifest.pages || {};
|
||||
const sharedFiles = uniq([...(pages['/_app'] || []), ...(manifest.polyfillFiles || [])]).filter(isJs);
|
||||
const sharedSize = sumBr(sharedFiles);
|
||||
|
||||
const SKIP = new Set(['/_app', '/_error', '/_document']);
|
||||
const routes = Object.keys(pages)
|
||||
.filter((r) => !SKIP.has(r))
|
||||
.map((route) => {
|
||||
const files = uniq([...(pages[route] || []), ...sharedFiles]).filter(isJs);
|
||||
return { route, files: files.length, size: sumBr(files) };
|
||||
})
|
||||
.sort((a, b) => b.size - a.size);
|
||||
|
||||
// Coarse total = brotli of every referenced client .js (deduped via cache).
|
||||
const allFiles = uniq(Object.values(pages).flat().filter(isJs).concat(sharedFiles));
|
||||
const totalSize = sumBr(allFiles);
|
||||
|
||||
// ---- report ----
|
||||
const breaches = [];
|
||||
const flag = (ok) => (ok ? 'OK' : 'OVER');
|
||||
|
||||
console.log('===== bundle budget — First Load JS (brotli) =====');
|
||||
{
|
||||
const ok = sharedSize <= sharedBudget;
|
||||
if (!ok) breaches.push(`shared ${human(sharedSize)} > ${human(sharedBudget)}`);
|
||||
const limit = sharedBudget === Infinity ? '—' : human(sharedBudget);
|
||||
console.log(
|
||||
`Shared by all pages : ${human(sharedSize).padStart(9)} (${sharedFiles.length} files) [budget ${limit}] ${flag(ok)}`
|
||||
);
|
||||
}
|
||||
console.log(`Total client JS : ${human(totalSize).padStart(9)} (${allFiles.length} files)`);
|
||||
console.log(`Routes analysed : ${routes.length}`);
|
||||
console.log('');
|
||||
console.log(`Heaviest ${Math.min(TOP, routes.length)} routes by First Load JS:`);
|
||||
for (const r of routes.slice(0, TOP)) {
|
||||
const limit = routeOverrides[r.route] != null ? parseSize(routeOverrides[r.route]) : routeMax;
|
||||
const ok = r.size <= limit;
|
||||
if (!ok) breaches.push(`${r.route} ${human(r.size)} > ${human(limit)}`);
|
||||
const limTxt = limit === Infinity ? '—' : human(limit);
|
||||
console.log(` ${human(r.size).padStart(9)} ${r.route.padEnd(42)} [budget ${limTxt}] ${flag(ok)}`);
|
||||
}
|
||||
// routes outside the top-N can still breach
|
||||
for (const r of routes.slice(TOP)) {
|
||||
const limit = routeOverrides[r.route] != null ? parseSize(routeOverrides[r.route]) : routeMax;
|
||||
if (r.size > limit) breaches.push(`${r.route} ${human(r.size)} > ${human(limit)}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (breaches.length) {
|
||||
console.log(`${breaches.length} budget breach(es):`);
|
||||
for (const b of breaches) console.log(` ✗ ${b}`);
|
||||
console.log(GATE ? 'FAIL (gating)' : 'breaches found (report-only — not gating)');
|
||||
process.exit(GATE ? 1 : 0);
|
||||
}
|
||||
console.log(GATE ? 'PASS' : 'within budget (report-only)');
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user