diff --git a/.github/workflows/deploy-frontend-pages.yml b/.github/workflows/deploy-frontend-pages.yml index d487d343..f2b2229d 100644 --- a/.github/workflows/deploy-frontend-pages.yml +++ b/.github/workflows/deploy-frontend-pages.yml @@ -7,6 +7,15 @@ on: types: - completed workflow_dispatch: + inputs: + channel: + description: Channel to deploy. Stable is an explicit promotion from trusted main. + required: true + default: dev + type: choice + options: + - dev + - stable env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -63,11 +72,54 @@ jobs: - name: Install run: npm ci - - name: Build + - name: Prepare Pages dist + run: rm -rf dist && mkdir -p dist + + - name: Build dev channel + env: + VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + VITE_PHASERFORGE_DEPLOY_CHANNEL: dev + VITE_E2E_TEST_BRIDGE: '1' + run: npm run build -- --outDir dist/dev + + - name: Build stable channel + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' env: VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + VITE_PHASERFORGE_DEPLOY_CHANNEL: stable VITE_E2E_TEST_BRIDGE: '1' - run: npm run build + run: | + npm run build -- --outDir dist/stable + node -e "const fs=require('fs'); fs.writeFileSync('dist/stable/version.json', JSON.stringify({ channel: 'stable', commit: process.env.GITHUB_SHA, promotedAt: new Date().toISOString() }, null, 2) + '\n')" + + - name: Preserve current stable channel + if: github.event_name != 'workflow_dispatch' || inputs.channel != 'stable' + run: | + set -euo pipefail + pages_root_url="https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/" + pages_stable_url="${pages_root_url}stable/" + mkdir -p dist/stable + fetch_pages_path() { + local url="$1" + local target="$2" + wget \ + --recursive \ + --level=2 \ + --page-requisites \ + --no-parent \ + --no-host-directories \ + --cut-dirs=1 \ + --directory-prefix="$target" \ + "$url" + } + fetch_pages_path "$pages_stable_url" dist || true + if [ ! -f dist/stable/index.html ]; then + echo "No existing /stable/ channel found; seeding stable from the currently published root editor." + mkdir -p /tmp/phaserforge-pages-root + fetch_pages_path "$pages_root_url" /tmp/phaserforge-pages-root + cp -a /tmp/phaserforge-pages-root/. dist/stable/ + fi + test -f dist/stable/index.html - name: Build docs run: npm run docs:build @@ -75,6 +127,12 @@ jobs: - name: Stage docs under /docs run: npm run docs:stage + - name: Create root redirect + run: npm run pages:root + + - name: Verify Pages artifact shape + run: npm run pages:verify + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/.plans/stable-dev-github-pages-deployment-plan.md b/.plans/stable-dev-github-pages-deployment-plan.md new file mode 100644 index 00000000..42bf617e --- /dev/null +++ b/.plans/stable-dev-github-pages-deployment-plan.md @@ -0,0 +1,78 @@ +# Stable / Dev GitHub Pages Deployment Plan + +Status: implemented and locally verified. + +Current editor URL: + +- `https://bcorfman.github.io/phaserforge/` + +Target editor URLs: + +- Stable: `https://bcorfman.github.io/phaserforge/stable/` +- Dev: `https://bcorfman.github.io/phaserforge/dev/` +- Root: `https://bcorfman.github.io/phaserforge/` must not host an editor build. + +## Durable Rule + +The public editor should deploy only to explicit stable and development channels. `/stable/` serves the user-safe release build. `/dev/` can track active development. The root `/phaserforge/` must not host an editor build; it may only redirect to `/stable/` or show a minimal channel chooser. No additional preview, release, or per-branch URL scheme is required for this increment. + +## Non-Goals + +- Do not change the existing game publish method. +- Do not add per-PR preview deployments. +- Do not introduce a release archive beyond `/stable/` and `/dev/`. +- Do not require users to understand Git branches or build IDs. + +## Proposed Channel Semantics + +- `/dev/` updates from `main` after CI passes. +- `/stable/` updates only from an explicit stable promotion action. +- The root `/phaserforge/` should either redirect to `/stable/` or show a tiny channel chooser, with `/stable/` preferred as the default user-safe destination. It must not contain a runnable editor bundle. +- Both channel builds should use relative asset paths or channel-aware Vite base paths so direct reloads work under their subdirectories. +- Persistence should use a hybrid channel model: `/stable/` keeps the existing production persistence namespace so saves from the former root URL carry forward automatically, while `/dev/` uses a separate development namespace unless an explicit compatibility gate says sharing is safe. + +## Phase 1 — Deployment Inventory + +- [x] Inventory the current GitHub Pages workflow in `.github/workflows/deploy-frontend-pages.yml`. +- [x] Confirm how `dist/`, docs staging, and asset paths behave under nested `/stable/` and `/dev/` paths. +- [x] Confirm whether docs remain at `/docs/` or move under one editor channel. +- [x] Identify any hard-coded `/phaserforge/` or root-relative asset URLs that would break under channel subpaths. + +## Phase 2 — Build and Stage Channels + +- [x] Build the editor once for `/dev/` and stage it under `dist/dev/`. +- [x] Build or copy the stable editor artifact under `dist/stable/`. +- [x] Add a root `index.html` redirect or chooser pointing users to `/stable/`, without staging editor JS/CSS/assets at the root. +- [x] Preserve existing docs staging without interfering with `/stable/` and `/dev/`. + +## Phase 3 — Stable Promotion + +- [x] Add an explicit workflow dispatch input or separate workflow for promoting stable. +- [x] Decide whether stable is promoted from: + - the current `main` SHA after validation; + - a Git tag; + - a named stable branch. +- [x] Ensure stable promotion does not happen automatically on every `main` push. +- [x] Record the promoted commit SHA in the Pages artifact, a small `stable/version.json`, or workflow summary. + +## Phase 4 — Dev Deployment + +- [x] Keep automatic deployment from CI-passing `main` to `/dev/`. +- [x] Make `/dev/` visibly identify itself as the development channel somewhere low-friction, without adding a disruptive workflow. +- [x] Keep `/stable/` on the current production IndexedDB/localStorage namespace so existing root saves migrate by continuity. +- [x] Move `/dev/` local persistence to a separate development namespace, or require an explicit schema compatibility gate before `/dev/` can write the shared production namespace. +- [x] Ensure cloud persistence cannot let `/dev/` corrupt `/stable/` projects without clear schema compatibility rules. + +## Phase 5 — Verification + +- [x] Add a workflow-level smoke or artifact check that `dist/stable/index.html` and `dist/dev/index.html` exist. +- [x] Add an artifact check proving the root does not contain the editor bundle entrypoints. +- [x] Run a deployed-path smoke test locally, serving the staged `dist` and visiting `/stable/` and `/dev/`. +- [x] Confirm deep reloads work on both channel URLs. +- [x] Confirm published games still use the existing publish path unchanged. + +## Open Decisions + +- Stable promotion uses a manual workflow dispatch selecting the `stable` channel from trusted `main`. +- Root `/phaserforge/` redirects immediately to `/stable/`. +- Docs stay at `/docs/`. diff --git a/.repo-memory/product-memory.md b/.repo-memory/product-memory.md index 8de0cd70..87d95914 100644 --- a/.repo-memory/product-memory.md +++ b/.repo-memory/product-memory.md @@ -20,6 +20,7 @@ Compact durable rules that should survive refactors and feature work. - The latest valid IndexedDB-backed project head wins over stale cached snapshots or legacy localStorage state. - Restore sequencing is a product contract and must remain stable. - Project history should preserve user intent across reload/rebuild, not just restoreable state. +- The public editor should deploy only to explicit stable/development channels: `/stable/` serves the user-safe release, `/dev/` can track active development, and the root must not host an editor build. ### Serialization and project data diff --git a/package.json b/package.json index 787ff6fa..9986eda6 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "docs:screenshots:app": "node --import tsx scripts/docs/capture-docs-screenshots.mjs", "docs:screenshots": "npm run docs:screenshots:storybook && npm run docs:screenshots:app", "docs:stage": "node scripts/docs/stage-docs-for-pages.mjs", + "pages:root": "node scripts/pages/create-root-index.mjs", + "pages:verify": "node scripts/pages/verify-pages-artifact.mjs", "server:dev": "node --import tsx server/src/index.ts", "server:start": "node --import tsx server/src/index.ts", "prisma:generate": "prisma generate", diff --git a/scripts/pages/create-root-index.mjs b/scripts/pages/create-root-index.mjs new file mode 100644 index 00000000..eff61cdd --- /dev/null +++ b/scripts/pages/create-root-index.mjs @@ -0,0 +1,31 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); + +export function buildRootIndexHtml({ target = './stable/' } = {}) { + return ` + + + + + + PhaserForge + + +

Open PhaserForge stable

+ + +`; +} + +export async function writeRootIndex({ distDir = path.join(repoRoot, 'dist'), target = './stable/' } = {}) { + await mkdir(distDir, { recursive: true }); + await writeFile(path.join(distDir, 'index.html'), buildRootIndexHtml({ target }), 'utf8'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await writeRootIndex(); +} diff --git a/scripts/pages/verify-pages-artifact.mjs b/scripts/pages/verify-pages-artifact.mjs new file mode 100644 index 00000000..f9a99729 --- /dev/null +++ b/scripts/pages/verify-pages-artifact.mjs @@ -0,0 +1,46 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); + +async function exists(filePath) { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function assertFile(filePath, label) { + if (!(await exists(filePath))) { + throw new Error(`Missing ${label}: ${filePath}`); + } +} + +export async function verifyPagesArtifact(distDir = path.join(repoRoot, 'dist')) { + await assertFile(path.join(distDir, 'stable', 'index.html'), 'stable editor index'); + await assertFile(path.join(distDir, 'dev', 'index.html'), 'dev editor index'); + await assertFile(path.join(distDir, 'index.html'), 'root index'); + + const rootHtml = await readFile(path.join(distDir, 'index.html'), 'utf8'); + if (/]*\btype=["']module["']/i.test(rootHtml)) { + throw new Error('Root index must not include an editor module script'); + } + if (/\.\/assets\/|\/assets\//i.test(rootHtml)) { + throw new Error('Root index must not reference editor assets'); + } + + const rootEntries = await readdir(distDir); + if (rootEntries.includes('assets')) { + throw new Error('Root dist/assets exists; editor assets must live under channel directories'); + } + + return true; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await verifyPagesArtifact(process.argv[2] ? path.resolve(process.argv[2]) : undefined); +} diff --git a/src/app/layout.css b/src/app/layout.css index f0a7b551..2806c297 100644 --- a/src/app/layout.css +++ b/src/app/layout.css @@ -273,6 +273,11 @@ textarea:focus-visible { cursor: pointer; } +.toolbar-channel-badge { + background: rgba(42, 133, 122, 0.18); + color: #7be0d0; +} + .toolbar-sync-badge-offline, .badge-warn { background: rgba(171, 96, 26, 0.18); diff --git a/src/editor/CloudAccountPanel.tsx b/src/editor/CloudAccountPanel.tsx index 2c92390c..7df0ddc2 100644 --- a/src/editor/CloudAccountPanel.tsx +++ b/src/editor/CloudAccountPanel.tsx @@ -9,6 +9,7 @@ import { projectPersistence } from './projectPersistence'; import { WorkspaceConflictModal } from './WorkspaceConflictModal'; import { summarizeYamlWorkspace } from './workspaceSummary'; import { prepareProjectForCloudSave } from '../cloud/projectCloudAssets'; +import { isCloudPersistenceEnabledForChannel } from './deployChannel'; type CloudAccountUser = { id: string; email: string } | null; type CloudPublishInfo = { ok: true; login: string; pagesBaseUrl: string } | { ok: false; error: string }; @@ -201,6 +202,17 @@ export function CloudAccountPanel({ onStatus: (message: string) => void; onError: (message: string) => void; }) { + if (!isCloudPersistenceEnabledForChannel()) { + return ( +
+
+
CLOUD WORKSPACE SYNC
+
Cloud workspace sync is disabled on the development channel.
+
+
+ ); + } + const editorInitialized = state.initialized ?? true; const [csrfToken, setCsrfToken] = useState(null); const [user, setUser] = useState(cachedCloudAccountUser ?? null); diff --git a/src/editor/EditorStore.tsx b/src/editor/EditorStore.tsx index fc3478c0..a494fab3 100644 --- a/src/editor/EditorStore.tsx +++ b/src/editor/EditorStore.tsx @@ -71,13 +71,14 @@ import { } from './projectHistoryEvents'; import { appendPersistenceDebugEntry, summarizeProjectLoadForDebug, summarizeYamlForDebug } from '../util/persistenceDebug'; import { buildGroupTintVariation } from './tintVariation'; - -export const SCENE_STORAGE_KEY_V1 = 'phaserforge.sceneYaml.v1'; -export const SCENE_STORAGE_KEY = 'phaserforge.sceneYaml.v2'; -export const STARTUP_MODE_STORAGE_KEY = 'phaserforge.startupMode.v1'; -export const THEME_MODE_STORAGE_KEY = 'phaserforge.themeMode.v1'; -export const UI_SCALE_STORAGE_KEY = 'phaserforge.uiScale.v1'; -export const SHOW_HITBOX_OVERLAY_STORAGE_KEY = 'phaserforge.showHitboxOverlay.v1'; +import { getChannelScopedStorageKey, isCloudPersistenceEnabledForChannel } from './deployChannel'; + +export const SCENE_STORAGE_KEY_V1 = getChannelScopedStorageKey('phaserforge.sceneYaml.v1'); +export const SCENE_STORAGE_KEY = getChannelScopedStorageKey('phaserforge.sceneYaml.v2'); +export const STARTUP_MODE_STORAGE_KEY = getChannelScopedStorageKey('phaserforge.startupMode.v1'); +export const THEME_MODE_STORAGE_KEY = getChannelScopedStorageKey('phaserforge.themeMode.v1'); +export const UI_SCALE_STORAGE_KEY = getChannelScopedStorageKey('phaserforge.uiScale.v1'); +export const SHOW_HITBOX_OVERLAY_STORAGE_KEY = getChannelScopedStorageKey('phaserforge.showHitboxOverlay.v1'); export const DEFAULT_UI_SCALE = 0.95; export type ThemeMode = 'system' | 'light' | 'dark'; @@ -5049,7 +5050,13 @@ export function EditorProvider({ children }: { children: React.ReactNode }) { }); }, [state.initialized, state.showHitboxOverlay, state.startupMode, state.themeMode, state.uiScale]); + const cloudPersistenceEnabled = isCloudPersistenceEnabledForChannel(); + const refreshCloudProjects = async () => { + if (!cloudPersistenceEnabled) { + setCloudProjects([]); + return; + } try { await me(); const response = await listGames(); @@ -5087,7 +5094,7 @@ export function EditorProvider({ children }: { children: React.ReactNode }) { useEffect(() => { void refreshCloudProjects(); - }, []); + }, [cloudPersistenceEnabled]); const openProject = async (projectId: string) => { const local = await projectPersistence.loadProjectById(projectId); @@ -5115,6 +5122,8 @@ export function EditorProvider({ children }: { children: React.ReactNode }) { return; } + if (!cloudPersistenceEnabled) return; + const cloud = await getGame(projectId); if (!cloud?.game?.project) return; const cacheRecord = buildStoredProjectRecord(cloud.game.project, { @@ -5169,6 +5178,7 @@ export function EditorProvider({ children }: { children: React.ReactNode }) { }; const linkActiveProjectToCloudGame = async (gameId: string) => { + if (!cloudPersistenceEnabled) return; const current = activeRecordRef.current ?? buildStoredProjectRecord(state.project, { id: activeProjectId ?? state.project.id, }); diff --git a/src/editor/Toolbar.tsx b/src/editor/Toolbar.tsx index bd1fc068..52c8a73a 100644 --- a/src/editor/Toolbar.tsx +++ b/src/editor/Toolbar.tsx @@ -1,4 +1,5 @@ import { useEditorStore } from './EditorStore'; +import { resolveEditorDeployChannel } from './deployChannel'; type ToolbarViewProps = { state: Pick['state'], 'dirty' | 'uiScale' | 'themeMode' | 'error' | 'statusMessage' | 'syncMode'>; @@ -7,12 +8,16 @@ type ToolbarViewProps = { }; export function ToolbarView({ state, dispatch, onToggleSyncMode }: ToolbarViewProps) { + const deployChannel = resolveEditorDeployChannel(); return (

Browser Editor

PhaserForge

+ {deployChannel === 'dev' ? ( + Dev + ) : null}