# Stage → Test → Release: the site pipeline

How a change travels from a laptop to `www.bigscreenvr.com`. This replaces the
legacy Jenkins/AWS flow end to end; the legacy pipeline is frozen (see
Non-goals). Decision record: `docs/adr/0001-staging-on-cloudflare-pages.md`.

| Stage | Trigger | Where it lands | Who can see it |
| --- | --- | --- | --- |
| 1. Local dev | `npm run local` | `localhost:3000` | you |
| 2. Staging | push to `dev` | `dev-website.bigscreencloud.com` | team, via Cloudflare Access (email OTP) |
| 3. API tier switch | `?api=prod` on staging | same staging URL | same |
| 4. Embed check | `npm run embed:check` | `check-<rand>.bigscreen-embed-check.pages.dev` | public for ≤5 min, meta-only shells |
| 5. Release | push tag `v*` (on `main`) | `bigscreen-website.pages.dev` → `www.bigscreenvr.com` | everyone |

Branch discipline is unchanged: feature branch off `dev` → merge to `dev` when
draft-complete → PR `dev` into `main` when staging-proven → tag to release.

## 1. Local dev

Unchanged — see `LOCAL-TESTING.md`. `npm run local` runs the site with the
built-in mock API (demo login `demo@bigscreenvr.com` / `beyond2`). Real-API
values in `.env.local` switch it to the live dev API.

One extra trick: the API servers accept any `*.bigscreencloud.com` origin, any
port. If you need a *browser* origin the API trusts (the bare `localhost:3000`
origin is rejected), add a hosts-file line `127.0.0.1 local.bigscreencloud.com`
and browse via `http://local.bigscreencloud.com:3000`.

## 2. Staging

Push (or merge) to `dev` → GitHub Actions (`.github/workflows/deploy.yml`)
typechecks, builds the static bundle against the **dev APIs**, and deploys the
Cloudflare Pages project **`bigscreen-website-dev`**, which serves
**`https://dev-website.bigscreencloud.com`**.

- The hostname is deliberate: the API origin allowlist
  (`cloud/apps/api/api.ts`) accepts any `*.bigscreencloud.com` origin, so
  staging talks to the real dev **and** prod APIs with zero backend changes.
  A bare `*.pages.dev` origin would be rejected (verified empirically —
  preflight 401).
- Access control is **Cloudflare Access** (login wall, email one-time-PIN),
  replacing the old IP whitelist. Nobody needs Cloudflare dashboard access to
  *view* staging — they get an email code at the URL. Onboarding a contractor
  = adding their email to the Access policy (owner task, ~1 min).
- The old IP-whitelisted CloudFront site that lived at this hostname is gone;
  its Jenkins pipeline is frozen (see Non-goals).

## 3. Switching API tiers on staging (no redeploy)

Staging is built on dev APIs but also ships `config.prod.json` (the prod API
tier — same public values the production site uses). In any staging page:

- append **`?api=prod`** → this browser talks to the **production** APIs. A
  dismissable `PROD API` badge stays on screen while active.
- append **`?api=dev`** → back to the dev APIs.

The choice sticks per-browser (localStorage), so two testers can run different
tiers against the same deployment at the same time. Production builds ship no
`config.prod.json`, so the switch is inert in prod. Implementation:
`src/site/partials/bigapi.js` + `scripts/build-config.mjs`.

## 4. Embed checks (Discord/Slack/Twitter unfurls)

Link unfurls are fetched by the *platform's servers*, so an Access-gated (or
IP-whitelisted, or tailnet) URL can never be embed-tested. Instead:

```
npm run build:static                    # once per content change
npm run embed:check                     # all pages with OG tags
npm run embed:check -- --pages /en,/10years
```

This builds **embed shells** — pages stripped to *only* their `<head>` unfurl
metadata (title/description/OG/Twitter tags + the referenced image/video
assets, `og:video` included) — and deploys them to the public
`bigscreen-embed-check` Pages project on a **fresh random URL** every run
(`check-<rand>.bigscreen-embed-check.pages.dev`), so platforms never serve you
a stale cached embed while you iterate. Paste the printed URLs into Discord.

Exposure model: shells are public but contain no page content (a leaked URL
renders a blank page), are `noindex` + robots-disallowed, and live **at most
~5–6 minutes** — the script deletes them after 5, and the `embed-purge` cron
Worker (`workers/embed-purge/`) force-deletes anything older than 5 even if
the script died. Note: whatever a platform fetched during that window stays in
its cache — only push shells you're prepared for Discord to have seen.

Production embeds can be spot-checked any time against
`bigscreen-website.pages.dev` (public) before DNS cutover, and `www` after.

## 5. Release to production

When `dev` is staging-proven: PR `dev` → `main`, merge, then tag:

```
git checkout main && git pull
git tag v1.4.0 && git push origin v1.4.0
```

The tag (must point at a commit on `main` — CI enforces this) builds against
the **prod APIs** and deploys the **`bigscreen-website`** Pages project. Plain
pushes to `main` run checks only; **merging alone never deploys** — the tag is
the deliberate release act.

Before DNS cutover, that means the release is live on
`bigscreen-website.pages.dev` for final verification. After cutover it *is*
`www.bigscreenvr.com`. Rollback = `Pages project → Deployments → Rollback`
(instant, dashboard) or re-tag the previous good commit.

## One-time DNS cutover (owner)

Preconditions — all of:

1. **URL parity dispositioned.** The legacy SPA's route table (its regex
   routing entries, incl. every Builder.io page) is the authoritative
   inventory. Every URL is either ported, 301'd, or consciously dropped
   (agreed dropped so far: the IPD selector). Load-bearing dynamic URLs
   (`/account/reset/:token`, `/purchase/:id/:url`, `/browser/:activityId`, …)
   are hard-coded in transactional emails, the iOS app clip, and the
   eyetracking client — test each one on `bigscreen-website.pages.dev`
   (`out/_routing-spec.json` lists them all).
2. Purchase/Stripe flow verified on the pages.dev URL with `?api=prod`
   semantics (prod build talks to prod APIs natively).
3. Embed spot-checks pass against `bigscreen-website.pages.dev`.

Cutover (Cloudflare dashboard, by hand — never CI): export the zone as backup,
note the current `www` + apex record values and proxy status, then attach
`www.bigscreenvr.com` **and** the apex `bigscreenvr.com` as custom domains on
the `bigscreen-website` Pages project (both serve the site today; keep both —
`www` stays canonical in page metadata). Rollback = restore the two DNS
records. Never touch NS/MX/TXT or unfamiliar records (company email, VR app
APIs) — see `CLOUDFLARE-DEPLOY.md` §5.

## CI secrets

Repo → Settings → Secrets and variables → Actions. All `BIG_*` values are
public client config (they ship in `config.json`); secrets only for tidiness.
Values from `Server_Keys` (Brandon).

| Secret | What |
| --- | --- |
| `CLOUDFLARE_API_TOKEN` | scope **Cloudflare Pages: Edit only** (cannot touch DNS) |
| `CLOUDFLARE_ACCOUNT_ID` | dashboard → Workers & Pages → right rail |
| `DEV_BIG_API_SERVER_URL` … `DEV_BIG_STRIPE_PK` | dev API tier (today: `dev-gem-api` / `dev-gem-cloud-api` / `dev-rdc` `.bigscreencloud.com`) |
| `PROD_BIG_API_SERVER_URL` … `PROD_BIG_STRIPE_PK` | prod API tier (today: `main-shark-api` / `main-shark-cloud-api` / `rdc` `.bigscreencloud.com`) |

## Non-goals / frozen legacy

- **Legacy pipeline (Jenkins → AWS S3/CloudFront) is frozen, not deleted.** No
  further legacy iterations are planned; leave the infra dormant. Don't
  decommission anything in AWS/Jenkins as part of this work.
- The `Dockerfile` and Coolify path are superseded by the static Pages deploy;
  kept in-tree for reference only. The old GHCR image workflow was replaced by
  `.github/workflows/deploy.yml`.
- No CMS wiring — the editing-layer decision stays open (`CLAUDE.md`).

## Bring-up status / execution checklist

Repo side (done in this repo):

- [x] `.github/workflows/deploy.yml` — the pipeline above
- [x] `scripts/build-embed-shells.mjs`, `scripts/embed-check.mjs`, `npm run embed:check`
- [x] `workers/embed-purge/` — 5-minute shell teardown Worker
- [x] `?api=prod` switcher + badge (`bigapi.js`, `build-config.mjs`)

Cloudflare/GitHub side (owner, one-time — detailed steps in the shared
bring-up artifact):

- [ ] Create Pages projects `bigscreen-website`, `bigscreen-website-dev`,
      `bigscreen-embed-check` (direct-upload)
- [ ] Cloudflare Access app over `bigscreen-website-dev` (+ its custom domain)
- [ ] Repoint `dev-website.bigscreencloud.com` CNAME → `bigscreen-website-dev.pages.dev`
- [ ] Deploy `workers/embed-purge` (set `ACCOUNT_ID`, secret `API_TOKEN`)
- [ ] Mint Pages:Edit API token; set the GitHub Actions secrets table above

Frontend dev afterwards: push to `dev`, confirm staging URL updates; run one
`npm run embed:check`; then normal iteration.
