ci: engines >=20 + .nvmrc; hard audit gate; boot check; bundle budget; eslint ratchet
- engines.node >=20.0.0 and .nvmrc mirroring .node-version (#54) - npm audit --audit-level=high is a hard gate (tree is at 0) (#91) - scripts/boot-check.mjs serves dist/ with vite preview and asserts /, config.json, the entry chunk and the Element Call bundle all load (#92) - scripts/check-bundle-size.mjs enforces gzip budgets from scripts/bundle-budget.json (seeded +10%); fails PRs, warns on push (#96) - check:eslint runs with --max-warnings 68 so the count can only go down; 7 unused eslint-disable directives removed to get there (#97) Fixes #54 Fixes #91 Fixes #92 Fixes #96 Fixes #97 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
+22
-18
@@ -64,8 +64,12 @@ jobs:
|
||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
||||
# informational — check:eslint has no --max-warnings).
|
||||
# eslint gates on errors, plus a warning ratchet (Gitea #97): `check:eslint`
|
||||
# runs with `--max-warnings 74`, the exact warning count on this tree at
|
||||
# the time the ratchet was added. New warnings push the count over that
|
||||
# ceiling and fail the build; fixing an existing warning is free to do
|
||||
# and should lower the ceiling in the same PR so the count can only go
|
||||
# down over time, never back up.
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
@@ -87,25 +91,25 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
# ── Boot check — actually loads the built dist/, not just builds it ──
|
||||
- name: Boot check
|
||||
run: node scripts/boot-check.mjs
|
||||
|
||||
# ── Security — hard gate. #24 cleared the outstanding advisories (0
|
||||
# vulnerabilities on this tree, verified with `npm audit --omit=dev`), so
|
||||
# there is nothing left this should be soft against. Hard on both
|
||||
# `push` and `pull_request`: a new high/critical advisory should block
|
||||
# the deploy just as much as it should block the PR.
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
continue-on-error: true
|
||||
|
||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||
- name: Report bundle sizes
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
|
||||
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
|
||||
name=$(basename "$f")
|
||||
size=$(du -sh "$f" | cut -f1)
|
||||
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
|
||||
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
# ── Bundle size budget — hard gate on pull_request, warning on push (a
|
||||
# push has already merged; failing it can only delay deploying an
|
||||
# otherwise-good commit, not prevent the regression, so pull_request is
|
||||
# where this should be caught). Budgets live in scripts/bundle-budget.json.
|
||||
- name: Check bundle size budget
|
||||
continue-on-error: ${{ github.event_name == 'push' }}
|
||||
run: node scripts/check-bundle-size.mjs ${{ github.event_name }}
|
||||
|
||||
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||
|
||||
+2
-2
@@ -5,14 +5,14 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:eslint": "eslint src/* --max-warnings 68",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
// Boot-check (Gitea #92): after `npm run build`, nothing actually loaded the
|
||||
// built dist/ — a build that produces a broken bundle (bad base path, a 500
|
||||
// from an asset, a malformed config.json) still went green. This script
|
||||
// serves dist/ the same way production does (vite preview) and makes a
|
||||
// handful of real HTTP requests against it, so a broken bundle fails CI
|
||||
// instead of surfacing after deploy.
|
||||
//
|
||||
// No Playwright here: playwright-core is not a project dependency (only the
|
||||
// browser binary caches happen to be present on this machine), so we don't
|
||||
// depend on it being installed. Plain fetch is enough to catch the class of
|
||||
// bug this check exists for — a page/asset/config that doesn't come back.
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const PORT = 4173;
|
||||
const HOST = '127.0.0.1';
|
||||
const BASE_URL = `http://${HOST}:${PORT}`;
|
||||
const BOOT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[boot-check] ${msg}`);
|
||||
}
|
||||
|
||||
function waitForPort(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const attempt = async () => {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
const res = await attempt();
|
||||
if (res) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`Timed out waiting for ${url} to come up`));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 300);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
async function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
log(`ok: ${message}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const preview = spawn(
|
||||
'npx',
|
||||
['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', HOST],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
let previewOutput = '';
|
||||
preview.stdout.on('data', (d) => (previewOutput += d.toString()));
|
||||
preview.stderr.on('data', (d) => (previewOutput += d.toString()));
|
||||
|
||||
const cleanup = () => {
|
||||
if (!preview.killed) {
|
||||
preview.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(BASE_URL, BOOT_TIMEOUT_MS);
|
||||
|
||||
// 1. index page loads and contains the SPA mount point.
|
||||
const indexRes = await fetch(`${BASE_URL}/`);
|
||||
await assert(indexRes.status === 200, `GET / returns 200 (got ${indexRes.status})`);
|
||||
const indexHtml = await indexRes.text();
|
||||
await assert(indexHtml.includes('<div id="root"'), 'GET / contains <div id="root"');
|
||||
|
||||
// 2. runtime config is valid JSON.
|
||||
const configRes = await fetch(`${BASE_URL}/config.json`);
|
||||
await assert(
|
||||
configRes.status === 200,
|
||||
`GET /config.json returns 200 (got ${configRes.status})`,
|
||||
);
|
||||
const configText = await configRes.text();
|
||||
let configJson;
|
||||
try {
|
||||
configJson = JSON.parse(configText);
|
||||
} catch (err) {
|
||||
throw new Error(`GET /config.json is not valid JSON: ${err.message}`);
|
||||
}
|
||||
await assert(
|
||||
typeof configJson === 'object' && configJson !== null,
|
||||
'/config.json parses to an object',
|
||||
);
|
||||
|
||||
// 3. the main JS entry referenced from index.html actually loads.
|
||||
const scriptMatch = indexHtml.match(/<script[^>]+type="module"[^>]+src="([^"]+)"/);
|
||||
await assert(!!scriptMatch, 'index.html references a module script entry');
|
||||
const mainScriptUrl = new URL(scriptMatch[1], BASE_URL).toString();
|
||||
const scriptRes = await fetch(mainScriptUrl);
|
||||
await assert(
|
||||
scriptRes.status === 200,
|
||||
`GET ${scriptMatch[1]} returns 200 (got ${scriptRes.status})`,
|
||||
);
|
||||
const scriptContentType = scriptRes.headers.get('content-type') || '';
|
||||
await assert(
|
||||
/javascript/.test(scriptContentType),
|
||||
`GET ${scriptMatch[1]} has a JS content-type (got "${scriptContentType}")`,
|
||||
);
|
||||
|
||||
// 4. Element Call widget bundle is present.
|
||||
const callRes = await fetch(`${BASE_URL}/public/element-call/index.html`);
|
||||
await assert(
|
||||
callRes.status === 200,
|
||||
`GET /public/element-call/index.html returns 200 (got ${callRes.status})`,
|
||||
);
|
||||
|
||||
log('all checks passed');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(`[boot-check] FAILED: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
// The killed preview server's stdio pipes can keep the event loop alive
|
||||
// briefly; force the process down promptly with whatever exit code was set.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
|
||||
"totalGzipBytes": 1731120,
|
||||
"largestChunkGzipBytes": 358317
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Bundle size budget (Gitea #96). The CI "Report bundle sizes" step printed
|
||||
// numbers with nothing to compare them against, so a bundle could balloon
|
||||
// silently. This script compares dist/assets gzip sizes against a small
|
||||
// checked-in budget (scripts/bundle-budget.json) and prints the same report.
|
||||
//
|
||||
// Mode is passed via argv: `pull_request` fails the build over budget,
|
||||
// anything else (e.g. `push`) only warns — a push has already merged, so
|
||||
// blocking it can't prevent the regression, only delay the deploy of an
|
||||
// otherwise-good commit; the pull_request gate is where this should be caught.
|
||||
import { appendFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mode = process.argv[2] || 'push';
|
||||
const distAssetsDir = path.join(__dirname, '..', 'dist', 'assets');
|
||||
const budgetPath = path.join(__dirname, 'bundle-budget.json');
|
||||
|
||||
function formatKb(bytes) {
|
||||
return `${(bytes / 1024).toFixed(1)} kB`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const budget = JSON.parse(readFileSync(budgetPath, 'utf-8'));
|
||||
|
||||
const jsFiles = readdirSync(distAssetsDir)
|
||||
.filter((f) => f.endsWith('.js') && !f.endsWith('.map'))
|
||||
.sort();
|
||||
|
||||
if (jsFiles.length === 0) {
|
||||
console.error(
|
||||
`[check-bundle-size] No .js files found in ${distAssetsDir} — did the build run?`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = jsFiles.map((name) => {
|
||||
const filePath = path.join(distAssetsDir, name);
|
||||
const size = statSync(filePath).size;
|
||||
const gzipSize = gzipSync(readFileSync(filePath)).length;
|
||||
return { name, size, gzipSize };
|
||||
});
|
||||
|
||||
const totalGzipBytes = rows.reduce((sum, r) => sum + r.gzipSize, 0);
|
||||
const largest = rows.reduce((max, r) => (r.gzipSize > max.gzipSize ? r : max), rows[0]);
|
||||
|
||||
const summaryLines = [
|
||||
'### Bundle sizes',
|
||||
'',
|
||||
'| File | Size | Gzip |',
|
||||
'|------|------|------|',
|
||||
...rows.map((r) => `| ${r.name} | ${formatKb(r.size)} | ${formatKb(r.gzipSize)} |`),
|
||||
'',
|
||||
`**Total gzip:** ${formatKb(totalGzipBytes)} (budget ${formatKb(budget.totalGzipBytes)})`,
|
||||
`**Largest chunk gzip:** ${largest.name} — ${formatKb(largest.gzipSize)} (budget ${formatKb(
|
||||
budget.largestChunkGzipBytes,
|
||||
)})`,
|
||||
];
|
||||
|
||||
const summaryText = summaryLines.join('\n');
|
||||
console.log(summaryText);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
// Gitea Actions/act_runner honors the same GITHUB_STEP_SUMMARY convention.
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summaryText}\n`);
|
||||
}
|
||||
|
||||
const overBudget = [];
|
||||
if (totalGzipBytes > budget.totalGzipBytes) {
|
||||
overBudget.push(
|
||||
`total gzip ${formatKb(totalGzipBytes)} exceeds budget ${formatKb(budget.totalGzipBytes)}`,
|
||||
);
|
||||
}
|
||||
if (largest.gzipSize > budget.largestChunkGzipBytes) {
|
||||
overBudget.push(
|
||||
`largest chunk (${largest.name}) gzip ${formatKb(largest.gzipSize)} exceeds budget ${formatKb(
|
||||
budget.largestChunkGzipBytes,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (overBudget.length > 0) {
|
||||
const message = `[check-bundle-size] Over budget: ${overBudget.join('; ')}`;
|
||||
if (mode === 'pull_request') {
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.warn(`${message} (warning only on "${mode}")`);
|
||||
}
|
||||
} else {
|
||||
console.log('[check-bundle-size] Within budget.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user