# Environments, releases, and CI/CD

How Glide's three environments fit together, who owns what, and how a change
travels from a branch to customers. This is the anchor doc for release
structure; [app-store.md](app-store.md) is the dashboard runbook for the
production iOS app specifically.

## The model at a glance

**`main` → sandbox is automatic and continuous; sandbox → production is a
deliberate tag.** Everything else is machinery serving those two arrows.

| | Development | Sandbox | Production |
|---|---|---|---|
| Purpose | in-progress work | stable dogfood; testers' daily driver | customers |
| App name | Glide (dev icon) | **Glide Beta** | Glide |
| Bundle/package ID | `com.h14h.glide` | `com.henrynelsonfirth.glide` | `com.h14h.glide` |
| Publisher account | personal | **personal** (sole proprietor) | **H14H, LLC** (org accounts) |
| Convex | per-developer dev deployment | `glide-sandbox` project, prod deployment | `glide` project, prod deployment |
| Clerk | dev instance (`pk_test_`) | dev instance (`pk_test_`) | production instance (`pk_live_`) |
| Payments | purchase stub (dev-only flag) | none — see [Sandbox and payments](#sandbox-and-payments) | RevenueCat IAP |
| EAS build profile | `development` / `simulator` | `sandbox` | `production` |
| EAS environment | `development` | `preview` | `production` |
| Update channel | `development` | `sandbox` | `production` |
| Distribution | dev client | TestFlight internal + Play internal track | App Store + Play Store |

## Identity and accounts

The sandbox and production apps are **two distinct apps with two distinct
publishers**, and their identifiers mirror that:

- **Production** is published by H14H, LLC under its namespace: `com.h14h.glide`,
  widget `com.h14h.glide.widgets`, App Group `group.com.h14h.glide`.
- **Sandbox** is published by the personal (sole proprietor) accounts under the
  personal namespace: `com.henrynelsonfirth.glide`, widget
  `com.henrynelsonfirth.glide.widgets`, App Group
  `group.com.henrynelsonfirth.glide`.

Both IDs are **permanent**. That is obvious for production; it is equally true
for sandbox because Glide is local-first — testers' tasks live in the app
container keyed to the bundle ID, so changing it silently wipes their data.

Because they are separate apps, both install side by side on one device, and
they never share local data or a backend. The URL scheme `glide://` is shared
by both apps; widget taps and quick actions always route to their own
containing app, so this is safe — but an *external* `glide://` link on a device
with both apps installed may open either one. Acceptable; do not build
features that depend on external scheme links resolving to a specific app.

The identity switch is implemented in [app.config.js](../../app.config.js) via
`GLIDE_APP_VARIANT` (`development` | `sandbox` | `production`), derived from the
EAS build profile and **also set explicitly in the matching EAS environment**
so workflow fingerprint jobs compute the same hash as builds. Web presence:
`glidetodos.com` is the product domain (marketing, support, universal links if
added); `h14h.com` is the LLC; personal domains appear nowhere user-facing.

### LLC transition plan

The existing App Store Connect app (`ascAppId` 6790872998) lives under the
personal Apple account and is treated as **disposable pre-launch scaffolding**.
When the LLC's org developer accounts exist (Apple + Google, one D-U-N-S number
serves both):

1. Create the production app fresh under the LLC accounts (no transfer —
   nothing has shipped). Redo the [app-store.md](app-store.md) runbook there,
   including banking/tax under the LLC, and update `ascAppId` in
   [eas.json](../../eas.json).
2. Google org accounts are exempt from the personal-account closed-testing
   requirement (12+ testers for 14 days); verify current policy in Play Console.
3. The sandbox app stays on the personal accounts indefinitely. Keep the
   personal Apple membership active or sandbox TestFlight builds expire.

## Convex

Two cloud projects plus per-developer deployments:

- **`glide`** — production deployment backs the production app. Deployed only
  by the release workflow.
- **`glide-sandbox`** — its production deployment backs the sandbox app.
  Deployed on every merge to `main`. This is also where schema migrations are
  rehearsed against realistic data before touching customers
  ([migrations.md](../engineering/migrations.md)).
- **Personal dev deployments** — `npx convex dev`, selected by
  `CONVEX_DEPLOYMENT` in `.env.local`.

Deploys always run **backend before client**, and schema changes must stay
backward-compatible with the previous client release
(widen → migrate → narrow).

## EAS environments and variables

Cloud-side variables live in EAS environments (never hardcoded in `eas.json`);
local development uses `.env.local`. Required per environment:

| Variable | `development` | `preview` (sandbox) | `production` |
|---|---|---|---|
| `GLIDE_APP_VARIANT` | `development` | `sandbox` | `production` |
| `EXPO_PUBLIC_CONVEX_URL` / `_SITE_URL` | dev deployment | `glide-sandbox` prod | `glide` prod |
| `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | `pk_test_…` | `pk_test_…` | `pk_live_…` |
| `EXPO_PUBLIC_REVENUECAT_KEY` | optional | **unset** (RevenueCat stays off) | platform store key |
| `EXPO_PUBLIC_FREE_SYNC` | optional | `1` (free Sync for signed-in testers) | **unset** (build refuses `1`) |
| `CONVEX_DEPLOY_KEY` (secret) | — | `glide-sandbox` deploy key | `glide` deploy key |

On the Convex side, the `glide-sandbox` deployment (and only it) sets
`SANDBOX_GRANT_ALL=1` — see [Sandbox: free Sync](#sandbox-free-sync-no-payments).

`app.config.js` refuses to produce a production build with missing or test
credentials.

## Deploying — manual for now

**The EAS Workflows exist but are DISABLED** (triggers set to `workflow_dispatch`
only), and the repo is not connected to EAS's GitHub integration. Nothing
deploys automatically — every cycle is run by hand. This is deliberate while we
build intuition for the OTA-vs-build-vs-submit decision below. The workflow
files in [.eas/workflows/](../../.eas/workflows) double as the blueprint for
that eventual automation; to turn it on, restore the commented-out triggers and
link the repo in the EAS dashboard.

### When to do what

The whole decision is: **did I change a native input?** (native dep in
`package.json`, `app.config.js`/`app.json`/plugins, `eas.json`, SDK version, or
`.gitignore` — the fingerprinted set from
[eas-update.md](../engineering/eas-update.md).) Plus: **did I change `convex/`?**

| What changed | Action |
|---|---|
| Only `convex/` (schema/functions) | `convex deploy` to the target deployment — **nothing else** |
| Only JS/TS/assets under `src/` (no native input) | **OTA**: `eas update` — no build, no submit, no store |
| Any native input | **Build + submit**: `eas build` then `eas submit` — an OTA can't carry it |

Rules that always hold:

- **Backend before client.** If a change touches both `convex/` and the app,
  `convex deploy` first, and keep the schema backward-compatible with the client
  still installed (widen → migrate → narrow).
- **A build is not a delivery.** `eas build` only produces a binary in EAS;
  `eas submit` is what puts it on TestFlight / the Play track. OTA never needs a
  submit; a native change always needs both.
- **OTA only reaches builds with a matching fingerprint.** Publish from a clean
  tree (a `*` after the commit in `eas update` output = dirty = danger). If you
  changed a native input, existing installs won't get an OTA until you ship a
  new build — that's the signal you're in the build+submit row.
- **How testers receive it:** OTA → on next app relaunch (no store). Android
  store track → auto-updates. iOS TestFlight → auto only if the tester enabled
  it, else a one-tap update, after a few-minutes processing delay.

### Manual runbook

Sandbox — typical JS change (the common case):

```
# 1. only if convex/ changed:
CONVEX_DEPLOY_KEY=<glide-sandbox key> npx convex deploy
# 2. publish OTA (clean tree!):
eas update --branch sandbox --environment preview -m "what changed"
# 3. testers relaunch the app
```

Sandbox — native change:

```
CONVEX_DEPLOY_KEY=<glide-sandbox key> npx convex deploy   # if convex/ changed
scripts/ship-sandbox.sh          # build both platforms, then submit both
```

`ship-sandbox.sh build` and `ship-sandbox.sh submit` run one half each; an
optional second argument (`ios` | `android`) narrows the platform.

**Do not call `eas submit --profile sandbox` directly.** Submit profiles have no
`env` block and `eas submit` has no `--environment` flag, so `GLIDE_APP_VARIANT`
never reaches [app.config.js](../../app.config.js), which falls back to the
production identity. EAS then looks up credentials for `com.h14h.glide` instead
of `com.henrynelsonfirth.glide` and fails with a misleading "credentials cannot
be set up in --non-interactive mode" — it is a wrong-app error wearing a missing-
credentials costume. The script exports the variant. (It is a shell script and
not a `package.json` entry because the `scripts` field of `package.json` is a
fingerprint input — adding one there changes every runtime version and cuts
installed builds off from OTAs.)

Production is the same with `--profile production`, `--branch production` /
`--environment production`, and the `glide` deploy key. `eas submit --profile
production` needs no variant prefix only because production is the fallback
identity; prefer `GLIDE_APP_VARIANT=production` anyway so the command states
what it means. Promotion = publish the
*same commit* you dogfooded in sandbox to the production channel (freshly built
with prod env), not a cross-channel republish (different bundle IDs → different
fingerprints).

## Release process

- **Branching:** trunk-based. `main` is the only long-lived branch and is
  **always releasable** — features that aren't ready to ship stay on their
  branch (no feature-flag process yet; revisit if parallel large features or
  A/B testing become real needs).
- **Rolling sandbox releases:** merge to `main`, then deploy to sandbox by hand
  (see the [manual runbook](#manual-runbook)) — OTA or build+submit per the
  change. No tags, no ceremony.
- **Production release:** when sandbox feels right — move
  [CHANGELOG.md](../../CHANGELOG.md) "Unreleased" under a version heading, bump
  `version` in `app.json` if a native release is expected, commit, tag
  `v1.2.0`, then run the production deploy by hand. Build numbers auto-increment
  remotely; `version` is the marketing version. (The tag is still the record of
  what shipped even though it no longer triggers anything automatically.)
- **Hotfix** while `main` has unreleased work: branch from the last tag
  (`git checkout -b hotfix/1.2.1 v1.2.0`), fix, tag `v1.2.1`, push the tag,
  merge back to `main`.
- **Changelog:** one file, three uses — repo history, TestFlight "What to
  Test", store release notes.

## Sandbox: free Sync, no payments

The subscription gates *sync only* (see
[subscriptions.md](../engineering/subscriptions.md)); there is no launch
paywall. In the sandbox app **Sync is free: signing in is the whole gate.**
Two switches, both sandbox-only:

- **Client** — `EXPO_PUBLIC_FREE_SYNC=1` (set in the `preview` environment;
  production builds refuse it). The paywall becomes a sign-up/sign-in flow
  with no plans or prices ([paywall-screen.tsx](../../src/design/paywall-screen.tsx)),
  the profile pitch reads "Free during the beta", and `FreeSyncBridge`
  ([free-sync.tsx](../../src/data/sync/free-sync.tsx)) maps signed-in →
  `subscribed`. Grace/billing states never occur in this mode.
- **Server** — `SANDBOX_GRANT_ALL=1` set as an env var on the
  **`glide-sandbox` Convex deployment only** (declared in
  [convex.config.ts](../../convex/convex.config.ts)). `requireSyncAccess`
  grants any authenticated identity read-write sync, bypassing the RevenueCat
  entitlement cache. This is the actual gate; the client flag is cosmetic.
  **Never set it on production — it would make Sync free for everyone.**

The `preview` environment sets no `EXPO_PUBLIC_REVENUECAT_KEY`, so RevenueCat
stays unconfigured in sandbox. The dev-only purchase stub is unchanged and
unrelated.

Consequences to keep in mind:

- **The payment flow gets zero dogfooding in sandbox.** Before any production
  release that touches subscription code, do a manual purchase/restore/lapse
  pass in a TestFlight build of the **production** app, where StoreKit sandbox
  applies automatically.
- Sandbox identities and synced data are a dead end by design (test Clerk
  instance, sandbox Convex project) — nothing carries over to production.
  Local-first softens this: tasks live on-device regardless.
- Sandbox server data is never wiped (no entitlement expiry → no scheduled
  cleanup); removing a departed tester is a manual dashboard delete.

## One-time setup checklist

Dashboard work required before the pipeline is fully live, in order. Most of it
is done; the one item still blocking a full sandbox release is the App Store
Connect API key in step 3.

1. ~~**Convex:** create the `glide-sandbox` project…~~ **Done** (2026-07):
   sandbox prod deployment is `quixotic-dinosaur-524`, production is
   `festive-hyena-328`; deploy keys for both are stored as EAS secrets. The
   sandbox deployment's env vars: `SANDBOX_GRANT_ALL=1` **and**
   `CLERK_JWT_ISSUER_DOMAIN` (same Clerk dev instance as the personal dev
   deployments — without it `getUserIdentity()` returns null and every sync
   call fails auth). It intentionally has **no** `REVENUECAT_SECRET_API_KEY`:
   free-sync skips the RevenueCat refresh entirely (see
   [engine.ts](../../src/data/sync/engine.ts)). Functions must be deployed to
   it (`convex deploy`) before the app can sync — the `deploy-sandbox`
   workflow does this automatically once the repo is connected to EAS/GitHub.
2. ~~**EAS environments:** populate the variable table above…~~ **Done**
   (2026-07), except the production Clerk `pk_live_` and RevenueCat store
   keys, which wait for the LLC accounts.
3. ~~**Apple (personal account):** register `com.henrynelsonfirth.glide`,
   `.widgets`, and App Group…~~ **Mostly done** (2026-07): both bundle IDs have
   live distribution certs and provisioning profiles, and the "Glide Beta" ASC
   app exists — `ascAppId` `6794097848` is in the `sandbox` submit profile.
   **Still blocking iOS submission:** no App Store Connect **API key** is
   registered with EAS, so `eas submit --platform ios` cannot authenticate and
   dies with "App Store Connect API Keys cannot be set up in --non-interactive
   mode". Fix once, interactively, by running `scripts/ship-sandbox.sh submit
   ios` in a terminal and following the prompt — EAS will either offer an
   existing key or create one via Apple login (needs 2FA, so it cannot be
   scripted). After that the submit is non-interactive forever. Also confirm
   testers are in a TestFlight internal group.
4. ~~**Google (personal account):** create the "Glide Beta" Play app…~~ **Done**
   (2026-07): verified end to end — a sandbox build was accepted on the
   `internal` track using the EAS-hosted service account
   `eas-submit@glide-play-publishing.iam.gserviceaccount.com`. Confirm the
   tester list on the internal track.
5. **GitHub (deferred):** connect the repo to EAS and restore the workflow
   triggers to automate deploys. Skipped for now — deploys are manual (see
   [Deploying — manual for now](#deploying--manual-for-now)).
6. ~~First sandbox builds~~ **Done** (2026-07): both platforms build green on
   the `sandbox` profile; Android reached the Play internal track. iOS is
   waiting only on the API key in item 3.
7. Later, per the [LLC transition plan](#llc-transition-plan): LLC org
   accounts, production store apps, `ascAppId` update, Play closed test /
   launch.

A distinct beta icon is a nice-to-have: sandbox currently reuses the dev icon
assets (`icon-dev.png` etc.).
