Skip to content

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)

  1. Match the toolchain matrix (rbenv/nvm/Xcode as needed).
  2. task native:setupbundle install (fastlane + cocoapods, pinned).
  3. gcloud auth login and gcloud auth application-default login (the second is what match's GCS storage reads).
  4. Android: JDK 17 (brew install --cask temurin@17) and the Android SDK (Android Studio installs it to ~/Library/Android/sdk), then export ANDROID_HOME in your shell profile:
    echo 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrc
    
    Gradle needs ANDROID_HOME; a local.properties won't do (each build's expo prebuild --clean regenerates android/).
  5. 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 in frontend/native-build.json as monotonic integers (stores reject a reused value, so every binary needs a fresh one even at the same marketing version).
    task native:version:build              # +build number / versionCode (same marketing version)
    task native:version:bump -- patch      # + marketing version (patch|minor|major)
    
    The beta lanes run native:version:build for you. app.config.js reads all three at build time. Commit native-build.json (and package.json on 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 rebuild needed" issue. Two servers, baked at build time via 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):

  1. 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
    
  2. Set the repo variable OTA_ENABLED=true (same page → Variables).
  3. Confirm the Xavia dev deployment is configured with tomoda-ota-signing-key (else the code-signed binary rejects every update).
  4. Cut a fresh Android beta so the shipped binary carries expo-updates and its new fingerprint, and commit the refreshed native-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:

  1. Bump the SDK/dep; run npx expo install --fix; update package-lock.json.
  2. Update the toolchain matrix above + the pin files if the SDK requires new Xcode/JDK/CocoaPods versions.
  3. npx expo prebuild --clean locally to shake out native config issues.
  4. task native:fingerprint — expect a new runtimeVersion. That means existing binaries won't receive further OTA until a new binary ships.
  5. task native:doctor, then cut a fresh beta on both platforms and commit native-build.json + native-fingerprint.*.txt.

See also