RELEASE
planning/2.8.0/plans/DEPLOY_STAGING_PLAN.md
sha256 9c1ea4cb0909b51e · 19230 bytes ·
original held in the private archive
# Deploy / Staging Separation — Investigation + Plan
> **LOCKED DECISIONS (owner, 2026-06-29):** 1) Option C — Cloudflare Pages staging. 2) Production deploys on version-bump (release-gated). 3) `v2.6.0` is the public baseline. 4) `main` = public/release, `dev` = working/unreleased (Cloudflare builds `dev`). 5) Yes — distinct `[DEV]` name + dev icon. 6) Sign in to staging via Google.
>
> **CUTOVER DECISION = (A):** at cutover, roll the live site back to the clean **`v2.6.0`** release so the already-public unreleased work comes off prod. HELD until the owner confirms staging works on their phone (Step 4). Access login = **Google** (keep Step-2 OAuth).
>
> **IMPLEMENTATION STATUS:**
> - ✅ `dev` branch created + pushed (`origin/dev`, tip `828d5f2`). Full gate passed.
> - ✅ `scripts/cf-staging-build.mjs` — Cloudflare build command (served-files only + `[DEV]` manifest + SVG dev icon). Verified locally.
> - ✅ `ci.yml` runs the gate on `dev` (deploy/release still guarded to `main`).
> - ✅ Protocol 43 (Dev-Branch Workflow / Release Gating) added to RULES.md + CLAUDE.md (commit `828d5f2`).
> - ✅ `dev` is now the **GitHub default branch** (set via API 2026-06-29).
> - ⏳ AWAITING OWNER: create Cloudflare Pages project, Cloudflare Access (Google) policy, Firebase authorized-domain add (the manual runbook).
> - 🔒 HELD until staging verified: production deploy-gating change + the (A) `v2.6.0` rollback (these touch the live site).
> **Original status:** READ-ONLY investigation. Nothing changed, nothing pushed.
> **Goal:** Production shows RELEASED versions only; a separate always-live URL shows the latest unreleased build for real-device testing away from home.
> **Hard constraint:** 100% free — no paid infra/hosting. PWA (service worker + manifest), currently served from the `/Robco-UOS/` subpath.
---
## TL;DR — Recommendation
**Three changes, all free:**
1. **Gate production to releases.** Change `deploy.yml` to deploy GitHub Pages **only when a release tag is published** (the existing `release.yml` already auto-creates a `vX.Y.Z` tag whenever `APP_VERSION` changes). Result: pushing unreleased work to `main` no longer touches the public site — only a deliberate version bump publishes.
2. **Add a free Cloudflare Pages staging site** on its own origin (`robco-uos-dev.pages.dev`), auto-built from `main` (or a `dev` branch). Because it is a **different origin**, it gets fully isolated PWA storage (localStorage, IndexedDB/Firebase auth, CacheStorage, service-worker scope) — so testing unreleased state migrations on your phone can **never corrupt your real production save** in the same browser.
3. **Lock staging to you alone with Cloudflare Access** (Zero Trust free tier, ≤50 users). An identity gate sits in front of the staging origin; a policy allowing only `zerckzzyzerckzzy77@gmail.com` means only you can load it — everyone else hits a login wall and never sees the unreleased build. See §5.
This is **Option C** below. It is the only option that gives true PWA isolation **and** a free per-identity private gate, stays 100% free, and keeps production a one-repo GitHub Pages site. The all-GitHub alternative (**Option A**, a `/dev/` subpath) works but shares per-origin storage with production *and* **cannot be made private for free** (GitHub Pages has no per-user access control) — so it is documented only as the fallback.
---
## 1. Current Deploy Mechanism (exactly how a build reaches the live site today)
**Pages source = "GitHub Actions"** (not "deploy from branch"). Confirmed by `deploy.yml` using `actions/configure-pages` + `actions/upload-pages-artifact` + `actions/deploy-pages`. No `CNAME` file → served at the default `zerckzzyhd.github.io/Robco-UOS/` project-page subpath.
**The pipeline (every push to `main`):**
```
push to main
│
▼
ci.yml (on: push branches:[main]) ──► npm run gate (lint+format+tests+Playwright)
│ on success
▼
deploy.yml (on: workflow_run "CI" completed, branch main, conclusion success)
│ - checks out the exact CI-tested SHA
│ - stages _site: *.png index.html sw.js manifest.json + css/ js/ docs/ + CHANGELOG.md
│ - uploads artifact, deploys to Pages
▼
LIVE at https://zerckzzyhd.github.io/Robco-UOS/
```
Also wired to the same CI-success trigger: **`release.yml`** — reads `APP_VERSION` from `js/state.js`, and **if no tag `vX.Y.Z` exists yet for that version, creates the git tag + GitHub Release** with the changelog block. Tags already present: `v2.0.0, v2.0.1, v2.5.0, v2.6.0`.
**Consequence (the problem):** Because `deploy.yml` fires on *every* CI-passing push to `main`, the batched `[Unreleased]` work is **already public right now**. There is currently no separation between "pushed" and "released."
**Branch state:** `main` (active), `checkpoint/pre-phase2` (stale checkpoint). Single remote `origin`. `APP_VERSION = 2.6.0`; `CHANGELOG.md` top block is `## [Unreleased]<!-- Tests: 1165/1165 | Cache: robco-terminal-v2.6.0-r10 -->` accumulating under the already-released 2.6.0 line (the batch-to-one-version workflow).
---
## 2. The PWA Wrinkle — scope, cache, and storage isolation
The app is written **fully path-relative**, which is what makes any subpath/origin work without code edits:
| Concern | Current value | Behaviour |
| --- | --- | --- |
| `manifest.json` `start_url` / `scope` | `./` / `./` | Resolves to wherever the manifest is served (root, `/dev/`, or `*.pages.dev`). |
| SW registration | `register('sw.js')` (relative) | Scope = the directory of `sw.js`. At `/Robco-UOS/` → controls `/Robco-UOS/`. |
| SW `ASSETS` | all `./`-relative | `cache.addAll` resolves each entry against the SW scope. |
| SW fetch handler | `if (url.origin !== self.location.origin) return;` | Only same-origin requests are intercepted; Firebase/Gemini cross-origin go straight to network. |
| `CACHE_NAME` | `'robco-terminal-v2.6.0-r10'` | **Fixed string, NOT keyed to origin or path.** |
**The isolation facts that drive the recommendation:**
- **`localStorage`, IndexedDB, and CacheStorage are per-ORIGIN, not per-path.** Every `zerckzzyhd.github.io/*` URL shares one storage partition.
- The app's durable state lives in `localStorage` key **`robco_v8`** (plus `robco_version`, `robco_feature_flags`, `robco_gemini_key_sync`, rolling-backup keys) and Firebase auth state lives in **IndexedDB**.
- **Therefore any same-origin staging site (a `/dev/` subpath OR a second `Robco-UOS-dev` repo — GitHub user pages are all one origin) SHARES the production save, version flag, feature flags, Gemini key, and Firebase login** in the owner's browser. A staging build that writes a new-shaped `robco_v8` (e.g. exercising a Protocol-4 state field added in the unreleased work) would be read back by production's `migrateState()` on the next prod visit — risking silent field loss or `beforeunload`-flush clobber of the real save. For a save-heavy PWA this is the central risk.
- **SW parent-scope overlap (same-origin only):** the prod SW scope `/Robco-UOS/` is a *parent* of `/Robco-UOS/dev/`. Until the dev page registers its own narrower-scope SW, the first `/dev/` navigation can be controlled by the prod SW. Cache keys are full URLs so there is no cross-serving of content, but it is a fragile interaction worth avoiding.
- **A different origin (Cloudflare `*.pages.dev`) fully partitions all of the above** — separate `localStorage`, IndexedDB, CacheStorage, SW registry, and Firebase auth partition. No collision is even possible.
**Per-option PWA/scope/manifest changes required** are listed inside each option below.
---
## 3. Realistic FREE Options
### Option A — One repo, GitHub Actions: prod at root (release-gated) + staging at `/Robco-UOS/dev/`
- **Branch model:** `main` = unreleased work; production root = the latest release tag (or a `release` branch). `deploy.yml` builds a single `_site` where `/` = checkout of the latest `vX.Y.Z` tag and `/dev/` = checkout of `main`. One artifact, one Pages deploy.
- **Production stays release-only:** root tree is built from the latest tag, never from `main` HEAD.
- **Phone access to staging:** `https://zerckzzyhd.github.io/Robco-UOS/dev/` (bookmark / add-to-home-screen).
- **PWA implications (the catch):**
- Same origin → **shares `localStorage`/IndexedDB/Firebase auth with production** (the migration-corruption hazard above). Mitigations are all imperfect: namespace every dev storage key (`robco_v8` → `robco_DEV_v8`, touches every storage access — invasive, easy to miss one), or use a separate browser profile for testing (fragile, undercuts "test on my real phone").
- Give dev a distinct `CACHE_NAME` prefix (e.g. `robco-DEV-...`) so the two SWs' `activate` cleanups don't delete each other's cache bucket.
- Give dev a distinct manifest `name`/`short_name` (e.g. `RobCo U.O.S. [DEV]`) so an installed staging PWA is distinguishable from prod on the home screen.
- Parent-scope SW overlap applies (see §2).
- **Ongoing cost:** moderate. `deploy.yml` rewritten to build two trees and resolve "latest release." Protocol 1 now governs **two** `CACHE_NAME`s. Stays 100% inside GitHub.
### Option B — Separate staging repo (`Robco-UOS-dev`) with its own Pages
- `https://zerckzzyhd.github.io/Robco-UOS-dev/`. Push dev work there; the prod repo only ever receives releases.
- **Still the SAME origin** (`zerckzzyhd.github.io`) → identical `localStorage`/IndexedDB sharing hazard as Option A, **with no isolation benefit**, **plus** the overhead of maintaining two repos (mirror push or split history) and duplicating the gate/hooks. Weakest option — listed only for completeness.
### Option C — Cloudflare Pages staging (different origin) ✅ recommended
- **Production unchanged in spirit:** stays on GitHub Pages, but gated to releases (see §4). Staging moves to Cloudflare.
- **Staging:** connect Cloudflare Pages to the GitHub repo, build branch = `main` (or a dedicated `dev` branch). Output → `https://robco-uos-dev.pages.dev`. It is a static-file host; **no build command needed** — point it at the repo root (or a small "copy served files" build step mirroring `deploy.yml`'s stage list).
- **Production stays release-only:** handled on the GitHub side (§4); Cloudflare only ever serves the dev branch.
- **Phone access to staging:** `https://robco-uos-dev.pages.dev` — bookmark or install as its own PWA.
- **PWA implications (minimal, all upside):**
- **Different origin → full isolation** of `localStorage`, IndexedDB/Firebase auth, CacheStorage, and SW. **Zero risk to the production save.**
- Relative paths "just work" at the Cloudflare root — **no path/scope/manifest edits required** for it to function.
- `CACHE_NAME` can stay the same string (different origin = different CacheStorage), so **no second cache rev to maintain**.
- **Both apps install side-by-side automatically.** PWA install identity = origin + `start_url`. Prod (`zerckzzyhd.github.io/Robco-UOS/`) and staging (`robco-uos-dev.pages.dev`) are different origins, so they are already two distinct installs, each with its own save/login/cache — no manifest change needed for that.
- *Optional polish so you can tell them apart:* a `[DEV]` manifest `name` (the label under the icon) and a distinct dev icon. Both currently share `icon.png`, so without this the two home-screen icons look identical. Best done via a tiny Cloudflare deploy-time rewrite so it never touches the repo or prod.
- **Firebase:** add `robco-uos-dev.pages.dev` to **Authorized domains** in the Firebase console so Google **popup** sign-in works there (`authDomain` stays `nv-overlord.firebaseapp.com`; Protocol 30 popup-only is unaffected). `connect-src`/CSP targets are unchanged because the API endpoints are identical — only the serving origin differs.
- **Ongoing cost:** low. Cloudflare auto-builds on every push to the connected branch (free tier: unlimited static requests, 500 builds/month — far inside limits). Adds one external provider + one dashboard, and one Firebase authorized-domain entry. **Still 100% free.**
---
## 4. Recommendation + concrete model
**Recommend Option C**, combined with release-gating production. Reasoning: it is the only free option that gives a **real** staging environment for a PWA — isolated storage means the owner can hammer unreleased state-migration changes on their actual phone without ever endangering the real production save, login, or Gemini key. Options A/B share the production storage partition, which for this app is the thing most likely to bite.
### Two orthogonal concerns, solved separately
**Concern 1 — stop unreleased work going live to prod (GitHub side):**
- Change `deploy.yml`'s trigger from "CI succeeded on main" to **"a release was published"** (`on: release: types: [published]`, or `on: push: tags: ['v*']`), checking out the tag ref.
- `release.yml` already auto-creates `vX.Y.Z` when `APP_VERSION` changes → **bumping the version becomes the single deliberate act that publishes to production.** Unreleased pushes (no version bump) never reach prod.
- Net branch model:
- `main` = active/unreleased work. Pushing here runs the gate + updates staging, **never** prod.
- Release = bump `APP_VERSION` (per Protocol 2 semver) → CI → `release.yml` tags `vX.Y.Z` → `deploy.yml` publishes that tag to the public site.
- (Optional) a `dev` branch if the owner wants `main` to track releases instead; not required.
- **Note:** under this model production "freezes" at the last released tag (`v2.6.0`) until the next version bump. That is the intended behaviour — confirm `v2.6.0` is the desired current public baseline before flipping.
**Concern 2 — a live staging URL for the phone (Cloudflare side):**
- One-time: create a Cloudflare Pages project linked to the repo, branch `main`, no build command (static), public dir = repo root (or a copy step matching `deploy.yml`'s served-file list so private files like `CLAUDE.md`/`tests/` aren't published).
- One-time: add `robco-uos-dev.pages.dev` to Firebase **Authorized domains**.
- One-time: put it behind **Cloudflare Access** so only you can reach it (see §5).
- Thereafter: every push to `main` updates staging automatically.
### How existing Protocols & flows adapt
- **Protocol 1 (cache bump):** unchanged. Same `CACHE_NAME` string serves both origins independently; bumping still fires the "REBOOT TERMINAL" prompt on each. No second cache rev (Option C's key win over A).
- **Changelog "released only" viewer:** already implemented — the boot viewer `.find(s => /^## \[v\d+\.\d+/)` skips `[Unreleased]`. On staging the owner may *want* to see `[Unreleased]`; if so that's a small optional staging-only tweak, otherwise leave as-is.
- **Protocol 11 (deploy verification):** now two surfaces — verify prod after a release tag, verify staging after a `main` push.
- **Gate / commit / Protocol 2a / 19:** unchanged. Same single gate before push.
- **`release.yml`:** unchanged in logic; it simply becomes the trigger source for prod deploys.
- **Free-only rule:** satisfied — GitHub Pages + Cloudflare Pages free tiers.
### Open questions for the owner (pick before any implementation)
1. **Option C (Cloudflare, isolated + privatable, recommended)** or **Option A (all-GitHub `/dev/`, shares storage, cannot be private for free)**?
2. Should production deploy on **version-bump tag** (recommended, leverages existing `release.yml`) or on **manual `workflow_dispatch`** only?
3. Confirm `v2.6.0` is the intended public baseline once prod freezes to releases.
4. Staging branch = `main`, or introduce a dedicated `dev` branch?
5. Staging PWA: rename to `[DEV]` + distinct icon (recommended so the two installs are tellable apart) or leave identical?
6. Access policy: sign in to staging with **one-time email PIN** (simplest) or **Login with Google** (`zerckzzyzerckzzy77@gmail.com`)? Both free.
---
## 5. Making staging private — only you can access it (free)
Requirement: only the owner may load the unreleased build for live testing. This is the deciding factor between the options, because **GitHub Pages has no per-user access control on the free tier** — a `/dev/` subpath or a second GitHub repo is *always public*. Cloudflare can gate it for free; GitHub cannot.
**Recommended: Cloudflare Access (Cloudflare Zero Trust, free tier — up to 50 users).**
- An identity gate sits **in front of** the staging origin. Before the page (or service worker) ever loads, the visitor must authenticate against an Access **policy**. Set the policy to **Allow → Emails → `zerckzzyzerckzzy77@gmail.com`** (one identity). Everyone else gets a login wall and never reaches the build.
- **Login methods (all free):** one-time PIN emailed to your address (zero setup), or "Login with Google" for your account. Pick per open-question 6.
- **Session length is configurable** (e.g. 24 h up to ~1 month). Set it long so you authenticate on your phone rarely. This matters for a PWA: the app shell is served from the SW cache and keeps working offline, but **fresh network requests (SW update checks, `CHANGELOG.md` fetch) will redirect to the Access login once the session lapses** — a long session minimizes that friction.
- **Does not affect AI or cloud sync:** Gemini and Firebase calls are **cross-origin**, so Access (which only gates the staging origin) never touches them. Sign-in, saves, and AI work normally once you're past the gate.
- **Cost:** $0. Cloudflare Zero Trust free plan covers this well inside the 50-user limit. No card required for the free tier.
**Why the alternatives are worse (all rejected):**
| Approach | Private to just you? | Free? | Verdict |
| --- | --- | --- | --- |
| **Cloudflare Access** (recommended) | Yes — per-identity (your email) | Yes | ✅ Clean, identity-based, pairs with Option C |
| HTTP Basic Auth via Cloudflare Pages Functions (`_middleware`) | Shared password, not identity | Yes | ⚠ Works, but a single shared secret that can leak; clunky with SW fetches |
| Obscure `*.pages.dev` URL ("security by obscurity") | No — anyone with the link gets in | Yes | ❌ Not real access control; subdomains are discoverable |
| GitHub Pages from a private repo | No — the *site* is still public | Yes | ❌ Private repo ≠ private site |
| Netlify / Vercel password protection | Yes | **No — paid (Pro) feature** | ❌ Violates free-only |
So the privacy requirement **confirms Option C** and effectively rules out the GitHub-only Option A (it cannot be made private without paying).
---
## Implementation footprint (for when an option is chosen — NOT done here)
- **Option C:** edit `deploy.yml` trigger (release-gated) + checkout tag ref; Cloudflare Pages project setup; **Cloudflare Access app + email-only policy** (§5); Firebase authorized-domain add; optional deploy-time `name`/icon rewrite for `[DEV]`. No source-code edits required for staging to function.
- **Option A:** rewrite `deploy.yml` to build `/` (latest tag) + `/dev/` (main) into one artifact; add a second `CACHE_NAME`; distinct dev manifest `name`; **and** decide the storage-namespacing mitigation (invasive) or accept the shared-storage caveat.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).