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 forcom.tomoda.app. The App Store Connect record already exists (app ID6765908734). See App Store. - Android: the release keystore + a Gradle
signingConfigwired intoandroid/appsobundleReleaseproduces a signed.aab. See Play Store. gcloudauthenticated (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.
task native:version:build— fresh build number.task build:ios— produces the signed.ipa.- Upload the
.ipawith Transporter, or:xcrun altool --upload-app -t ios -f frontend/build/ios/ipa/*.ipa \ -u <apple-id> -p <app-specific-password> - 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.
- Testers update through the TestFlight app.
task native:fingerprint, then commitnative-build.json+native-fingerprint.txt.
Releasing to Google Play internal testing¶
The dev-facing Android channel, parallel to TestFlight.
task native:version:build.task build:android— produces the signed.aab.- Play Console → Testing → Internal testing → Create new release → upload
the
.aab→ review → roll out. - Testers on the internal track update through the Play Store.
task native:fingerprint, then commitnative-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):
- Promote a build already vetted in TestFlight (no rebuild needed), or
task native:version:bump -- patchthentask build:iosfor a fresh one. - App Store Connect → create a new App Store version → select the build → fill in the metadata → Submit for Review.
- After approval, release manually or set automatic release. See App Store.
Android (Play production):
- Play Console → Production → Create new release.
- Promote the
.aabthat passed internal testing, or upload a freshtask build:androidartifact. - 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:
- dev —
ota-dev.tomoda.life. TheOTACI workflow publishes here on every merge tomain; TestFlight-internal / Play-internal binaries pull it on next launch. - prod —
ota.tomoda.life. The CI publishes here on thevX.Y.Ztag (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.txtto 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¶
npx expo install expo-updates(pins the SDK-55 version).- From the devops Terraform output
ota_signing_certificate_pem, commit the cert tofrontend/certs/ota-public.pem(one cert covers both servers). - 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 variableOTA_ENABLED=trueonce 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 toota-dev. - push tag
vX.Y.Z→ same → publish toota(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¶
eas build --profile production --platform iosfinishes and uploads to App Store Connect.- Apple processes the build (~5–30 min).
- In App Store Connect → TestFlight, the new build appears under your app. Add it to an internal or external test group.
- External groups require beta review (typically a day on first submission, faster after).
- 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¶
eas submit --profile production --platform androiduploads the AAB to Play Console.- The release goes to the internal testing track by default. Pass
--track productionor use Play Console to promote. - 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¶
- Play Store — Android signing, SHA-1 fingerprints, keystore management
- Native Testing — simulator / device setup for QA
frontend/eas.json— the authoritative build/submit configuration