ci(deploy): switch from Cloudflare Pages to GitHub Pages

Migrate build and deployment pipeline from Cloudflare Pages to GitHub Pages.
Adds production-ready deploy workflow in deploy-github-pages.yml with proper
artifact handling. Removes Cloudflare-specific tooling: wrangler config, _headers,
_redirects, and CSP hash injection scripts (no longer needed with GitHub Pages
static hosting). Updates package.json build scripts and all project documentation
to reflect new deployment target and simplified architecture.
This commit is contained in:
2026-05-09 23:25:06 +07:00
parent a06cbd2675
commit 8fc3ec6ebd
20 changed files with 385 additions and 301 deletions
+8 -35
View File
@@ -1,4 +1,4 @@
name: Deploy redirect to GitHub Pages
name: Deploy to GitHub Pages
on:
push:
@@ -19,43 +19,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate redirect pages
run: |
set -euo pipefail
DEST="https://loto.miti99.com"
mkdir -p out/master
cat > out/index.html <<HTML
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lô tô — đã chuyển sang ${DEST}</title>
<link rel="canonical" href="${DEST}/">
<meta http-equiv="refresh" content="0; url=${DEST}/">
<meta name="robots" content="noindex">
<script>
(function () {
var path = location.pathname.replace(/^\/loto/, "");
location.replace("${DEST}" + path + location.search + location.hash);
})();
</script>
<style>
body { font-family: system-ui, sans-serif; padding: 3rem 1.5rem; text-align: center; color: #475569; }
a { color: #6366f1; }
</style>
</head>
<body>
<p>Trang đã chuyển sang <a href="${DEST}/">${DEST}</a></p>
</body>
</html>
HTML
# Mirror to /master so deep links also redirect cleanly
cp out/index.html out/master/index.html
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build:gh
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: out
path: build
deploy:
needs: build
-1
View File
@@ -25,4 +25,3 @@ jobs:
- run: npm ci
- run: npm test
- run: npm run build
- run: npm run verify:build
+5 -4
View File
@@ -31,12 +31,13 @@ path prefix and breaks the SvelteKit base path.
## Build
```bash
npm run build # default — root basePath, for Cloudflare Pages
npm run build:gh # /loto basePath, for tiennm99.github.io/loto manual export
npm run build # root basePath (local preview / generic static host)
npm run build:gh # /loto basePath, for tiennm99.github.io/loto
```
Static export to `build/`. Deployed to Cloudflare Pages from `main`
(set up via the CF dashboard — see `docs/deployment-guide.md`).
Static export to `build/`. Deployed to GitHub Pages from `main` via
`.github/workflows/deploy-github-pages.yml` — see
`docs/deployment-guide.md`.
## Regenerating audio
+3 -3
View File
@@ -189,7 +189,7 @@ Set in `.env.local` (not committed).
### Build Targets
- **adapter-static**: Generates static HTML + JS in `build/`.
- **basePath**: Dual-mode: `""` (Cloudflare, dev) or `/loto` (GitHub Pages via `BUILD_PROFILE=gh`).
- **basePath**: `/loto` for production GitHub Pages (`BUILD_PROFILE=gh`); `""` for local dev / generic static preview.
Last reviewed: 2026-04-27
Last synced: 2026-04-27 (6-phase refactor)
Last reviewed: 2026-05-09
Last synced: 2026-05-09 (deploy target switched to GitHub Pages)
+5 -7
View File
@@ -49,15 +49,13 @@
|------|---------|
| `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, verify:build. |
| `scripts/verify-build-inline-scripts.mjs` | Post-build CSP guard. Counts inline `<script>` tags in `build/index.html` (fails if > EXPECTED_INLINE) and asserts `build/_headers` `script-src` has no `'unsafe-inline'`. Catches future SvelteKit upgrades that ship a new inline block the CSP isn't calibrated for. |
| `scripts/inject-csp-hashes.mjs` | Postbuild step chained into `npm run build` and `build:gh`. Reads inline `<script>` bodies from `build/index.html`, computes SHA-256, and rewrites `build/_headers` `script-src 'self' 'unsafe-inline'``script-src 'self' 'sha256-…'`. Hash regenerates per build; `_headers` is treated as a build artifact. |
| `.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. |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
| `.github/workflows/deploy-github-pages.yml` | Canonical deploy. On push to `main`: `npm ci && npm run build:gh`, uploads `build/` as the GitHub Pages artifact, deploys to `https://tiennm99.github.io/loto/`. |
| `.github/workflows/verify-build.yml` | CI: on push/PR to main runs `npm test && npm run build` as a regression gate before the deploy job. |
| `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. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
| `static/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. |
| `static/manifest.webmanifest` | PWA manifest: name, short_name, icons (192/512px), theme colors, display: standalone, scope, start_url. |
| `static/icons/{192,512}.png` | PWA icons (auto-generated by @vite-pwa/sveltekit from source). |
| `static/audio/{voiceId}/*.mp3` | Pre-generated Vietnamese voice clips (190 + cho + kinh per voice). Generated by `scripts/generate-audio.py`. SW caches at runtime (CacheFirst). |
@@ -105,5 +103,5 @@ RootLayout
| `handleCellClick()` | PlayerBoard.svelte | Toggle crossed[row][col]. |
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
Last reviewed: 2026-04-27
Last synced: 2026-04-27 (UI polish v2 + PWA: font, mode picker, color picker, empty state, PWA stack)
Last reviewed: 2026-05-09
Last synced: 2026-05-09 (deploy target switched from Cloudflare Pages to GitHub Pages; CSP-hash machinery and `_headers` / `_redirects` removed)
+30 -68
View File
@@ -4,8 +4,8 @@
| Script | basePath | Target |
|---|---|---|
| `npm run build` | `""` (root) | Cloudflare Pages → `https://loto.miti99.com` |
| `npm run build:gh` | `/loto` | GitHub Pages → `https://tiennm99.github.io/loto` (manual fallback) |
| `npm run build` | `""` (root) | Local preview / generic static host |
| `npm run build:gh` | `/loto` | GitHub Pages → `https://tiennm99.github.io/loto` (canonical) |
Implementation: `svelte.config.js` reads `BUILD_PROFILE` env. Default is empty
basePath; `BUILD_PROFILE=gh npm run build` switches to `/loto`.
@@ -13,61 +13,21 @@ basePath; `BUILD_PROFILE=gh npm run build` switches to `/loto`.
Internal links use `import { base } from '$app/paths'` so they survive
either profile without code changes.
## Production Deployment — Cloudflare Pages
## Production Deployment — GitHub Pages
Primary deploy. Set up via the Cloudflare dashboard once; subsequent
pushes to `main` trigger automatic builds + deploys.
Canonical deploy. Wired via `.github/workflows/deploy-github-pages.yml`:
on push to `main`, runs `npm run build:gh`, uploads `build/` as the
GitHub Pages artifact, and deploys.
1. dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git
→ pick the repo
2. Build settings:
- Framework preset: SvelteKit
- Build command: `npm run build`
- Build output directory: `build`
- Production branch: `main`
3. After first deploy, add the custom domain:
Project → Custom domains → `loto.miti99.com`. Cloudflare gives DNS
records to add at your registrar (or auto-configures if `miti99.com` is
on Cloudflare DNS).
One-time setup (already done; documented for restoration):
1. Repo → Settings → Pages → Source: **GitHub Actions**.
2. Push to `main` triggers the workflow; the deploy job posts the
live URL on completion.
No GitHub Actions involved; no repo secrets needed.
URL: `https://tiennm99.github.io/loto/`
## GitHub Pages (redirect-only)
`.github/workflows/deploy-github-pages.yml` no longer builds the app.
It generates a tiny HTML page at `/loto/index.html` that immediately
redirects to the canonical URL on Cloudflare Pages, plus a copy at
`/loto/master/index.html` to catch legacy `/master` bookmarks (the app
itself is single-page now; Cloudflare's `_redirects` collapses unknown
paths back to `/`).
The redirect uses both `<meta http-equiv="refresh">` (no-JS fallback)
and a tiny inline script that preserves path / query / hash:
```js
location.replace("https://loto.miti99.com" + path + search + hash);
```
So `tiennm99.github.io/loto/``loto.miti99.com/` and any legacy
`tiennm99.github.io/loto/master` link → `loto.miti99.com/` (after the
intermediate `/master` redirect is collapsed by `_redirects`).
The redirect runs on every push to `main`. If you want full GH Pages
serving back (instead of the redirect), restore the prior version of
`.github/workflows/deploy-github-pages.yml` from git history — it ran
`npm run build:gh` and uploaded the `build/` artifact.
### Manual GH Pages Build (still available)
If you ever want to build a real GH Pages export by hand:
```bash
npm run build:gh
# upload build/ to the GH Pages target manually
```
`build:gh` script kept as an escape hatch — not used by the automated
redirect workflow.
No external secrets; the workflow uses GitHub's built-in `pages` and
`id-token` permissions (declared in the workflow YAML).
## Development Environment
@@ -129,11 +89,11 @@ This is normal in proxy environments.
### Build Command
```bash
npm run build
npm run build:gh
```
Generates:
- `build/` — Complete static HTML + JS export
- `build/` — Complete static HTML + JS export with `/loto` basePath
- `.svelte-kit/` — Build cache (not needed for deployment)
### Export Settings
@@ -142,9 +102,9 @@ Generates:
- All pages pre-rendered to HTML + JS bundles
### Asset Hosting
- `base` path matches deployment target (prod: `""`, GH: `/loto`, codeserver: `/absproxy/{port}`)
- `base` path matches deployment target (GH: `/loto`, root for local preview, codeserver: `/absproxy/{port}`)
- CSS, JS, fonts all prefixed correctly
- GitHub Pages serves from repository root, so `/loto` paths resolve correctly
- GitHub Pages serves the project at `/loto`, so `/loto/_app/*` paths resolve correctly
## Environment Variables
@@ -154,7 +114,9 @@ Generates:
- `CODESERVER_PORT` — port (default 3000)
### Build-Time
- `BUILD_PROFILE` — set to "gh" for GitHub Pages build (basePath `/loto`). Default empty (Cloudflare).
- `BUILD_PROFILE` — set to `gh` for GitHub Pages build (basePath `/loto`). The
deploy workflow sets this via `npm run build:gh`. Default empty (root
basePath) is for local preview / non-GH static hosts.
### Not Used at Runtime
- No database URL, API keys, or secrets (all client-side, localStorage)
@@ -164,7 +126,7 @@ Generates:
| Issue | Cause | Fix |
|-------|-------|-----|
| 404 on subpages after deploy | basePath mismatch | Verify `BUILD_PROFILE=gh` for GitHub Pages; default for Cloudflare |
| 404 on assets after deploy | basePath mismatch | Workflow runs `npm run build:gh` — check the deploy job log emits `/loto/_app/...` URLs |
| HMR not connecting (code-server) | CODESERVER_HOST not set | Add `CODESERVER_HOST=...` to `.env.local` |
| Assets 404 (code-server) | Wrong proxy URL | Use `/absproxy/{port}`, not `/proxy/{port}` |
| Page blank after refresh | State not persisted | Check browser localStorage is enabled |
@@ -172,12 +134,12 @@ Generates:
## CI/CD Pipeline
Two pipelines run on push to `main`:
- **Cloudflare Pages** (canonical) — wired via the CF dashboard, builds with
`npm run build`, publishes to `loto.miti99.com`.
- **GitHub Pages** (redirect-only) — wired via
`.github/workflows/deploy-github-pages.yml`, deploys static redirect HTML
that forwards `tiennm99.github.io/loto/*` to `loto.miti99.com/*`.
Two workflows on `main`:
- **`.github/workflows/deploy-github-pages.yml`** canonical deploy. Builds
with `npm run build:gh` and publishes `build/` to GitHub Pages.
- **`.github/workflows/verify-build.yml`** — PR + push gate. Runs
`npm test && npm run build` to catch regressions before they reach the
deploy job.
## Performance Checklist
@@ -185,7 +147,7 @@ Two pipelines run on push to `main`:
- [x] Tailwind 4 purged for production size
- [x] localStorage reduces bundle—no API calls
- [x] Images minimal (mostly CSS gradients + emojis)
- [x] Fonts: Geist via Google Fonts CDN
- [x] Fonts: Roboto Condensed self-hosted via @fontsource
Bundle analysis: Run `npm run build && ls -lh build/` to inspect file sizes.
@@ -195,6 +157,6 @@ Bundle analysis: Run `npm run build && ls -lh build/` to inspect file sizes.
- `.env.local` is local-only, not committed
- localStorage scoped to origin
- No external API calls (offline-capable)
- GitHub Pages HTTPS by default
- GitHub Pages serves HTTPS by default
Last reviewed: 2026-04-26
Last reviewed: 2026-05-09
+1 -1
View File
@@ -80,7 +80,7 @@ European Bingo 90 patterns. **Out of scope** — Tân Tân uses single-line "Kin
## Decision Rationale
All decisions follow **YAGNI** (You Aren't Gonna Need It), **KISS** (Keep It Simple), **DRY** (Don't Repeat Yourself):
- No multiplayer sync → adds server dependency, breaks static export (Cloudflare Pages)
- No multiplayer sync → adds server dependency, breaks static export (GitHub Pages)
- No i18n → Vietnamese-only community, localizing adds complexity
- Unit tests implemented → critical paths (constraints, persistence, settings) now have automated coverage
- Component/E2E tests deferred → small codebase, manual testing sufficient for UI flows
+5 -5
View File
@@ -73,7 +73,7 @@ That format is intentionally out of scope.
- **Styling**: Tailwind CSS 4 (utility-first, animations)
- **Persistence**: localStorage (no backend)
- **Audio**: pre-generated MP3 clips bundled under `static/audio/{voiceId}/`. Built once by `scripts/generate-audio.py` using free `edge-tts` (Microsoft Neural). Runtime plays via plain HTML5 Audio — no TTS API at runtime.
- **Deploy**: Cloudflare Pages (root domain), GitHub Pages fallback (`/loto`)
- **Deploy**: GitHub Pages at `/loto` (canonical, via GitHub Actions)
- **Dev Profile**: code-server compatible via `/absproxy/{port}` basePath + HMR proxy config
## Architecture Overview
@@ -86,9 +86,9 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
## Deployment
- **Production**: Cloudflare Pages at `loto.miti99.com` (canonical, CF dashboard, root basePath). GitHub Pages serves only a redirect to the canonical URL via `.github/workflows/deploy-github-pages.yml`.
- **Production**: GitHub Pages at `https://tiennm99.github.io/loto/` (canonical, basePath `/loto`). Deployed by `.github/workflows/deploy-github-pages.yml` on push to `main`.
- **Development**: `npm run dev` (local), `npm run dev:codeserver` (code-server via proxy).
- **Build**: `npm run build` generates static export to `build/` directory.
- **Build**: `npm run build:gh` generates static export to `build/` directory with the `/loto` basePath.
## Key Acceptance Criteria
@@ -143,5 +143,5 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
- Multiplayer sync (real-time via WebSocket)
- i18n beyond Vietnamese
Last reviewed: 2026-04-27 (scope locked: Lô tô hội chợ Tân Tân + theme/master/auto-call)
Last synced: 2026-04-27 (6-phase refactor)
Last reviewed: 2026-05-09 (scope locked: Lô tô hội chợ Tân Tân + theme/master/auto-call)
Last synced: 2026-05-09 (deploy target switched to GitHub Pages)
+5 -12
View File
@@ -68,20 +68,13 @@ All keys are JSON stringified. Corruption is silent (returns null).
## basePath & Asset Resolution
### Production (Cloudflare Pages, default)
```
npm run build
basePath="" (root)
Output: build/index.html
Deploy: loto.miti99.com (root domain)
```
### Production (GitHub Pages, manual)
### Production (GitHub Pages, canonical)
```
npm run build:gh
basePath="/loto"
Output: build/index.html with /loto prefix
Deploy: https://tiennm99.github.io/loto
Deploy: https://tiennm99.github.io/loto/
Workflow: .github/workflows/deploy-github-pages.yml
```
### Development (Local)
@@ -199,5 +192,5 @@ Each draw creates a new object (even repeat numbers) to ensure reactive re-fire.
3. User installs → app appears on home screen, launches in standalone mode
4. Updates: SW checks for new version; if found, toast appears; user can reload
Last reviewed: 2026-04-27
Last synced: 2026-04-27 (UI polish v2 + PWA: font, mode picker, color picker, empty state, PWA stack)
Last reviewed: 2026-05-09
Last synced: 2026-05-09 (Cloudflare Pages removed; GitHub Pages is the sole production target)
+3 -4
View File
@@ -6,13 +6,12 @@
"scripts": {
"dev": "vite dev",
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
"build": "vite build && node scripts/inject-csp-hashes.mjs",
"build:gh": "BUILD_PROFILE=gh vite build && node scripts/inject-csp-hashes.mjs",
"build": "vite build",
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"verify:build": "node scripts/verify-build-inline-scripts.mjs"
"test:watch": "vitest"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.32.0"
@@ -0,0 +1,88 @@
---
phase: 1
title: Wire GH Pages build into CI
status: completed
priority: P2
effort: 1h
dependencies: []
---
# Phase 1: Wire GH Pages build into CI
## Overview
Replace the redirect-only `deploy-github-pages.yml` with a real build+deploy
pipeline that runs `npm run build:gh` (basePath `/loto`) and uploads `build/`
as the GH Pages artifact. Site lives at `https://tiennm99.github.io/loto/`.
## Requirements
- Functional: push to `main` builds and deploys the SvelteKit app to GH Pages.
- Non-functional: workflow uses `actions/configure-pages@v5`, `upload-pages-artifact@v3`, `deploy-pages@v4` (already present). Concurrency group `github-pages`. Caches npm.
## Architecture
Single workflow, two jobs (build → deploy). Build job runs Node 20, `npm ci`,
`npm run build:gh`, uploads `build/`. Deploy job consumes the artifact.
`build:gh` already exists in `package.json` and produces basePath `/loto` via
`BUILD_PROFILE=gh` in `svelte.config.js:23`. CSP-hash injection step in that
script (`node scripts/inject-csp-hashes.mjs`) gets removed in Phase 2 — for
this phase we leave it; the script no-ops cleanly if `_headers` is absent
after Phase 2 lands (will be revisited).
Note: Phase 1 + 2 should land in the same PR so the build script and the
files it touches stay consistent.
## Related Code Files
- Modify: `.github/workflows/deploy-github-pages.yml`
- Read for context: `package.json`, `svelte.config.js`, `.github/workflows/verify-build.yml`
## Implementation Steps
1. Rewrite `.github/workflows/deploy-github-pages.yml`:
- Replace the `Generate redirect pages` step block with a real build:
```yaml
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build:gh
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: build
```
- Keep deploy job as-is (`actions/deploy-pages@v4`, environment
`github-pages`).
- Rename workflow: `name: Deploy redirect to GitHub Pages` →
`name: Deploy to GitHub Pages`.
2. After PR merges and the first run goes green:
- GitHub repo → Settings → Pages → Source: GitHub Actions (should already
be set; confirm).
- Confirm `https://tiennm99.github.io/loto/` loads the app, not the old
redirect HTML.
## Success Criteria
- [ ] `deploy-github-pages.yml` runs `npm run build:gh` and uploads `build/`.
- [ ] First post-merge run on `main` succeeds (both build + deploy jobs green).
- [ ] `https://tiennm99.github.io/loto/` serves the live app.
- [ ] Service worker registers at `/loto/sw.js`; manifest at
`/loto/manifest.webmanifest`; icons at `/loto/icons/...` resolve.
- [ ] Audio clips load from `/loto/audio/{voice}/{n}.mp3`.
## Risk Assessment
- **Risk:** basePath mismatch causes 404s on assets.
**Mitigation:** `build:gh` already wires basePath `/loto`; `import { base } from '$app/paths'` is used internally per `docs/deployment-guide.md:12`. Verify in Phase 1 success-criteria checks.
- **Risk:** Stale CF cache or DNS still points users to old `loto.miti99.com`.
**Mitigation:** Out of scope for this plan; documented as post-merge manual step in `plan.md`.
- **Risk:** Service worker from previous CF deploy lingers in user browsers and
serves stale paths. **Mitigation:** SW uses `registerType: "autoUpdate"`
(`vite.config.js:42`). Users on `loto.miti99.com` won't see the new
deployment anyway since URL changed; users on `tiennm99.github.io/loto` had
only the redirect HTML before, no SW registered.
@@ -0,0 +1,87 @@
---
phase: 2
title: Remove Cloudflare artifacts
status: completed
priority: P2
effort: 1h
dependencies:
- 1
---
# Phase 2: Remove Cloudflare artifacts
## Overview
Delete CF-only files (`wrangler.toml`, `static/_headers`, `static/_redirects`)
and the CSP-hash machinery that exists solely to patch `_headers`. GH Pages
ignores these files (or wouldn't have them) and the user opted to keep it
simple for a static site.
## Requirements
- Functional: `npm run build` and `npm run build:gh` produce a deployable
`build/` without invoking CSP-hash injection or relying on `_headers` /
`_redirects`.
- Non-functional: no dead scripts in `package.json`; no orphan files in
`static/` or repo root.
## Architecture
The chain `vite build → inject-csp-hashes.mjs → verify-build-inline-scripts.mjs`
exists only because CF Pages reads `static/_headers` and we wanted to ship a
strict CSP without `'unsafe-inline'`. None of that survives the move:
- GH Pages can't set HTTP headers from a `_headers` file.
- User accepted dropping CSP/security-headers machinery.
So the simplification is:
- `npm run build``vite build` (no postbuild step).
- `npm run build:gh``BUILD_PROFILE=gh vite build` (no postbuild step).
- Delete `verify:build` script entry; CI step that called it gets removed.
## Related Code Files
- Delete: `wrangler.toml`
- Delete: `static/_headers`
- Delete: `static/_redirects`
- Delete: `scripts/inject-csp-hashes.mjs`
- Delete: `scripts/verify-build-inline-scripts.mjs`
- Modify: `package.json` (drop CSP postbuild from `build` and `build:gh`; drop `verify:build` script)
- Modify: `.github/workflows/verify-build.yml` (drop `npm run verify:build` step)
## Implementation Steps
1. Delete `wrangler.toml` (CF-only manifest).
2. Delete `static/_headers` and `static/_redirects` (CF-only routing/headers).
3. Delete `scripts/inject-csp-hashes.mjs` and `scripts/verify-build-inline-scripts.mjs`.
4. Edit `package.json` scripts:
- `"build": "vite build && node scripts/inject-csp-hashes.mjs"`
`"build": "vite build"`
- `"build:gh": "BUILD_PROFILE=gh vite build && node scripts/inject-csp-hashes.mjs"`
`"build:gh": "BUILD_PROFILE=gh vite build"`
- Remove the entire `"verify:build": "node scripts/verify-build-inline-scripts.mjs"` line.
5. Edit `.github/workflows/verify-build.yml`:
- Remove the trailing `- run: npm run verify:build` step.
6. Run locally to confirm:
- `npm run build` exits 0, produces `build/index.html` and assets.
- `npm run build:gh` exits 0, produces `build/` with basePath `/loto`
visible in the rendered HTML (`grep -q '/loto/_app/' build/index.html`).
- `npm test` still passes (no test should reference `_headers`/`_redirects`).
## Success Criteria
- [ ] Deleted files no longer present (`git status` shows them as deletions).
- [ ] `npm run build` and `npm run build:gh` both succeed locally.
- [ ] `package.json` has no reference to `inject-csp-hashes` or `verify:build`.
- [ ] `verify-build.yml` does not invoke `npm run verify:build`.
- [ ] CI `Verify build` workflow stays green on PR.
## Risk Assessment
- **Risk:** Some test or doc depends on `static/_headers` content.
**Mitigation:** Phase 3 sweeps docs. Tests under `src/` don't reference
these files; sanity-check with `grep -r '_headers\|_redirects\|wrangler' src/ tests/ 2>/dev/null` before merging.
- **Risk:** `inject-csp-hashes.mjs` referenced from somewhere besides
`package.json` (e.g. a husky hook, a doc snippet someone copy-pastes).
**Mitigation:** `grep -r 'inject-csp-hashes\|verify-build-inline-scripts' .`
before deletion to confirm only `package.json` references them.
@@ -0,0 +1,99 @@
---
phase: 3
title: Update docs and TODO
status: completed
priority: P2
effort: 1h
dependencies:
- 1
- 2
---
# Phase 3: Update docs and TODO
## Overview
Sweep all docs and the residual TODO list to remove CF references and
describe GH Pages as the sole deploy target. Update the README build snippet
since `build:gh` becomes the canonical build (or `build` stays as the GH
build — we keep both scripts for now since they're aliased to the same
output via env).
## Requirements
- Functional: docs accurately describe the new deploy flow.
- Non-functional: no stale `loto.miti99.com` references except where
intentional (e.g. PageFooter's `miti99.com` is the author site, not the
deploy URL — leave alone).
## Related Code Files
- Modify: `README.md`
- Modify: `docs/deployment-guide.md` (heaviest rewrite — currently CF-centric)
- Modify: `docs/codebase-summary.md`
- Modify: `docs/system-architecture.md`
- Modify: `docs/code-standards.md`
- Modify: `docs/development-roadmap.md`
- Modify: `docs/project-overview-pdr.md`
- Modify: `plans/todo.md` (drop CF Lighthouse entries; keep GH Pages ones)
- Read for context: `src/lib/PageFooter.svelte` (no change — `miti99.com` link is unrelated)
## Implementation Steps
1. **`README.md`**
- Replace the Build section's two-script table with a single `npm run build:gh`
line OR keep both but mark `build:gh` as the deployed one.
- Replace `Deployed to Cloudflare Pages from main (set up via the CF
dashboard — see docs/deployment-guide.md).` with: `Deployed to GitHub
Pages from main via .github/workflows/deploy-github-pages.yml — see
docs/deployment-guide.md.`
2. **`docs/deployment-guide.md`** — substantial rewrite:
- Build Profiles table: drop CF row, keep GH Pages row as the only target.
- Replace "Production Deployment — Cloudflare Pages" section with
"Production Deployment — GitHub Pages": describe the workflow, GH repo
Settings → Pages → Source: GitHub Actions, URL `https://tiennm99.github.io/loto/`.
- Delete "GitHub Pages (redirect-only)" subsection.
- Delete "Manual GH Pages Build (still available)" subsection (the build
IS the canonical build now).
- "Build & Output" section: remove mention of `_headers`/`_redirects` and
CSP injection.
- "Environment Variables → Build-Time": `BUILD_PROFILE=gh` is now the
default for the deploy workflow; document it as such.
- "CI/CD Pipeline" section: drop the Cloudflare bullet; keep only GH Pages.
- "Security Considerations": drop CSP/headers bullets that no longer apply;
a one-liner that GH Pages serves HTTPS by default is enough.
- "Troubleshooting" table: drop the `BUILD_PROFILE` row that mentions
Cloudflare; reword the basePath row for `/loto` only.
- Update "Last reviewed" date to 2026-05-09.
3. **`docs/codebase-summary.md`** — find Cloudflare/CF/wrangler/_headers
mentions, replace with GH Pages descriptions or remove.
4. **`docs/system-architecture.md`** — same sweep; if it has a deployment
diagram or section, replace CF box with GH Pages.
5. **`docs/code-standards.md`** — likely just a passing CF mention; replace
or remove. If it references `inject-csp-hashes.mjs`, drop that.
6. **`docs/development-roadmap.md`** — replace CF references with GH Pages.
7. **`docs/project-overview-pdr.md`** — replace CF references with GH Pages.
8. **`plans/todo.md`** — under "PWA install verification":
- Delete "Lighthouse — Cloudflare Pages (root base)" subsection entirely.
- Keep "Lighthouse — GitHub Pages (`/loto/` base)" as the sole production
check.
- Delete the "CSP + headers (production)" subsection (no longer applicable).
- In "Common gotchas", remove the CSP / `_headers` references.
- Drop the "CSP hash brittleness" entry under "Tech debt".
## Success Criteria
- [ ] `grep -ri 'cloudflare\|wrangler\|_headers\|_redirects\|loto\.miti99\.com' docs/ README.md plans/todo.md` returns nothing (or only intentional leftovers documented in this plan).
- [ ] `docs/deployment-guide.md` describes only GH Pages.
- [ ] `plans/todo.md` no longer has CF-specific Lighthouse / CSP entries.
- [ ] `docs/deployment-guide.md` "Last reviewed" updated.
## Risk Assessment
- **Risk:** Doc sweeps miss a reference and downstream readers get confused.
**Mitigation:** The grep success-criterion is the safety net.
- **Risk:** `docs/code-standards.md` or `docs/system-architecture.md` describe
the CSP hash injection as a code-standard. Removing without reading
context could leave a dangling concept (e.g. "we ship strict CSP" claims).
**Mitigation:** Read each doc fully before editing; rewrite affected
paragraphs rather than deleting sentences mid-thought.
@@ -0,0 +1,41 @@
---
title: Switch deploy target from Cloudflare Pages to GitHub Pages
description: >-
Make GitHub Pages the canonical deploy at tiennm99.github.io/loto. Drop CF
Pages, _headers, _redirects, CSP-hash injection. Keep it simple — static site,
no security-headers machinery.
status: completed
priority: P2
created: 2026-05-09T00:00:00.000Z
---
# Switch deploy target from Cloudflare Pages to GitHub Pages
## Overview
Today CF Pages is canonical (`loto.miti99.com`) and GH Pages serves a redirect HTML
to it. Flip that: make GH Pages do a real build of `npm run build:gh` (basePath
`/loto`) and serve the app at `https://tiennm99.github.io/loto/`. Remove CF
artifacts (`wrangler.toml`, `static/_headers`, `static/_redirects`, CSP-hash
injection scripts) since GH Pages can't honor them and the user opted to keep
it simple for a static site.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Wire GH Pages build into CI](./phase-01-wire-gh-pages-build-into-ci.md) | Completed |
| 2 | [Remove Cloudflare artifacts](./phase-02-remove-cloudflare-artifacts.md) | Completed |
| 3 | [Update docs and TODO](./phase-03-update-docs-and-todo.md) | Completed |
## Dependencies
None. Sequential within plan: phase 1 → 2 → 3 (CI must work before docs declare
the new flow). Phase 2 can land in same PR as phase 1 since they touch
different files.
## Post-merge manual step
Disable the Cloudflare Pages project from the CF dashboard so it stops
auto-building from `main`. Optionally remove the `loto.miti99.com` DNS record
or repoint it (out of scope for this plan).
+5 -19
View File
@@ -7,16 +7,13 @@ folders have been deleted; residual / new items live here directly.
### PWA install verification (manual, post-deploy)
Needs production deploy on Cloudflare Pages + physical Android
Chrome + iOS Safari. No code; verification only.
**Lighthouse — Cloudflare Pages (root base)**
- Open `https://loto.miti99.com/` in incognito Chrome.
- DevTools → Lighthouse → PWA + Perf + Best Practices + a11y.
- PWA score = 100. No CSP violations. No mixed-content warnings.
Needs production deploy on GitHub Pages + physical Android Chrome +
iOS Safari. No code; verification only.
**Lighthouse — GitHub Pages (`/loto/` base)**
- Open `https://tiennm99.github.io/loto/` in incognito.
- Open `https://tiennm99.github.io/loto/` in incognito Chrome.
- DevTools → Lighthouse → PWA + Perf + Best Practices + a11y.
- PWA score = 100. No mixed-content warnings.
- Confirm SW URL `/loto/sw.js`, manifest `/loto/manifest.webmanifest`,
icons `/loto/icons/...` resolve.
@@ -37,12 +34,6 @@ Chrome + iOS Safari. No code; verification only.
- Launch standalone, fonts legible under translucent status bar.
- Airplane mode → app shell + default voice play.
**CSP + headers (production)**
- `curl -I https://loto.miti99.com/` shows `Content-Security-Policy`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy`,
`Permissions-Policy`, `X-Frame-Options: DENY`.
- CSP `script-src` no longer contains `'unsafe-inline'`.
**Common gotchas**
- Manifest paths break under `/loto/` base → check `vite.config.js`
PWA `manifest: false` + `app.html` uses `%sveltekit.assets%`.
@@ -62,11 +53,6 @@ Chrome + iOS Safari. No code; verification only.
Remove the `overrides` block in `package.json` once
`@sveltejs/kit` and `workbox-build` ship releases that pull
`cookie >= 0.7.0` and `serialize-javascript >= 7.0.5` upstream.
- **CSP hash brittleness.** `inject-csp-hashes.mjs` regenerates the
SvelteKit-bootstrap hash per build. If the bootstrap changes
format (e.g. SvelteKit moves to script-src-elem with nonce), the
marker `script-src 'self' 'unsafe-inline'` won't be present and
the script will exit 1. Watch for that on SvelteKit major bumps.
- **Voice list growth.** If we add voices > 2 (esp. > 10), revisit
the precache strategy — currently we precache only the default
voice. The 7d runtime cache covers the rest, but cold-start cost
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env node
/**
* Postbuild step: replace `'unsafe-inline'` in build/_headers script-src
* with the SHA-256 hash(es) of every inline <script> in
* build/index.html. Hash changes per build are expected (the
* SvelteKit bootstrap embeds a timestamped registration call), so
* this script must run on every build.
*
* If no inline scripts are present (future SvelteKit could go
* src-only), the script removes `'unsafe-inline'` entirely so the
* tightest possible CSP ships.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const HEADERS = "build/_headers";
const HTML = "build/index.html";
const MARKER = `script-src 'self' 'unsafe-inline'`;
const html = readFileSync(HTML, "utf8");
const inlineScripts = [
...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g),
];
const hashes = inlineScripts.map((m) => {
const body = m[1];
const digest = createHash("sha256").update(body, "utf8").digest("base64");
return `'sha256-${digest}'`;
});
const replacement =
hashes.length > 0
? `script-src 'self' ${hashes.join(" ")}`
: `script-src 'self'`;
const headers = readFileSync(HEADERS, "utf8");
if (!headers.includes(MARKER)) {
console.error(
`inject-csp-hashes: marker not found in ${HEADERS}.\nLooking for: ${MARKER}\n` +
`Either the previous build already replaced it (re-run \`npm run build\` from clean) ` +
`or static/_headers no longer contains the relaxed script-src directive.`,
);
process.exit(1);
}
writeFileSync(HEADERS, headers.replace(MARKER, replacement), "utf8");
console.log(
`inject-csp-hashes: replaced 'unsafe-inline' with ${hashes.length} hash(es) in ${HEADERS}.`,
);
@@ -1,66 +0,0 @@
#!/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";
const HEADERS_PATH = "build/_headers";
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.`,
);
}
// Post-Phase 7: script-src must NOT contain 'unsafe-inline' anymore —
// `inject-csp-hashes` should have replaced it with sha256 hashes.
let headers;
try {
headers = readFileSync(HEADERS_PATH, "utf8");
} catch {
console.warn(`verify-build: ${HEADERS_PATH} not found — skipping CSP check.`);
process.exit(0);
}
const scriptSrcLine = headers
.split("\n")
.find((l) => /script-src\b/.test(l) && /Content-Security-Policy/i.test(l));
// Single-line policy: Content-Security-Policy: ... script-src 'self' …
if (scriptSrcLine && /script-src[^;]*'unsafe-inline'/.test(scriptSrcLine)) {
console.error(
`verify-build: ${HEADERS_PATH} script-src still contains 'unsafe-inline'. ` +
`inject-csp-hashes.mjs should have replaced it with SHA-256 hash(es).`,
);
process.exit(1);
}
console.log(`verify-build: ${inline} inline <script> tag(s), CSP hashed — OK.`);
-14
View File
@@ -1,14 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; manifest-src 'self'; worker-src 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
X-Frame-Options: DENY
# Service worker must always re-validate so fresh deploys propagate.
/sw.js
Cache-Control: no-cache
/manifest.webmanifest
Cache-Control: no-cache
Content-Type: application/manifest+json
-10
View File
@@ -1,10 +0,0 @@
# Cloudflare Pages routing rules.
#
# 1. /master* → 301 redirect to / (handles old bookmarks from when the
# host had its own page; the URL bar updates to /).
# 2. /* → 200 rewrite to /index.html (SPA fallback for any other unknown
# path: keeps the URL but serves the homepage content). Static assets
# take precedence over rules, so /, /_app/*, /favicon.ico, etc. are
# served directly without rewrite — no redirect loop on /.
/master* / 301
/* /index.html 200
-3
View File
@@ -1,3 +0,0 @@
name = "loto"
pages_build_output_dir = "build"
compatibility_date = "2026-04-26"