mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
f38b6f14fa
commit
f4db567267
5 files changed
+2224
-39
No files matched your search
@@ -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': '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# The update-signing private key belongs to the XPREM server, never to the app.
|
||||||
|
private-key.pem
|
||||||
|
*.key
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+2097
-36
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user