feat(mobile): wire expo-updates to the self-hosted XPREM OTA server

Install expo-updates and the eoas CLI, and configure the client against
https://ota.apps.jbrunton.co.uk.

app.json stays the static source of truth for version, buildNumber, and
versionCode, which EAS rewrites during a release build. A new app.config.js
spreads it and layers on the updates block, because XPREM selects a release
channel from a build-time request header and that value differs per EAS
build profile.

- updates.url points at the XPREM manifest endpoint, /manifest
- runtimeVersion uses the appVersion policy, so the runtime version is the
  store-visible expo.version and a native change requires a store build
- expo-channel-name comes from RELEASE_CHANNEL, set per EAS build profile:
  development, staging (preview profile), and production
- expo-app-id and an empty xprem-branch header are declared at build time,
  since expo-updates only sends headers that existed when the build was made
- code signing uses keyid main and rsa-v1_5-sha256 against a certificate at
  certs/certificate.pem, skipped when DISABLE_CODE_SIGNING is set so a dev
  client can run against the packager

The XPREM app id and the code-signing certificate are still placeholders
pending the update server deployment. The config throws a descriptive error
whenever RELEASE_CHANNEL is set while either is unresolved, so a
misconfigured build fails at config resolution rather than shipping a client
that can never validate a manifest.

The native projects are Expo CNG output and are left untouched: the
expo-updates config plugin embeds the real app id and certificate contents,
so they are regenerated as a reviewed source change once both values land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 20:12:51 +01:00
1 parent f38b6f14fa
commit f4db567267
5 files changed
+2224 -39

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
// 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, shown in the XPREM dashboard under App Info.
* PLACEHOLDER until the update server deployment reports the real value.
*/
const OTA_APP_ID_PLACEHOLDER = 'REPLACE_WITH_XPREM_APP_ID';
const OTA_APP_ID = process.env.XPREM_APP_ID || OTA_APP_ID_PLACEHOLDER;
/** 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() {
if (OTA_APP_ID === OTA_APP_ID_PLACEHOLDER) {
throw new Error(
`RELEASE_CHANNEL=${releaseChannel} requires a real XPREM app id. ` +
'Set XPREM_APP_ID, or replace the placeholder in apps/mobile/app.config.js. ' +
'See docs/mobile-ota.md.',
);
}
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': '',
},
},
});
+3
View File
@@ -0,0 +1,3 @@
# The update-signing private key belongs to the XPREM server, never to the app.
private-key.pem
*.key
+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",
@@ -87,6 +92,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",
+2097 -36
View File
File diff suppressed because it is too large. Load diff