From f51649b326f7d6d27eb66cd55c0639c0915ae44a Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sun, 28 Jun 2026 14:29:23 +0700 Subject: [PATCH] feat: prepare guarded framework Pages cutover --- .github/workflows/main.yml | 46 ++++++++++++++-- AGENTS.md | 18 ++++--- .../framework-routing-dual-build.md | 24 +++++---- docs/dev-net-core-seo-plan-handoff.md | 53 ++++++++++--------- scripts/validate-framework-build.mjs | 23 ++++++++ 5 files changed, 119 insertions(+), 45 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 29d4b01..79f1539 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,7 +1,23 @@ name: Deploy static content to Pages +run-name: Deploy ${{ inputs.deploy_target }} to GitHub Pages + on: workflow_dispatch: + inputs: + deploy_target: + description: Static output to deploy + required: true + default: framework + type: choice + options: + - framework + - spa-rollback + confirm_deploy: + description: I confirm this production deployment + required: true + default: false + type: boolean permissions: contents: read @@ -14,8 +30,8 @@ concurrency: jobs: deploy: - name: Validate and deploy current SPA - if: github.ref == 'refs/heads/main' + name: Validate and deploy selected static output + if: github.ref == 'refs/heads/main' && inputs.confirm_deploy == true timeout-minutes: 15 environment: name: github-pages @@ -69,8 +85,28 @@ jobs: - name: Validate framework candidate run: npm run validate:framework - - name: Create SPA fallback - run: cp dist/index.html dist/404.html + - name: Prepare deploy targets + shell: bash + run: | + mkdir -p rollback-spa + cp -R dist/. rollback-spa/ + cp dist/index.html rollback-spa/404.html + + if [[ "${{ inputs.deploy_target }}" == "framework" ]]; then + echo "PAGES_ARTIFACT_PATH=./build-framework/client" >> "$GITHUB_ENV" + else + echo "PAGES_ARTIFACT_PATH=./rollback-spa" >> "$GITHUB_ENV" + fi + + echo "Selected deploy target: ${{ inputs.deploy_target }}" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload seven-day SPA rollback snapshot + uses: actions/upload-artifact@v4 + with: + name: spa-rollback-${{ github.sha }}-${{ github.run_attempt }} + path: './rollback-spa' + if-no-files-found: error + retention-days: 7 - name: Setup Pages uses: actions/configure-pages@v5 @@ -78,7 +114,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: - path: './dist' + path: ${{ env.PAGES_ARTIFACT_PATH }} - name: Deploy to GitHub Pages id: deployment diff --git a/AGENTS.md b/AGENTS.md index 1184a3e..60fa1da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,9 +82,9 @@ The project currently uses a dual-build cutover. `npm run build` writes the deployable SPA to `dist/`. `npm run build:framework` writes the React Router static/pre-render candidate to `build-framework/client/`. `npm run finalize:framework` writes the candidate sitemap, candidate -`robots.txt`, and dedicated `404.html` into that client output. Do not switch -the GitHub Pages deployment artifact until the framework route matrix, fallback -handling, and production URLs pass their dedicated cutover validation. +`robots.txt`, and dedicated `404.html` into that client output. Production +remains on the SPA until an explicitly approved manual framework deployment +passes the dedicated cutover validation. GitHub Actions intentionally separates validation from deployment: @@ -92,11 +92,15 @@ GitHub Actions intentionally separates validation from deployment: gate for pull requests and pushes to `main`, but has read-only permissions and does not upload or deploy a Pages artifact. Its `Validate framework candidate` job is required on `main` through strict/up-to-date branch protection. -* `.github/workflows/main.yml` is manual-only, runs the same validation gate, - and continues to deploy the existing `dist/` SPA from `main`. +* `.github/workflows/main.yml` is manual-only, requires an explicit deployment + target and confirmation, and runs the same validation gate. `framework` + selects `build-framework/client/`; `spa-rollback` selects a deploy-ready copy + of `dist/`. +* Every confirmed manual deployment run uploads the deploy-ready SPA rollback + snapshot as a separate artifact retained for seven days. * Branch protection does not require review approvals and still permits - administrator bypass. Do not change that policy or switch the deployed - directory to `build-framework/client/` without explicit approval. + administrator bypass. Do not change that policy or manually deploy either + target without explicit approval. ## Important Project Areas diff --git a/docs/architecture/framework-routing-dual-build.md b/docs/architecture/framework-routing-dual-build.md index 25606c5..6dc1cbe 100644 --- a/docs/architecture/framework-routing-dual-build.md +++ b/docs/architecture/framework-routing-dual-build.md @@ -5,13 +5,14 @@ changing the current GitHub Pages deployment artifact. ## Build Ownership -- `npm run build` remains the deployable Vite SPA build and writes `dist/`. +- `npm run build` remains the rollback-capable Vite SPA build and writes + `dist/`. - `npm run build:framework` builds the migration candidate and writes `build-framework/client/`. - React Router uses `ssr: false`; no request-time Node server is required. - The temporary framework server bundle is removed after pre-rendering. -- The manual deployment workflow continues to publish `dist/` until a separate - cutover task is approved. +- Production remains on the SPA until the guarded manual framework deployment + is explicitly approved and run. ## GitHub Actions Ownership @@ -23,8 +24,13 @@ changing the current GitHub Pages deployment artifact. runs confirmed timing and reliability. Branch protection requires the branch to be up to date, does not require review approvals, and permits administrator bypass. -- `.github/workflows/main.yml` is manual-only and restricted to `main`. It runs - the complete validation gate before publishing the existing `dist/` SPA. +- `.github/workflows/main.yml` is manual-only and restricted to `main`. It + requires both a `framework`/`spa-rollback` target and a confirmation checkbox, + then runs the complete validation gate before uploading the selected public + output. +- Every confirmed deployment run prepares a deploy-ready SPA fallback, uploads + an exact seven-day rollback snapshot, and keeps that legacy fallback isolated + from the framework output. - Candidate and deployment workflows use separate concurrency groups, so validation cannot cancel or replace a deployment. @@ -61,11 +67,11 @@ dedicated Not Found + `noindex` `404.html` into `build-framework/client/`. HTML, self-canonical metadata, Open Graph metadata, and the required route artifact. It parses the sitemap, validates the dedicated fallback, enforces output budgets, confirms that app-only routes were not emitted as static -sitemap pages, and confirms that the existing `dist/index.html` SPA artifact -still exists. +sitemap pages, confirms that the existing `dist/index.html` SPA artifact still +exists, and verifies the custom-domain `CNAME` in source and both build outputs. -The final hosting cutover still needs explicit GitHub Pages fallback/output -handling and production URL validation. `__spa-fallback.html` must not be +The final hosting cutover still needs an explicitly approved manual `framework` +deployment and production URL validation. `__spa-fallback.html` must not be counted as static output for any sitemap URL. ## Candidate Output Budgets diff --git a/docs/dev-net-core-seo-plan-handoff.md b/docs/dev-net-core-seo-plan-handoff.md index 562382a..921e41e 100644 --- a/docs/dev-net-core-seo-plan-handoff.md +++ b/docs/dev-net-core-seo-plan-handoff.md @@ -248,9 +248,10 @@ npm run build:framework -> React Router framework migration candidate ``` -The GitHub Pages workflow still publishes `dist/`. Do not switch it to -`build-framework/client/` until fallback handling, sitemap output, production -validation, and the full cutover gate are complete. +Production still serves the SPA. The guarded manual Pages workflow can select +either `build-framework/client/` for the framework cutover or a deploy-ready SPA +rollback copy, but no framework deployment is authorized until the local route +matrix and explicit deployment approval are complete. React Router creates `__spa-fallback.html` for app-only routes. GitHub Pages expects `404.html`, so `finalize:framework` adds Not Found metadata and writes @@ -483,6 +484,14 @@ The merged implementation completes the next readiness steps: - Four stable candidate runs completed in 30-32 seconds. `Validate framework candidate` is now required on `main` with strict/up-to-date branch protection, no review approval requirement, and administrator bypass retained. +- The manual Pages workflow now requires a `framework` or `spa-rollback` + selection plus explicit confirmation. +- Every confirmed manual run prepares a deploy-ready SPA fallback and uploads + an exact rollback snapshot retained for seven days. +- Framework validation now confirms `www.dev-net-core.com` in `public/CNAME`, + `dist/CNAME`, and `build-framework/client/CNAME`. +- Cloudflare configuration remains unchanged for cutover; the unrelated + Cloudflare Workers build integration was removed. ## Work Not Yet Completed @@ -512,10 +521,12 @@ The workflow split is now in place: - `.github/workflows/framework-candidate.yml` validates pull requests and pushes to `main` with read-only permissions and no deployment steps. - `.github/workflows/main.yml` runs only by manual dispatch from `main`, runs - the full gate, and still deploys `dist/`. + the full gate, requires explicit target selection and confirmation, uploads a + seven-day SPA rollback snapshot, and publishes only the selected public + output. - Both workflows record and enforce the 60-second framework build budget. -Before switching the deployed artifact: +Before the first framework deployment: 1. Build the existing SPA rollback artifact. 2. Build the framework candidate. @@ -523,7 +534,7 @@ Before switching the deployed artifact: 4. Generate sitemap and dedicated fallback output. 5. Confirm only public client output is uploaded. 6. Keep the old deployment path easy to restore. -7. Change the published directory only after the complete route matrix passes. +7. Select `framework` only after the complete route matrix passes. Do not deploy `build-framework/server`; React Router removes it under `ssr:false` anyway. @@ -576,12 +587,14 @@ Then: ## Recommended Next Implementation Order 1. Run the complete local route matrix again before cutover. -2. Perform the explicit GitHub Pages cutover only after user approval and in a - separate commit. -3. Validate production with headers, source HTML, and browser hydration. -4. Add custom metadata to the highest-value topics while temporary fallbacks +2. Merge the guarded cutover workflow only after the required candidate check + passes. +3. Perform the explicit manual GitHub Pages framework deployment only after + separate user approval. +4. Validate production with headers, source HTML, and browser hydration. +5. Add custom metadata to the highest-value topics while temporary fallbacks remain accepted for the rest. -5. Submit the sitemap only after the production gate passes. +6. Submit the sitemap only after the production gate passes. ## Safety Rules for the Next Agent @@ -604,20 +617,12 @@ Then: ## Git State at Handoff -Latest implementation commit: +The latest merged readiness commit on `main` is: ```txt -ea36f27 docs: add SEO implementation handoff +023d0f8 Merge pull request #3 from CircleQMinh/codex/require-framework-check ``` -Current working tree: - -```txt -Theme bootstrap, framework output budgets, finalization, and validation changes -are uncommitted. -?? docs/plans/dev-net-core-seo-improvement-plan.md -``` - -Verify `git status` before committing and keep the untracked SEO plan outside -the commit unless explicitly requested. Do not push or deploy the current work -without user approval. +Step 15 pre-cutover work is developed on +`codex/framework-pages-cutover`. Commit each completed implementation step +locally. Do not push, merge, revert, or deploy without user approval. diff --git a/scripts/validate-framework-build.mjs b/scripts/validate-framework-build.mjs index 37633fc..b8f8bdd 100644 --- a/scripts/validate-framework-build.mjs +++ b/scripts/validate-framework-build.mjs @@ -14,6 +14,21 @@ const frameworkFallbackPath = path.join(clientRoot, "__spa-fallback.html"); const notFoundPath = path.join(clientRoot, "404.html"); const robotsPath = path.join(clientRoot, "robots.txt"); const sitemapPath = path.join(clientRoot, "sitemap.xml"); +const expectedCname = new URL(SEO_SITE_ORIGIN).hostname; +const cnamePaths = [ + { + label: "public source", + path: path.join(repositoryRoot, "public", "CNAME"), + }, + { + label: "SPA output", + path: path.join(repositoryRoot, "dist", "CNAME"), + }, + { + label: "framework output", + path: path.join(clientRoot, "CNAME"), + }, +]; const manifest = JSON.parse( fs.readFileSync( path.join( @@ -43,6 +58,14 @@ assert.ok(fs.existsSync(spaIndexPath)); assert.ok(fs.existsSync(frameworkFallbackPath)); assert.ok(fs.existsSync(path.join(clientRoot, "theme-bootstrap.js"))); assert.ok(fs.existsSync(path.join(repositoryRoot, "dist", "theme-bootstrap.js"))); +for (const cname of cnamePaths) { + assert.ok(fs.existsSync(cname.path), `Missing CNAME in ${cname.label}.`); + assert.equal( + fs.readFileSync(cname.path, "utf8").trim(), + expectedCname, + `Incorrect CNAME in ${cname.label}.` + ); +} assert.ok( fs.readFileSync(spaIndexPath, "utf8").includes( ''