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
|
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
# eslint gates on errors, plus a warning ratchet (Gitea #97): `check:eslint`
|
||||||
# informational — check:eslint has no --max-warnings).
|
# 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
|
- name: Prettier
|
||||||
run: npm run check:prettier
|
run: npm run check:prettier
|
||||||
|
|
||||||
@@ -87,25 +91,25 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
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)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
# ── Bundle size budget — hard gate on pull_request, warning on push (a
|
||||||
- name: Report bundle sizes
|
# push has already merged; failing it can only delay deploying an
|
||||||
continue-on-error: true
|
# otherwise-good commit, not prevent the regression, so pull_request is
|
||||||
run: |
|
# where this should be caught). Budgets live in scripts/bundle-budget.json.
|
||||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
- name: Check bundle size budget
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
continue-on-error: ${{ github.event_name == 'push' }}
|
||||||
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
|
run: node scripts/check-bundle-size.mjs ${{ github.event_name }}
|
||||||
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
|
|
||||||
|
|
||||||
# ── Desktop build trigger ──────────────────────────────────────────────
|
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||||
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||||
|
|||||||
+2
-2
@@ -5,14 +5,14 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/* --max-warnings 68",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"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