Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -69,16 +85,36 @@ 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

- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: './dist'
path: ${{ env.PAGES_ARTIFACT_PATH }}

- name: Deploy to GitHub Pages
id: deployment
Expand Down
18 changes: 11 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,25 @@ 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:

* `.github/workflows/framework-candidate.yml` runs the dual-build validation
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

Expand Down
24 changes: 15 additions & 9 deletions docs/architecture/framework-routing-dual-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
53 changes: 29 additions & 24 deletions docs/dev-net-core-seo-plan-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -512,18 +521,20 @@ 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.
3. Run framework/static SEO validation.
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.
Expand Down Expand Up @@ -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

Expand All @@ -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.
23 changes: 23 additions & 0 deletions scripts/validate-framework-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
'<script src="/theme-bootstrap.js"></script>'
Expand Down