mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Merge self-hosted XPREM OTA updates wiring into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
2664791233
9 files changed
+2485
-39
No files matched your search
@@ -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': '',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ historical notes. Agents should load only the section relevant to their task.
|
||||
- [Subscriptions](subscription-system.md)
|
||||
- [Redemption codes](product/redemption.md)
|
||||
- [Mobile release process](releasing.md)
|
||||
- [Mobile over-the-air updates](mobile-ota.md)
|
||||
- [Dares loop](dares-loop.md)
|
||||
- [Couples quiz](quiz.md)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
- **Java JDK 17** - Required for Android Gradle builds
|
||||
|
||||
Generated
+2097
-36
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user