Native Release¶
The Tomoda native iOS and Android apps are built with fastlane, no EAS. Every
build runs through a single entrypoint so a laptop and CI produce the same
signed binary: Taskfile → scripts/build-with-secrets.sh → bundle exec
fastlane. Signing is shared and reproducible (iOS via fastlane match, Android
via a shared upload keystore), and all secrets come from GCP Secret Manager.
This page is the conceptual + operational flow. For the signing material and the one-time credential setup, see Signing. For portal-side store management see App Store and Play Store.
The model¶
| Layer | Ships | When | How |
|---|---|---|---|
| Native binary | .ipa / .aab |
native dep/SDK change, or a store submission | fastlane (below) → TestFlight / Play |
| OTA JS bundle | JS + assets | every other merge | expo export → Xavia (OTA) |
ios/ and android/ are not committed — every build regenerates them from
app.config.js (expo prebuild --clean), so nothing machine-local leaks in.
Reproducibility: the toolchain matrix¶
Reproducible builds require every machine (and CI) to run the same tool versions. These are pinned in the repo; treat this table as the source of truth and update it (plus the pin files) together.
| Tool | Version | Pinned in |
|---|---|---|
| Node | 24.15.0 | frontend/.nvmrc |
| Ruby | 4.0.5 | frontend/.ruby-version |
| Xcode | 26.6 | frontend/.xcode-version |
| JDK | 17 (Temurin) | CI setup-java; document locally |
| CocoaPods | ~1.16 | frontend/Gemfile |
| fastlane | ~2.226 | frontend/Gemfile |
| Expo SDK / RN | pinned | frontend/package-lock.json |
task native:doctor checks the installed toolchain against these, that gcloud
is authenticated, and that the signing secrets are reachable. Run it first on any
new machine.
One-time setup (per machine)¶
- Match the toolchain matrix (rbenv/nvm/Xcode as needed).
task native:setup—bundle install(fastlane + cocoapods, pinned).gcloud auth loginandgcloud auth application-default login(the second is whatmatch's GCS storage reads).- Android: JDK 17 (
brew install --cask temurin@17) and the Android SDK (Android Studio installs it to~/Library/Android/sdk), then exportANDROID_HOMEin your shell profile:Gradle needsecho 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrcANDROID_HOME; alocal.propertieswon't do (each build'sexpo prebuild --cleanregeneratesandroid/). task native:doctor— expect all green except lanes you haven't set up yet.
First-time credential setup (match bucket, keystore, ASC key, Play service account) is a separate, mostly one-person job — see Signing.
Cutting a beta¶
Betas are the dev-facing channels: TestFlight internal (iOS) and Play Internal testing (Android). Both bump the build number, build a signed artifact, upload it, and refresh the OTA fingerprint.
task native:beta:ios # match-signed .ipa -> TestFlight internal
task native:beta:android # signed .aab -> Play Internal testing
task native:beta # both
Then commit the bumped native-build.json + refreshed native-fingerprint.*.txt.
Testers update through TestFlight / the Play Store. iOS internal groups skip beta
review; external groups need Beta App Review on first submission.
Production¶
Promote a build already vetted in beta (no rebuild needed):
task native:release:ios # submit the latest TestFlight build for App Store review
task native:release:android # promote the current Play Internal build to production (staged)
Pair a production cut with the vX.Y.Z release tag so the store binary and the
prod OTA bundle (published on that tag to ota.tomoda.life) share the same
fingerprint. See App Store / Play Store for the
portal steps (metadata, review, staged rollout).
Versioning — two numbers, don't conflate them¶
- Marketing version (
frontend/package.json, e.g.1.2.3) — what the stores show. The iOS build number + Android versionCode live infrontend/native-build.jsonas monotonic integers (stores reject a reused value, so every binary needs a fresh one even at the same marketing version).The beta lanes runtask native:version:build # +build number / versionCode (same marketing version) task native:version:bump -- patch # + marketing version (patch|minor|major)native:version:buildfor you.app.config.jsreads all three at build time. Commitnative-build.json(andpackage.jsonon a bump) with the release. - Deployment tag (
vX.Y.Z) — the backend + web release + the prod OTA gate, cut by the Release workflow. Independent of the marketing version; pair them at a production cut. See Deployment.
Local == CI¶
The Native Release workflow (.github/workflows/native-release.yml) runs the
same task native:beta:* lanes on a runner. CI needs only GCP auth
(GCP_WIF_PROVIDER + GCP_SERVICE_ACCOUNT); every signing/upload secret is
pulled from GCP Secret Manager by the same script, never stored as a GitHub
secret. Android runs on ubuntu-latest; the iOS job targets macos-14 but is
gated on the repo variable NATIVE_IOS_CI (off until a Mac runner is funded —
cut iOS locally until then). Trigger via Run workflow (choose platform +
environment) or a vX.Y.Z tag.
OTA updates (self-hosted)¶
Native binaries are built rarely (only on a native change). Most merges are pure
JS/TS and ship over-the-air via a self-hosted
Xavia OTA server (expo-updates manifest
server, deployed in-cluster). No store round-trip.
app.config.js uses runtimeVersion: { policy: "fingerprint" }, computed from
the native project. Any native change → a different fingerprint → a different
runtimeVersion, and an OTA bundle only reaches binaries whose runtimeVersion
matches. So a native change can never crash an old binary via OTA; it simply
needs a fresh binary. No one bumps a version by hand.
iOS and Android fingerprints differ, so each is tracked in its own file:
frontend/native-fingerprint.{android,ios}.txt hold the fingerprint each
platform's current binary was built with (the beta lanes refresh the built
platform's file via @expo/fingerprint). The OTA workflow computes both
runtimeVersions, publishes the same JS bundle under each (Xavia serves the
right per-platform bundle by runtimeVersion), and per platform compares to that
file: unchanged → the bundle reaches existing binaries; changed → it
opens a "Native EXPO_PUBLIC_OTA_URL: ota-dev.tomoda.life and ota.tomoda.life. The
beta lanes bake the dev URL (build-with-secrets.sh sets it for beta-*),
so Internal-testing binaries pull JS from ota-dev; release lanes keep the prod
default.
Manifests are code-signed: the binary carries the public cert
(frontend/certs/ota-public.pem) and the Xavia server signs each manifest with
the private key (tomoda-ota-signing-key). An unsigned manifest is rejected, so
the Xavia deployment must hold that key.
Enabling OTA¶
expo-updates is a dependency, so a fresh binary carries the OTA runtime. To
turn on publishing (the OTA workflow is dormant until then):
- Add the Xavia upload keys as GitHub Actions secrets (repo → Settings →
Secrets and variables → Actions):
gcloud secrets versions access latest --secret=tomoda-ota-dev-upload-key --project=development-485000 # → XAVIA_DEV_UPLOAD_KEY gcloud secrets versions access latest --secret=tomoda-ota-prod-upload-key --project=development-485000 # → XAVIA_PROD_UPLOAD_KEY - Set the repo variable
OTA_ENABLED=true(same page → Variables). - Confirm the Xavia dev deployment is configured with
tomoda-ota-signing-key(else the code-signed binary rejects every update). - Cut a fresh Android beta so the shipped binary carries
expo-updatesand its new fingerprint, and commit the refreshednative-fingerprint.*.txt.
After that, a merge to main publishes JS to ota-dev on the Android
runtimeVersion, and Internal-testing devices update on next launch.
Upgrading the Expo SDK (or any native dep)¶
A native dependency bump changes the fingerprint, so it needs a fresh binary:
- Bump the SDK/dep; run
npx expo install --fix; updatepackage-lock.json. - Update the toolchain matrix above + the pin files if the SDK requires new Xcode/JDK/CocoaPods versions.
npx expo prebuild --cleanlocally to shake out native config issues.task native:fingerprint— expect a newruntimeVersion. That means existing binaries won't receive further OTA until a new binary ships.task native:doctor, then cut a fresh beta on both platforms and commitnative-build.json+native-fingerprint.*.txt.
See also¶
- Signing — match on GCS, upload keystore, Play App Signing, the secret inventory, first-time setup.
- App Store / Play Store — portal-side management.
- Native Testing — simulator / device QA.