ci: add inline-script guard for built index.html

SvelteKit emits one inline bootstrap <script> in build/index.html and
the CSP in static/_headers is relaxed to `script-src 'unsafe-inline'`
to admit it. If a SvelteKit upgrade adds another inline block, the
relaxation no longer matches reality and the new block could ship
unhashed.

`npm run verify:build` reads build/index.html, counts inline scripts
(no `src=`), and fails when count > EXPECTED_INLINE (1). New GH
Actions workflow runs test + build + verify on push/PR to main.

Mutation-tested locally: setting EXPECTED_INLINE=0 fails as expected,
restored to 1 passes.
This commit is contained in:
2026-04-28 10:55:43 +07:00
parent 99c7a900a8
commit 558d0c75b2
6 changed files with 79 additions and 4 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Verify build
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
concurrency:
group: verify-build-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- run: npm run verify:build
+3 -1
View File
@@ -46,7 +46,9 @@
|------|---------|
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch, verify:build. |
| `scripts/verify-build-inline-scripts.mjs` | Post-build CSP guard. Counts inline `<script>` tags in `build/index.html` and fails if > EXPECTED_INLINE (1). Catches future SvelteKit upgrades that add inline blocks the CSP `'unsafe-inline'` relaxation isn't calibrated for. |
| `.github/workflows/verify-build.yml` | CI: on push/PR to main runs `npm test && npm run build && npm run verify:build` to enforce the inline-script guard above. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
+2 -1
View File
@@ -11,7 +11,8 @@
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"verify:build": "node scripts/verify-build-inline-scripts.mjs"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.32.0"
@@ -1,9 +1,10 @@
---
name: CI inline-script guard
phase: 2
status: todo
status: completed
priority: high
effort: 30m
completed: 2026-04-28
---
# Phase 2 — CI inline-script guard
@@ -18,7 +18,7 @@ YAGNI — parking-lot features and upstream-blocked items skipped.
| # | Phase | File |
|---|-------|------|
| 1 | Auto-tick integration test ✅ | `phase-01-auto-tick-test.md` |
| 2 | CI inline-script guard | `phase-02-ci-inline-script-guard.md` |
| 2 | CI inline-script guard | `phase-02-ci-inline-script-guard.md` |
| 3 | Mode picker glyph redesign | `phase-03-mode-picker-glyphs.md` |
| 4 | Settings modal sticky on small screens | `phase-04-settings-modal-sticky.md` |
| 5 | Per-row "Chờ" indicator | `phase-05-cho-row-indicator.md` |
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* Guard: count inline <script> tags in build/index.html. SvelteKit
* currently emits exactly one inline bootstrap block — the relaxation
* we ship in static/_headers (`script-src 'self' 'unsafe-inline'`)
* is calibrated to that. If a future SvelteKit upgrade adds another
* inline block, this guard fails CI so we either (a) hash the new
* block into CSP or (b) bump EXPECTED_INLINE intentionally.
*
* Inline = no `src=` attribute. Module/external scripts are excluded.
*/
import { readFileSync } from "node:fs";
const EXPECTED_INLINE = 1;
const HTML_PATH = "build/index.html";
let html;
try {
html = readFileSync(HTML_PATH, "utf8");
} catch (e) {
console.error(`verify-build: cannot read ${HTML_PATH} — run \`npm run build\` first.`);
process.exit(2);
}
const inline = (html.match(/<script(?![^>]*\bsrc=)[^>]*>/g) || []).length;
if (inline > EXPECTED_INLINE) {
console.error(
`verify-build: found ${inline} inline <script> tags in ${HTML_PATH} (expected ${EXPECTED_INLINE}).\n` +
`If this is intentional, update EXPECTED_INLINE in scripts/verify-build-inline-scripts.mjs\n` +
`AND add the SHA-256 hash(es) of the new inline block(s) to static/_headers script-src.`,
);
process.exit(1);
}
if (inline < EXPECTED_INLINE) {
console.warn(
`verify-build: found ${inline} inline <script> tags but expected ${EXPECTED_INLINE}.\n` +
`If SvelteKit changed its bootstrap strategy, lower EXPECTED_INLINE and tighten CSP.`,
);
}
console.log(`verify-build: ${inline} inline <script> tag(s) — OK.`);