feat(ci): assert gzip transfer sizes against ADR-0017 budgets #133

Merged
julien merged 2 commits from feat/ci/gzip-transfer-size-budgets-v2 into main 2026-05-14 16:22:55 +02:00
3 changed files with 144 additions and 2 deletions
Showing only changes of commit f776ce732a - Show all commits
@@ -209,7 +209,8 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
### Confirmation
- `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
- `apps/portal-shell/project.json` declares `budgets` with `maximumWarning == maximumError` (so an overshoot fails the build, matching `type: "error"` in spirit). Four entries: `initial` (≤ 1 MB raw), `anyScript` (≤ 300 KB raw), `anyComponentStyle` (≤ 6 KB raw, with 5 KB warning floor), `bundle name=styles` (≤ 150 KB raw). Angular CLI compares **raw** sizes; the values above are translated from the gzip-based ADR targets at the conventional ≈ 3× JS / ≈ 4× CSS compression ratios. A future follow-up will add a CI check that asserts the **actual** gzipped transfer size against the ADR thresholds — Angular CLI does not natively support a gzip-mode budget.
- `apps/portal-shell/project.json` declares `budgets` with `maximumWarning == maximumError` (so an overshoot fails the build, matching `type: "error"` in spirit). Four entries: `initial` (≤ 1 MB raw), `anyScript` (≤ 300 KB raw), `anyComponentStyle` (≤ 6 KB raw, with 5 KB warning floor), `bundle name=styles` (≤ 150 KB raw). Angular CLI compares **raw** sizes; the values above are translated from the gzip-based ADR targets at the conventional ≈ 3× JS / ≈ 4× CSS compression ratios.
- A complementary CI check in [`scripts/check-gzip-budgets.mjs`](../../scripts/check-gzip-budgets.mjs) (wired into `pnpm ci:perf` between the production build and Lighthouse) asserts the **actual** gzipped transfer size against the ADR thresholds: initial-JS-total ≤ 300 KB, any single lazy chunk ≤ 100 KB, total CSS ≤ 150 KB. Angular CLI does not natively support a gzip-mode budget — this script is the explicit gate. It parses `index.html` to distinguish initial assets from lazy chunks, gzips each file at level 9 (matching what HTTP servers typically serve), and exits non-zero on any breach.
- `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
- CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf`. It is blocking on human PRs and on `push` to `main`; skipped on PRs authored by the Renovate bot user (`apf-portal-bot`) per the gating-policy subsection above.
- `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
+2 -1
View File
@@ -8,7 +8,8 @@
"ci:check": "pnpm exec nx affected -t format:check lint test build",
"ci:audit": "pnpm audit --audit-level=moderate",
"ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose",
"ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm exec lhci autorun --config=./lighthouserc.js"
"ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs",
"ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js"
},
"private": true,
"lint-staged": {
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* Check the gzip-transfer size of an Angular production build against
* the ADR-0017 perf budgets. Complements Angular CLI's native
* `budgets` (which only compare RAW sizes — no built-in gzip mode).
*
* Strategy
* --------
* 1. Parse the Angular `index.html` of the build output. Every
* `<script src="…">` and `<link href="…">` referencing a JS/CSS
* asset is part of the **initial** payload. Everything else in
* the output dir is a **lazy** chunk loaded on demand.
* 2. Gzip every JS/CSS file with level 9 (matching what HTTP
* servers typically emit for static assets) and report the
* resulting transfer size.
* 3. Compare:
* - sum of initial JS ≤ BUDGET_INITIAL_JS_TOTAL
* - any single lazy JS ≤ BUDGET_LAZY_JS_EACH
* - sum of all CSS ≤ BUDGET_CSS_TOTAL
* Exit non-zero on any violation.
*
* Invocation: `node scripts/check-gzip-budgets.mjs [dist-dir]`.
* The default `dist-dir` is `dist/apps/portal-shell/browser`.
*/
import { readdir, readFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import { basename, join } from 'node:path';
const DEFAULT_DIST = 'dist/apps/portal-shell/browser';
const dist = process.argv[2] ?? DEFAULT_DIST;
// ADR-0017 budgets (gzip transfer, in bytes).
const BUDGET_INITIAL_JS_TOTAL = 300 * 1024;
const BUDGET_LAZY_JS_EACH = 100 * 1024;
const BUDGET_CSS_TOTAL = 150 * 1024;
const fmtKb = (n) => `${(n / 1024).toFixed(2)} KB`;
const padFile = (s) => s.padEnd(36);
async function gzipSize(path) {
return gzipSync(await readFile(path), { level: 9 }).byteLength;
}
async function main() {
let indexHtml;
try {
indexHtml = await readFile(join(dist, 'index.html'), 'utf8');
} catch (err) {
console.error(`✗ Could not read index.html under "${dist}".`);
console.error(` Did you run "nx build portal-shell --configuration=production" first?`);
console.error(` Underlying error: ${err.message}`);
process.exit(2);
}
// Match every src=/href= that points at a .js or .css file in the
// output dir. Robust enough for Angular's emitted index.html (which
// is well-formed and predictable).
const referenced = new Set(
[...indexHtml.matchAll(/(?:src|href)\s*=\s*["']([^"']+\.(?:js|css))["']/g)].map((m) =>
basename(m[1]),
),
);
const all = await readdir(dist);
const jsFiles = all.filter((f) => f.endsWith('.js'));
const cssFiles = all.filter((f) => f.endsWith('.css'));
const initialJs = jsFiles.filter((f) => referenced.has(f));
const lazyJs = jsFiles.filter((f) => !referenced.has(f));
const sized = async (files) =>
Promise.all(files.map(async (f) => ({ f, size: await gzipSize(join(dist, f)) })));
const initialJsSizes = await sized(initialJs);
const lazyJsSizes = await sized(lazyJs);
const cssSizes = await sized(cssFiles);
const sum = (arr) => arr.reduce((s, x) => s + x.size, 0);
const totalInitialJs = sum(initialJsSizes);
const totalCss = sum(cssSizes);
const violations = [];
console.log(`Gzip-transfer budget check (ADR-0017) — output: ${dist}\n`);
console.log(' Initial JS (loaded on first paint):');
for (const { f, size } of initialJsSizes.sort((a, b) => b.size - a.size)) {
console.log(` ${padFile(f)} ${fmtKb(size)}`);
}
const initialFlag =
totalInitialJs > BUDGET_INITIAL_JS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
console.log(
` ${padFile('TOTAL')} ${fmtKb(totalInitialJs)} / ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}${initialFlag}`,
);
if (totalInitialJs > BUDGET_INITIAL_JS_TOTAL) {
violations.push(
`initial JS total ${fmtKb(totalInitialJs)} > budget ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}`,
);
}
console.log('\n Lazy JS chunks (loaded on demand):');
if (lazyJsSizes.length === 0) {
console.log(' (none)');
}
for (const { f, size } of lazyJsSizes.sort((a, b) => b.size - a.size)) {
const flag = size > BUDGET_LAZY_JS_EACH ? ' ✗ EXCEEDS BUDGET' : '';
console.log(` ${padFile(f)} ${fmtKb(size)}${flag}`);
if (size > BUDGET_LAZY_JS_EACH) {
violations.push(`lazy chunk ${f} ${fmtKb(size)} > per-chunk budget ${fmtKb(BUDGET_LAZY_JS_EACH)}`);
}
}
console.log(` (per-chunk budget: ${fmtKb(BUDGET_LAZY_JS_EACH)})`);
console.log('\n CSS files:');
for (const { f, size } of cssSizes.sort((a, b) => b.size - a.size)) {
console.log(` ${padFile(f)} ${fmtKb(size)}`);
}
const cssFlag = totalCss > BUDGET_CSS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓';
console.log(
` ${padFile('TOTAL')} ${fmtKb(totalCss)} / ${fmtKb(BUDGET_CSS_TOTAL)}${cssFlag}`,
);
if (totalCss > BUDGET_CSS_TOTAL) {
violations.push(`CSS total ${fmtKb(totalCss)} > budget ${fmtKb(BUDGET_CSS_TOTAL)}`);
}
if (violations.length > 0) {
console.error('\n✗ ADR-0017 budget violations:');
for (const v of violations) console.error(` - ${v}`);
process.exit(1);
}
console.log('\n✓ All ADR-0017 gzip-transfer budgets respected.');
}
main().catch((err) => {
console.error('Budget check failed:', err);
process.exit(2);
});