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
62 changes: 60 additions & 2 deletions .github/workflows/deploy-frontend-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,18 +72,67 @@ 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

- 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:
Expand Down
78 changes: 78 additions & 0 deletions .plans/stable-dev-github-pages-deployment-plan.md
Original file line number Diff line number Diff line change
@@ -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/`.
1 change: 1 addition & 0 deletions .repo-memory/product-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions scripts/pages/create-root-index.mjs
Original file line number Diff line number Diff line change
@@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="0; url=${target}" />
<title>PhaserForge</title>
</head>
<body>
<p><a href="${target}">Open PhaserForge stable</a></p>
</body>
</html>
`;
}

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();
}
46 changes: 46 additions & 0 deletions scripts/pages/verify-pages-artifact.mjs
Original file line number Diff line number Diff line change
@@ -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 (/<script\b[^>]*\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);
}
5 changes: 5 additions & 0 deletions src/app/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/editor/CloudAccountPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -201,6 +202,17 @@ export function CloudAccountPanel({
onStatus: (message: string) => void;
onError: (message: string) => void;
}) {
if (!isCloudPersistenceEnabledForChannel()) {
return (
<div className="cloud-panel" data-testid="cloud-panel-disabled">
<div className="cloud-section-card">
<div className="cloud-section-title">CLOUD WORKSPACE SYNC</div>
<div className="cloud-help">Cloud workspace sync is disabled on the development channel.</div>
</div>
</div>
);
}

const editorInitialized = state.initialized ?? true;
const [csrfToken, setCsrfToken] = useState<string | null>(null);
const [user, setUser] = useState<CloudAccountUser>(cachedCloudAccountUser ?? null);
Expand Down
26 changes: 18 additions & 8 deletions src/editor/EditorStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
});
Expand Down
5 changes: 5 additions & 0 deletions src/editor/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEditorStore } from './EditorStore';
import { resolveEditorDeployChannel } from './deployChannel';

type ToolbarViewProps = {
state: Pick<ReturnType<typeof useEditorStore>['state'], 'dirty' | 'uiScale' | 'themeMode' | 'error' | 'statusMessage' | 'syncMode'>;
Expand All @@ -7,12 +8,16 @@ type ToolbarViewProps = {
};

export function ToolbarView({ state, dispatch, onToggleSyncMode }: ToolbarViewProps) {
const deployChannel = resolveEditorDeployChannel();
return (
<header className="toolbar" data-testid="toolbar">
<div className="toolbar-left">
<p className="toolbar-kicker">Browser Editor</p>
<div className="toolbar-title-row">
<h1 className="brand">PhaserForge</h1>
{deployChannel === 'dev' ? (
<span className="badge toolbar-channel-badge" data-testid="deploy-channel-badge">Dev</span>
) : null}
<button
className={`badge toolbar-sync-badge ${state.syncMode === 'offline' ? 'toolbar-sync-badge-offline' : ''}`}
data-testid="project-sync-badge"
Expand Down
Loading
Loading