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": {
"node": "20.19.4",
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"env": {
"RELEASE_CHANNEL": "development"
}
},
"preview": {
"node": "20.19.4",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app",
"RELEASE_CHANNEL": "staging"
}
},
"production": {
"node": "20.19.4",
"autoIncrement": true,
"env": {
"RELEASE_CHANNEL": "production",
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
"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: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: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": {
"@bacons/apple-targets": "^0.1.6",
@@ -62,6 +66,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.20",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@@ -88,6 +93,7 @@
"@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0",
"eoas": "^3.1.3",
"eslint": "^8.57.0",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",