Merge self-hosted XPREM OTA updates wiring into staging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 20:19:07 +01:00
commit 2664791233
9 files changed
+2485 -39

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// Dynamic Expo config.
//
// `app.json` stays the static source of truth for everything that is stable
// across environments, including the `version`, `ios.buildNumber`, and
// `android.versionCode` fields that EAS rewrites during a release build. This
// file only layers the over-the-air update configuration on top, because the
// XPREM update server selects a release channel from a build-time request
// header and that value differs per EAS build profile.
//
// See `docs/mobile-ota.md`.
/* global __dirname */
const fs = require('fs');
const path = require('path');
const appJson = require('./app.json');
/** Base URL of the self-hosted XPREM update server. */
const OTA_SERVER_URL = 'https://ota.apps.jbrunton.co.uk';
/** XPREM manifest endpoint. XPREM serves the Expo Updates protocol here. */
const OTA_MANIFEST_URL = `${OTA_SERVER_URL}/manifest`;
/**
* XPREM application UUID for Sauci, shown in the XPREM dashboard under App
* Info. Not a secret; overridable for pointing a build at another app.
*/
const OTA_APP_ID = process.env.XPREM_APP_ID || '9a66f3c2-fda7-416f-87e2-6db833b32e9d';
/** Public code-signing certificate downloaded from the XPREM dashboard. */
const CODE_SIGNING_CERTIFICATE_PATH = './certs/certificate.pem';
/** Marker string present only in the committed placeholder certificate. */
const CERTIFICATE_PLACEHOLDER_MARKER = 'XPREM-PLACEHOLDER-CERTIFICATE';
/**
* Release channel bound to the build. XPREM maps a channel to a branch server
* side, so a channel can be repointed at a different branch without rebuilding.
* Set per EAS build profile in `eas.json` and exported before `eoas publish`.
*/
const releaseChannel = process.env.RELEASE_CHANNEL || 'development';
/**
* Code signing is skipped for local development, where the packager serves the
* bundle directly and no signed manifest exists:
* DISABLE_CODE_SIGNING=1 npx expo start --dev-client
*/
const codeSigningDisabled = Boolean(process.env.DISABLE_CODE_SIGNING);
function assertUpdateConfigIsReal() {
const certificatePath = path.join(__dirname, CODE_SIGNING_CERTIFICATE_PATH);
const certificate = fs.existsSync(certificatePath)
? fs.readFileSync(certificatePath, 'utf8')
: '';
if (!certificate || certificate.includes(CERTIFICATE_PLACEHOLDER_MARKER)) {
throw new Error(
`RELEASE_CHANNEL=${releaseChannel} requires the real XPREM code-signing ` +
`certificate at apps/mobile/${CODE_SIGNING_CERTIFICATE_PATH}. ` +
'Download it from the XPREM dashboard App Info page. See docs/mobile-ota.md.',
);
}
}
// A build or publish always names its channel explicitly. Fail loudly there
// rather than shipping a build that can never validate a signed manifest.
if (process.env.RELEASE_CHANNEL) {
assertUpdateConfigIsReal();
}
module.exports = () => ({
...appJson.expo,
// Updates are gated on the app version. A JavaScript-only change ships over
// the air; anything that changes native code must ship a new store build with
// a bumped `version`, which starts a fresh runtime version on the server.
runtimeVersion: {
policy: 'appVersion',
},
updates: {
enabled: true,
url: OTA_MANIFEST_URL,
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 5000,
codeSigningCertificate: codeSigningDisabled
? undefined
: CODE_SIGNING_CERTIFICATE_PATH,
codeSigningMetadata: codeSigningDisabled
? undefined
: { keyid: 'main', alg: 'rsa-v1_5-sha256' },
// expo-updates only sends headers that were declared at build time, so
// every header XPREM may need has to be present here even when empty.
requestHeaders: {
'expo-channel-name': releaseChannel,
'expo-app-id': OTA_APP_ID,
// Branch surfing: lets a non-production build be pointed at another
// branch at runtime. Empty means the channel mapping decides.
'xprem-branch': '',
},
},
});
+6
View File
@@ -0,0 +1,6 @@
# The update-signing private key belongs to the XPREM server, never to the app.
private-key.pem
*.key
# The public certificate must be committed despite the root *.pem rule: the
# expo-updates plugin embeds it into every build.
!certificate.pem
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
+7 -2
View File
@@ -7,19 +7,24 @@
"development": { "development": {
"node": "20.19.4", "node": "20.19.4",
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal",
"env": {
"RELEASE_CHANNEL": "development"
}
}, },
"preview": { "preview": {
"node": "20.19.4", "node": "20.19.4",
"distribution": "internal", "distribution": "internal",
"env": { "env": {
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app" "EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app",
"RELEASE_CHANNEL": "staging"
} }
}, },
"production": { "production": {
"node": "20.19.4", "node": "20.19.4",
"autoIncrement": true, "autoIncrement": true,
"env": { "env": {
"RELEASE_CHANNEL": "production",
"EXPO_PUBLIC_API_URL": "https://api.sauci.app", "EXPO_PUBLIC_API_URL": "https://api.sauci.app",
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co", "EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI", "EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
+7 -1
View File
@@ -18,7 +18,11 @@
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local", "release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local", "release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
"release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local", "release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
"release:android:upload": "bundle exec fastlane android upload" "release:android:upload": "bundle exec fastlane android upload",
"ota:publish:staging": "RELEASE_CHANNEL=staging eoas publish --branch staging",
"ota:publish:production": "RELEASE_CHANNEL=production eoas publish --branch production",
"ota:rollback": "eoas rollback",
"ota:doctor": "eoas doctor"
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^0.1.6", "@bacons/apple-targets": "^0.1.6",
@@ -62,6 +66,7 @@
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8", "expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.20",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -88,6 +93,7 @@
"@types/react": "~19.1.10", "@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eoas": "^3.1.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
+1
View File
@@ -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)
+243
View File
@@ -0,0 +1,243 @@
# 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 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`). What remains is the native
prebuild and a store build; 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:
```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 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 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.
With the real app id and certificate now committed, 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.
## 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: 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.
+5
View File
@@ -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
+2097 -36
View File
File diff suppressed because it is too large. Load diff