mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
267 lines
11 KiB
Markdown
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.
|