GENERATED VIEW · PRIVATE ARCHIVE · DO NOT SERVE
RobCo Industries · Archive Museum

DEPLOY_STAGING_PLAN.md



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).