mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
docs(mobile): document the XPREM over-the-air update flow
Add docs/mobile-ota.md covering how OTA works here, the branch and channel model, the appVersion runtime version policy and its store-build rule, the eoas publish commands per channel, both rollback paths, the pending native prebuild, and the exact placeholders the server deployment still has to fill. Index it and cross-link it from the release guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4db567267
commit
0ada812294
3 files changed
+251
No files matched your search
@@ -23,6 +23,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Subscriptions](subscription-system.md)
|
- [Subscriptions](subscription-system.md)
|
||||||
- [Redemption codes](product/redemption.md)
|
- [Redemption codes](product/redemption.md)
|
||||||
- [Mobile release process](releasing.md)
|
- [Mobile release process](releasing.md)
|
||||||
|
- [Mobile over-the-air updates](mobile-ota.md)
|
||||||
- [Dares loop](dares-loop.md)
|
- [Dares loop](dares-loop.md)
|
||||||
- [Couples quiz](quiz.md)
|
- [Couples quiz](quiz.md)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# 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). OTA never
|
||||||
|
replaces a store build; it only refreshes the JavaScript bundle and assets of an
|
||||||
|
already installed build.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The client is wired but not yet live. Two values from the server deployment are
|
||||||
|
still outstanding; see [Pending server values](#pending-server-values).
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
runtimeVersion: { policy: 'appVersion' }
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime version is therefore the `expo.version` field in `app.json`, for
|
||||||
|
example `1.0.6`. 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`.
|
||||||
|
|
||||||
|
`appVersion` is chosen over `fingerprint` deliberately. Sauci commits its native
|
||||||
|
projects and ships several config plugins and an Apple widget target, so a
|
||||||
|
fingerprint would churn on native edits that never reach a device, and it would
|
||||||
|
be harder to reason about from a store release. `appVersion` ties the runtime
|
||||||
|
version to the number already visible in App Store Connect and Google Play.
|
||||||
|
|
||||||
|
## 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` | `{ policy: 'appVersion' }` |
|
||||||
|
|
||||||
|
`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 app id or the
|
||||||
|
certificate is still a 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 native projects currently still carry the
|
||||||
|
updates-disabled defaults:
|
||||||
|
|
||||||
|
- `ios/Sauci/Supporting/Expo.plist`: `EXUpdatesEnabled` is `false`.
|
||||||
|
- `android/app/src/main/AndroidManifest.xml`: `expo.modules.updates.ENABLED` is
|
||||||
|
`false`.
|
||||||
|
|
||||||
|
The `expo-updates` config plugin writes the URL, the resolved runtime version,
|
||||||
|
the request headers, the code-signing metadata, and the certificate contents into
|
||||||
|
these files. Those values embed the real app id and certificate, so the native
|
||||||
|
projects are deliberately left untouched until both land.
|
||||||
|
|
||||||
|
Once the pending values below are in place, regenerate and commit the native
|
||||||
|
projects as a reviewed source change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mobile
|
||||||
|
RELEASE_CHANNEL=production npx expo prebuild
|
||||||
|
git diff # expect only expo-updates related native changes
|
||||||
|
```
|
||||||
|
|
||||||
|
A build produced before that prebuild 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts wrap `eoas publish --branch <branch>` with the matching
|
||||||
|
`RELEASE_CHANNEL`. Useful flags to pass through:
|
||||||
|
|
||||||
|
| 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.6" \
|
||||||
|
-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.
|
||||||
|
|
||||||
|
## Pending server values
|
||||||
|
|
||||||
|
The client wiring is complete apart from two values owned by the XPREM server
|
||||||
|
deployment. Both are non-secret.
|
||||||
|
|
||||||
|
| Value | Where it goes | How to obtain it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| XPREM app id (UUID) | `OTA_APP_ID_PLACEHOLDER` in `apps/mobile/app.config.js`, or the `XPREM_APP_ID` env var | XPREM dashboard, App Info page for the Sauci app |
|
||||||
|
| Code-signing certificate (PEM) | Replace the whole of `apps/mobile/certs/certificate.pem` | XPREM dashboard App Info download, or `npx eoas generate-certs` |
|
||||||
|
|
||||||
|
Also required, outside this repository:
|
||||||
|
|
||||||
|
- An `EOO_TOKEN` publish token per environment, stored as a secret.
|
||||||
|
- `development`, `staging`, and `production` channels created in the dashboard
|
||||||
|
and mapped to branches of the same names.
|
||||||
|
- The `private-key.pem` counterpart of the certificate installed in the server key
|
||||||
|
store.
|
||||||
|
|
||||||
|
After both values land: replace them, run the prebuild described in
|
||||||
|
[Native projects](#native-projects), commit the native diff, and cut a new store
|
||||||
|
build. Only builds produced after that point can receive updates.
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
This guide covers local release builds for Android and iOS.
|
This guide covers local release builds for Android and iOS.
|
||||||
|
|
||||||
|
JavaScript-only changes can also ship over the air between store releases. See
|
||||||
|
[Mobile over-the-air updates](mobile-ota.md). Anything that changes native code
|
||||||
|
still requires a store build with a bumped `expo.version`, because the OTA
|
||||||
|
runtime version follows the app version.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Java JDK 17** - Required for Android Gradle builds
|
- **Java JDK 17** - Required for Android Gradle builds
|
||||||
|
|||||||
Reference in new issue
Block a user