Files
Sauci/docs/mobile-ota.md
T

267 lines
11 KiB
Markdown

# Mobile over-the-air updates
Sauci ships JavaScript-only mobile changes over the air with `expo-updates`
against a self-hosted XPREM update server (formerly `expo-open-ota`) at
`https://ota.apps.jbrunton.co.uk`. XPREM speaks the official Expo Updates
protocol, so the client is stock `expo-updates`; only the endpoint, the request
headers, and the code-signing certificate are Sauci specific.
Store releases still follow [Mobile release process](releasing.md), which opens
with the OTA-vs-store-release decision rule. OTA never replaces a store build;
it only refreshes the JavaScript bundle and assets of an already installed
build.
## Status
The client and checked-in native projects are wired with the real server values
(app id `9a66f3c2-fda7-416f-87e2-6db833b32e9d` and the Sauci code-signing
certificate, SHA-256 fingerprint beginning `DF:C9:17:A9`). A new store build is
required before updates can flow; see [Server registration](#server-registration).
## How it works
1. A build embeds a manifest URL, a release channel, an app id, and a public
code-signing certificate.
2. On launch the app requests `https://ota.apps.jbrunton.co.uk/manifest`, sending
`expo-channel-name`, `expo-app-id`, `xprem-branch`, and the standard
`expo-runtime-version` and `expo-platform` headers.
3. XPREM resolves the channel to a branch, finds the newest update published for
that branch and runtime version, and returns a signed manifest.
4. The client verifies the signature against the embedded certificate, downloads
the bundle, and launches it on the next start.
### Branches and channels
XPREM separates storage from delivery. A **branch** groups updates for a release
line. A **release channel** is bound to a build at build time and points at a
branch. Repointing a channel at a different branch promotes or reverts an update
without a rebuild.
| EAS build profile | `RELEASE_CHANNEL` | Publish branch |
| --- | --- | --- |
| `development` | `development` | `development` |
| `preview` | `staging` | `staging` |
| `production` | `production` | `production` |
Channels and their branch mappings are created in the XPREM dashboard under
Channels. The client cannot create them.
### Runtime version
`apps/mobile/app.config.js` sets an explicit runtime string:
```js
runtimeVersion: '1.0.7'
```
The release version script updates this string with `expo.version` and the
checked-in iOS and Android runtime values, so it remains the same value as the
public app version. An update is only offered to builds whose runtime version
matches exactly.
The practical rule: **any change that touches native code, native dependencies,
or the `ios/`/`android/` projects requires a new store build with a bumped
`expo.version`.** Bumping `version` starts a fresh runtime version on the server,
so old builds stop receiving updates that assume the new native surface.
JavaScript, asset, and configuration changes that do not alter the native binary
can ship over the air against the current `version`.
An explicit runtime string is used because EOAS requires a concrete value for
this checked-in bare workflow. The synchronized release script keeps it tied to
the number visible in App Store Connect and Google Play without relying on Expo
to resolve a policy during upload.
## Client configuration
`apps/mobile/app.config.js` is a dynamic Expo config that spreads `app.json` and
layers on the `updates` block. `app.json` remains the static source of truth for
`version`, `ios.buildNumber`, and `android.versionCode`, which EAS rewrites
during a release build.
Resolved configuration:
| Key | Value |
| --- | --- |
| `updates.url` | `https://ota.apps.jbrunton.co.uk/manifest` |
| `updates.enabled` | `true` |
| `updates.checkAutomatically` | `ON_LOAD` |
| `updates.fallbackToCacheTimeout` | `5000` |
| `updates.codeSigningCertificate` | `./certs/certificate.pem` |
| `updates.codeSigningMetadata` | `{ keyid: 'main', alg: 'rsa-v1_5-sha256' }` |
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
| `updates.requestHeaders['xprem-branch']` | `''` |
| `runtimeVersion` | Explicit public version, currently `1.0.7` |
`expo-updates` only sends request headers that were declared at build time, so
`xprem-branch` is declared even though it is empty. Removing it would not disable
branch surfing, it would strip the header from every poll for the life of the
install.
`RELEASE_CHANNEL` is set per profile in `apps/mobile/eas.json`. The config throws
a descriptive error whenever `RELEASE_CHANNEL` is set but the committed
certificate is missing or is the placeholder, so a misconfigured build fails at
config resolution rather than shipping a client that can never validate a
manifest.
### Local development
The dev server serves bundles directly and no signed manifest exists, so disable
code signing when running a dev client:
```bash
cd apps/mobile
DISABLE_CODE_SIGNING=1 npx expo start --dev-client
```
## Code signing
XPREM signs every manifest with an RSA private key held by the server. The app
embeds only the matching public certificate.
- `apps/mobile/certs/certificate.pem` is the public certificate. It is public key
material and is committed.
- `private-key.pem` belongs to the update server only. `apps/mobile/certs/.gitignore`
blocks it. It must never be committed or kept in a developer checkout.
The keypair is generated once, on the machine that configures the server:
```bash
npx eoas generate-certs
```
The certificate can also be downloaded from the XPREM dashboard App Info page for
the Sauci app.
## Native projects
`apps/mobile/ios` and `apps/mobile/android` are committed but generated by Expo
CNG, and `docs/releasing.md` treats `expo prebuild` as a source change rather
than a release step. The checked-in native projects embed the production updates
configuration:
- `ios/Sauci/Supporting/Expo.plist` enables updates and contains the manifest
URL, runtime version, request headers, code-signing metadata, and public
certificate.
- `android/app/src/main/AndroidManifest.xml` enables updates and contains the
same production configuration; `android/app/src/main/res/values/strings.xml`
supplies its resolved runtime version.
The `expo-updates` config plugin wrote those values from the dynamic config as a
reviewed source change. When future changes intentionally affect native config,
regenerate and review the native diff separately:
```bash
cd apps/mobile
RELEASE_CHANNEL=production npx expo prebuild
git diff # retain only reviewed expo-updates native changes
```
A build produced before this native source change will not check for updates.
## Publishing an update
Publishing uses the XPREM CLI, `eoas`, pinned as a dev dependency of
`apps/mobile`. Authentication is an app-scoped API token generated in the XPREM
dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
```bash
cd apps/mobile
# Verify the exact export environment without contacting the update server.
npm run ota:preflight:production
# Staging: consumed by builds made with the EAS `preview` profile.
EOO_TOKEN=<token> npm run ota:publish:staging
# Production.
EOO_TOKEN=<token> npm run ota:publish:production
```
The scripts resolve the matching EAS build profile into EOAS's export subprocess.
EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load
profile values from `eas.json`. The wrapper injects the selected profile's public
configuration, including the required API and hosted Auth values, sets the
matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print
configuration values.
Production public API/Auth values come from the checked-in production profile.
Staging uses the preview API value and requires the designated non-production
Auth URL and anonymous key in the invoking environment; it rejects the production
Auth origin. Run its safe export preflight with:
```bash
EXPO_PUBLIC_SUPABASE_URL=https://itbzhrvlgvdmzbnhzhyx.supabase.co \
EXPO_PUBLIC_SUPABASE_ANON_KEY=<non-production-anon-key> \
npm run ota:preflight:staging
```
`npm run ota:doctor:production` performs EOAS's read-only manifest probes using
the production profile. It does not export or upload an update. Use the staging
doctor command with the same non-production Auth environment as its preflight.
Useful flags to pass through to a publish command:
| Flag | Purpose |
| --- | --- |
| `--platform ios\|android\|all` | Defaults to `all`. |
| `-m "<text>"` | Update description shown in the dashboard. |
| `--rollout-percentage <1-99>` | Progressive rollout to a fraction of devices. |
| `--nonInteractive` | Required in CI. |
`eoas publish` refuses to run against a dirty git working tree. Commit first
rather than reaching for `--disableRepositoryCheck` or `EAS_NO_VCS=1`; the
published update records the commit it came from.
Publish only from a commit that has passed `npm run verify:full`. An OTA update
reaches installed devices without store review, so it carries the same risk as a
production deploy and none of the review latency.
### Verifying a published update
```bash
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-platform: ios"
```
## Rollback
Two mechanisms, in order of preference:
1. **Repoint the channel.** In the XPREM dashboard, map the affected channel to a
branch holding the last known good update. This takes effect at each app's
next update check and needs no rebuild and no republish.
2. **Roll back from the CLI.**
```bash
cd apps/mobile
EOO_TOKEN=<token> npm run ota:rollback
```
`eoas rollback` publishes a rollback directive for a branch and runtime
version, sending clients back to the bundle embedded in the store build.
`eoas republish` can instead re-serve a specific earlier update.
A bad update that prevents the app from starting cannot be fixed over the air on
devices that never complete a launch. Keep `fallbackToCacheTimeout` short and
treat a startup-path change as store-release risk, not OTA risk.
## Server registration
The Sauci app is registered on the XPREM server; the deployment itself is
documented in the homelab repo (`unraid/docs/xprem-ota.md`).
| Value | State |
| --- | --- |
| App id | `9a66f3c2-fda7-416f-87e2-6db833b32e9d`, hardcoded in `apps/mobile/app.config.js` (non-secret) |
| Code-signing certificate | Committed at `apps/mobile/certs/certificate.pem` (public half; CN=Sauci, valid to 2036, SHA-256 fingerprint `DF:C9:17:A9:85:02:DD:EB:86:06:2B:64:8D:73:D2:8C:A2:AA:29:F0:AC:62:A8:76:62:A1:6B:A2:42:B0:3D:71`) |
| Signing private key | Never leaves the server: keys mode `database`, sealed in the server's Postgres under its master key |
| Publish token | `XPREM_SAUCI_API_KEY` in the homelab repo `.env`; export as `EOO_TOKEN` when publishing |
| Channels | `staging` and `production` created server-side, mapped 1:1 to branches of the same names. `development` intentionally has no server channel — dev clients run with `DISABLE_CODE_SIGNING=1` against the packager |
Remaining before updates flow: cut a new store build from this checked-in native
source. Only builds produced after that point can receive updates.