Skip to content

Native Release

The Tomoda native iOS and Android apps are built locally from a developer's machine and submitted to the App Store / Google Play by hand. The build process pulls Sentry secrets from GCP Secret Manager at build time via the wrapper script at frontend/scripts/build-with-secrets.sh — see Local build (current default) below.

This page covers the iOS + Android flow at the conceptual level. For portal-side management of each store, see Play Store and App Store. For local dev environment setup, see Local development.

EAS is documented as the alternative path

The sections below cover EAS (Expo Application Services) — Expo's hosted-build platform — as an alternative we may adopt later. Tomoda doesn't have an EAS account today; you can ignore the EAS-specific instructions until that decision is revisited.

Local build (current default)

Native binaries are built on a developer machine with the native toolchains (Xcode for iOS, Gradle for Android) and driven by Taskfile targets. ios/ and android/ are not committed; every build regenerates them from app.config.js via expo prebuild.

Task Produces For
task native:version:build bumps iOS build number + Android versionCode before any store upload
task native:version:bump -- patch\|minor\|major bumps marketing version + build number a user-facing version change
task build:ios signed App Store .ipa in frontend/build/ios/ipa/ TestFlight / App Store
task build:android signed Play .aab in frontend/android/app/build/outputs/bundle/release/ Play internal / production
task native:fingerprint writes frontend/native-fingerprint.txt records the binary's OTA runtimeVersion
task release:native runs all of the above in order a full binary cut

All build targets go through frontend/scripts/build-with-secrets.sh, which pulls tomoda-sentry-dsn and tomoda-sentry-auth-token from GCP Secret Manager into the build environment (no secret is written to disk) and sets EXPO_PUBLIC_ENV=prod. See scripts.md.

Versioning

Marketing version is single-sourced from frontend/package.json. The iOS build number and Android versionCode live in frontend/native-build.json and are monotonic integers: the App Store and Play reject a reused value, so every binary needs a fresh one even at the same marketing version. native:version:* increment them; app.config.js reads all three at build time. Commit both files with the release.

Prerequisites

  • iOS: Apple Developer account signed into Xcode, automatic signing for team 33TV825K7S, and an App Store distribution cert + provisioning profile for com.tomoda.app. The App Store Connect record already exists (app ID 6765908734). See App Store.
  • Android: the release keystore + a Gradle signingConfig wired into android/app so bundleRelease produces a signed .aab. See Play Store.
  • gcloud authenticated (gcloud auth login) for the Sentry secrets.

Releasing to TestFlight (iOS internal / beta)

The dev-facing iOS channel. Binaries here point at the dev API and pull OTA from ota-dev.tomoda.life.

  1. task native:version:build — fresh build number.
  2. task build:ios — produces the signed .ipa.
  3. Upload the .ipa with Transporter, or:
    xcrun altool --upload-app -t ios -f frontend/build/ios/ipa/*.ipa \
      -u <apple-id> -p <app-specific-password>
    
  4. App Store Connect processes the build (about 5 to 30 minutes). In TestFlight, add it to an internal test group (no beta review) so testers get it immediately. External groups need Beta App Review on first submission.
  5. Testers update through the TestFlight app.
  6. task native:fingerprint, then commit native-build.json + native-fingerprint.txt.

Releasing to Google Play internal testing

The dev-facing Android channel, parallel to TestFlight.

  1. task native:version:build.
  2. task build:android — produces the signed .aab.
  3. Play Console → Testing → Internal testing → Create new release → upload the .aab → review → roll out.
  4. Testers on the internal track update through the Play Store.
  5. task native:fingerprint, then commit native-build.json + native-fingerprint.txt.

Production release (App Store + Play production)

Production native binaries are controlled and released by hand through the stores. Pair a native production cut with the vX.Y.Z release tag so the store binary and the production OTA bundle (published on that tag to ota.tomoda.life) share the same fingerprint runtimeVersion.

iOS (App Store):

  1. Promote a build already vetted in TestFlight (no rebuild needed), or task native:version:bump -- patch then task build:ios for a fresh one.
  2. App Store Connect → create a new App Store version → select the build → fill in the metadata → Submit for Review.
  3. After approval, release manually or set automatic release. See App Store.

Android (Play production):

  1. Play Console → Production → Create new release.
  2. Promote the .aab that passed internal testing, or upload a fresh task build:android artifact.
  3. Set a staged rollout percentage, then roll out. Halt the rollout from the console if early metrics regress. See Play Store.

After a production binary ships, run task native:fingerprint and commit the updated files so the OTA workflow knows the live production runtimeVersion.

OTA updates (self-hosted)

Native binaries are built locally and rarely (only on a native change). Most merges are pure JS/TS and ship over-the-air via a self-hosted Xavia OTA server (an expo-updates manifest server backed by Postgres + a GCS bucket, deployed in-cluster; see the devops kubernetes/apps/xavia-ota page). No store round-trip.

Two layers, two cadences:

Layer Ships When How
Native binary .ipa / .aab native dep/SDK change, or a store submission local build (above) → TestFlight / Play
OTA JS bundle JS + assets every other merge expo export → publish to Xavia

Two servers (Xavia has no channels — each instance serves the latest bundle per runtimeVersion). Which server a binary talks to is baked at build time via EXPO_PUBLIC_OTA_URL, exactly like the API URL:

  • devota-dev.tomoda.life. The OTA CI workflow publishes here on every merge to main; TestFlight-internal / Play-internal binaries pull it on next launch.
  • prodota.tomoda.life. The CI publishes here on the vX.Y.Z tag (the controlled gate). Store binaries pull it.

The safety rule: runtimeVersion (automatic)

app.config.js uses runtimeVersion: { policy: "fingerprint" } — it's 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, and old binaries keep their last compatible bundle. No one bumps a version by hand.

When to cut a new binary (tracked automatically)

frontend/native-fingerprint.txt holds the fingerprint the current binary was built with. The OTA workflow compares it to the live fingerprint each run:

  • unchanged → the OTA bundle reaches existing binaries; nothing to do.
  • changed → the workflow opens a GitHub issue "Native binary rebuild needed". Build a fresh binary (below), submit it, then update native-fingerprint.txt to the new hash.

Rule of thumb: if npx expo prebuild would produce different native code, cut a binary; otherwise it ships OTA.

One-time setup

  1. npx expo install expo-updates (pins the SDK-55 version).
  2. From the devops Terraform output ota_signing_certificate_pem, commit the cert to frontend/certs/ota-public.pem (one cert covers both servers).
  3. Add repo secrets XAVIA_DEV_UPLOAD_KEY / XAVIA_PROD_UPLOAD_KEY (gcloud secrets versions access latest --secret=tomoda-ota-{dev,prod}-upload-key), and set the repo variable OTA_ENABLED=true once the first binary is out (the workflow is a no-op until then).

Publishing (automatic)

The OTA GitHub Actions workflow does it — no manual step:

  • merge to main → tests gate → expo export (dev config) → publish to ota-dev.
  • push tag vX.Y.Z → same → publish to ota (prod).

Rollback: activate a prior release in the Xavia dashboard for that server.


EAS-based build (alternative, not adopted)

Build profiles

The build matrix is defined in frontend/eas.json:

Profile Use for Distribution Notes
development-simulator Local iOS simulator dev internal developmentClient: true, ios.simulator: true
development EAS dev client on a real device internal developmentClient: true
preview Internal QA / TestFlight beta / Play internal track internal Stand-alone build, debug-stripped
production App Store + Google Play release store autoIncrement: true on version code; production env vars baked in

Production env vars

The production profile pins two EXPO_PUBLIC_* values directly in eas.json:

"production": {
  "autoIncrement": true,
  "env": {
    "EXPO_PUBLIC_API_URL": "https://api.tomoda.life/api/v1",
    "EXPO_PUBLIC_WS_URL": "wss://api.tomoda.life/ws"
  }
}

Other EXPO_PUBLIC_* values (the Google OAuth client IDs in particular) come from the EAS Secret store or the developer's environment at build time. As with the web frontend, anything EXPO_PUBLIC_* is shipped to clients — never put a real secret behind one. See Secrets.

App version source

cli.appVersionSource: "remote" means EAS — not app.json — is the source of truth for the version code / build number. autoIncrement: true on the production profile means EAS bumps the build number on each production build. Marketing version (1.2.3) still comes from app.json.

Building

cd frontend

# Local sim build, instant
eas build --profile development-simulator --platform ios

# Real-device dev client (iOS + Android in parallel)
eas build --profile development --platform all

# Internal preview / TestFlight / Play internal
eas build --profile preview --platform all

# Production
eas build --profile production --platform all

Builds run on EAS infrastructure; you get a link to monitor progress and download the artifact. For production builds you'll also want to make sure you've run the test suite locally first (task test — see Testing).

Submitting

Once a production build is done, push it to the stores:

# iOS — App Store Connect
eas submit --profile production --platform ios

# Android — Google Play
eas submit --profile production --platform android

The submit configuration is at the bottom of eas.json:

"submit": {
  "production": {
    "ios": { "ascAppId": "6765908734" }
  }
}

This is the App Store Connect numeric app ID for tomoda — eas submit --platform ios uploads the latest production build to that record.

iOS — TestFlight workflow

  1. eas build --profile production --platform ios finishes and uploads to App Store Connect.
  2. Apple processes the build (~5–30 min).
  3. In App Store Connect → TestFlight, the new build appears under your app. Add it to an internal or external test group.
  4. External groups require beta review (typically a day on first submission, faster after).
  5. Once tested, promote to the App Store: create a new version (or attach to the in-progress one), select the build, fill in metadata, submit for review.

Android — Google Play workflow

  1. eas submit --profile production --platform android uploads the AAB to Play Console.
  2. The release goes to the internal testing track by default. Pass --track production or use Play Console to promote.
  3. Set the rollout percentage in Play Console for a staged rollout. Halt the rollout if early metrics look bad.

OTA updates (EAS Update)

For JS-only changes (no native code / dependency changes), you can ship an OTA update without going through the stores:

# Publish to the "production" channel
eas update --branch production --message "Fix calendar timezone bug"

Clients on a build subscribed to that channel pick up the update on their next launch. To revert:

eas update:rollback --branch production

OTA updates are bounded by Apple/Google rules — anything that materially changes app behavior or adds features outside the reviewed scope should go through a full native release.

Beta channels

The preview build profile (internal distribution) is the standard channel for beta testing — pre-release builds you want to put in front of a small group before pushing to TestFlight / Play. Distribute via the EAS internal-distribution page; testers install the build with a one-click link on iOS (UDID-provisioned) or APK on Android.

See also