diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index eff29c08f..94f7ac03a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..f94d3c2ea --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.13.1 \ No newline at end of file diff --git a/package.json b/package.json index d2f109a88..f8561478a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/boot-check.mjs b/scripts/boot-check.mjs new file mode 100644 index 000000000..985f1a55f --- /dev/null +++ b/scripts/boot-check.mjs @@ -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('