TailSpend Docs

Releasing the mobile app from your Mac

How to cut a production iOS + Android release from a developer machine, without going through the pipeline.

The compile runs on EAS's infrastructure, not your laptop, so no Xcode or Android SDK is needed. What runs locally is the orchestration: preflight checks, kicking off the builds, and recording that the release happened.

cd apps/mobile
npm run release:production:check   # preflight only, changes nothing
npm run release:production         # build + submit both platforms

One-time setup

1. Sign in to EAS

npx eas-cli@20.3.0 login

Anything that satisfies eas whoami works, including an EXPO_TOKEN in the environment.

2. Google Play service account key

Already done: the key is stored on EAS's servers (eas credentials → Android → Google Service Account), and eas submit uses it from any machine, CI included. Nothing needs to be on disk.

Don't add serviceAccountKeyPath to eas.json. A local path takes precedence over the stored key, and the CI runner has no such file, so every pipeline-driven native release fails at submission. That happened from 2026-09-17 to 2026-09-25. The preflight and tests/eas-submit-config.test.mjs both reject it now.

To rotate the key, create a new one in the Play Console (Setup → API access → Service accounts, Release manager role) and upload it with npx eas-cli@20.3.0 credentials from apps/mobile. If you download it first, put it in apps/mobile/credentials/, which is ignored by both git and EAS uploads, and delete it afterwards.

3. Apple credentials

submit.production.ios.ascAppId is already set to TailSpend's App Store Connect app id. The first submission from a new machine will ask you to authenticate with Apple; EAS stores the resulting credentials against the project, so later releases don't prompt.

For a fully non-interactive iOS submit, add an App Store Connect API key to EAS (eas credentials) instead of using an Apple ID.

Running a release

npm run release:production does the following, in order:

  1. Preflight. Fails fast rather than half-releasing — see below.
  2. eas build --platform all --profile production. Both platforms build on EAS. autoIncrement handles build numbers, so nothing needs bumping by hand.
  3. Auto-submit. --auto-submit-with-profile=production hands each finished artifact straight to EAS Submit server-side, which is the same mechanism the pipeline uses. There's no build id to copy and no second command to forget after a 30-minute build.
  4. Moves the mobile-branding-released tag. See Interaction with CI.

Expect roughly 20-40 minutes per platform, running in parallel. Progress is on the EAS dashboard.

Flags

Flag Effect
--dry-run Preflight only. Also npm run release:production:check.
--platform ios | android One platform. Also npm run release:ios / release:android.
--no-submit Build without submitting.
--no-wait Queue the builds and return immediately.
--skip-tag Don't move the branding tag.

Pass them through npm with --:

npm run release:production -- --platform ios --no-submit

What preflight checks, and why

Each check exists because of a specific way a release can appear to succeed while shipping the wrong thing.

Signed in to EAS. Otherwise eas build prompts mid-run, which is confusing in a scripted context.

Working tree is clean. A store build has to be traceable to one commit: build numbers auto-increment on EAS and the artifact long outlives the shell you started it from, so "which code is in build 47" needs a single answer. A dirty tree also makes the upload ambiguous — see the note on .easignore below.

HEAD matches origin/main. Releasing an unpushed or stale commit produces a store build that nobody can reproduce from main later.

No local Play key path in eas.json. It would override the key stored on EAS and break every CI release (see step 2).

It also prints the app version, the runtime version, and the Android track it is about to submit to, so a wrong target is visible before anything starts.

A trap worth knowing: .easignore overrides .gitignore

apps/mobile/.easignore exists, and when it does EAS uses it instead of .gitignore to decide what goes into the upload. Anything gitignored but not listed in .easignore is still uploaded to the build workers.

That is why credentials is listed in .easignore as well as .gitignore — a key downloaded there would otherwise be shipped to EAS with every build. Generated /ios and /android projects, local environment files and signing keys are also excluded so EAS regenerates native projects from the committed Expo configuration. Add both ignore entries for anything else secret under apps/mobile/.

Beta simulator and emulator candidate

Use EAS Build, platform: all, profile: preview-simulator, with automatic submission off. This profile inherits the preview environment, beta API and preview OTA channel, producing an Android APK and an iOS simulator app. Regular preview remains available for physical iOS devices. The current runtime is 1.2.8: its Android push-tap handoff requires a fresh native binary; an OTA cannot install that service into 1.2.7. See docs/notifications.md for signed beta acceptance and remaining production rollout gates.

The workflow runs Expo Doctor before queuing either native build, waits for EAS to finish, and validates both results. A queued build is not an acceptance pass. Install both artifacts and repeat real-worker delivery and account lifecycle checks before restoring production Client Release. Its existing native job must succeed before the OTA job runs.

To inspect what EAS will upload without building:

cd apps/mobile
npx eas-cli@20.3.0 build:inspect --platform android --stage archive \
  --profile preview-simulator --output /tmp/tailspend-eas-archive

Inspect a clean checkout: unrelated untracked repository-root files may still appear in a local monorepo archive. Never upload a dirty local credential export.

Interaction with CI

The branding tag. The nightly client release decides whether to rebuild native binaries by diffing apps/mobile/app.base.json and apps/mobile/assets/images against the mobile-branding-released tag (see .github/workflows/client-release.yml). A release from your laptop moves that tag too — otherwise the next nightly run would rebuild and resubmit the same binaries. That's why the tag is moved only when the release actually shipped: both platforms, submitted, and waited for. Any of --no-submit, --no-wait or a single --platform leaves the tag alone, and the script says so.

OTA vs native. JavaScript changes reach users through OTA updates and need no native release. The weekly client release publishes one for iOS and Android whenever mobile (or anything it depends on) changed since the mobile-ota-released tag, from the commit prod last verified. To ship sooner, run Client Release manually from the Actions tab (targets: mobile-ota), or include [mobile-ota] in a main commit's message. That explicit hotfix marker calls the same OTA-only release planner after both production integration and browser suites pass, releases exactly that verified commit, and advances the released tag only after publication succeeds. Ordinary commits keep the weekly schedule. Use the marker for JS/assets compatible with the installed runtime. For a coordinated release of all changed clients, a main merge commit marked [client-release] invokes Client Release with targets: all after the same production gates. The planner includes native binaries when the app version or native assets change, waits for both store submissions before OTA, and then records the released tags. This marker takes precedence over [mobile-ota] so two release planners cannot race. Existing desktop release controls still apply.

Main Pipeline and Client Release use GitHub's concurrency.queue: max with cancel-in-progress: false. This keeps up to 100 pending runs rather than the default single pending slot, which replaces a waiting run on the next push and can lose its explicit release markers. In-flight deployments are never cancelled by a new push. The two workflows use separate concurrency groups so a pipeline can wait for its reusable client release without blocking it. See GitHub's concurrency queue documentation.

OTA release recording explicitly checks for a successful plan and publication, even when no native build was needed. A skipped native ancestor must not skip the recording job. Failed, skipped or cancelled publication never moves the tag.

Guest-user release 1.3.1 bumps app.base.json and the mobile package together, creating a new runtime for the aligned SDK 58 native patch dependencies. Its OTA must follow its native builds; existing 1.3.0 installs need the new binary to receive this runtime.

Mobile 1.3.2 uses the Expo 58.0.20 patch alignment from #773. Both the app config and mobile package use 1.3.2 so the native dependency changes have a separate runtime. Both native builds/submissions must succeed before publishing its OTA; installed 1.3.1 clients keep their existing runtime until they receive the new binary. The coordinated release retains the local Android build option and the iOS cloud build, with the normal production gates.

Mobile 1.3.3 aligns with Expo 58.0.21 and its recommended SDK 58 native patch versions to unblock the backend deployment verification gate. The mobile package and app config advance together to give these native changes a separate OTA runtime. Both native builds must succeed before any 1.3.3 OTA publication; installed 1.3.2 clients keep their existing runtime until a new binary is released. This patch update does not publish a client release by itself: the normal weekly or explicit coordinated release remains responsible for native builds.

Mobile 1.3.4 updates the app's React Native dependency and the root workspace override to 0.88.1, as required by Expo SDK 58's current compatibility check. The lockfile also takes React Native's matching native tooling and upstream Hermes compiler patch. Both the package and app config advance together so this native change has a separate OTA runtime. The coordinated release must complete the production checks and both native builds/submissions before publishing OTA; existing 1.3.3 installs require the new binary. This unblocks the dependency gate that stopped rollout of the timeout/recovery improvements in #783.

Launcher icons, splash screens, app name, permissions and native dependencies are baked into the binary, so they only ship through a native release.

Runtime version. runtimeVersion.policy is appVersion, so bumping version in app.base.json creates a new runtime version. Existing OTA updates won't apply to the new binary, and new ones won't reach older installs until users update. Bumping the version and shipping a native release therefore go together.

After the release

Troubleshooting

"Not signed in to EAS" — npx eas-cli@20.3.0 login.

"Working tree is not clean" — commit or stash. Don't work around it: the build would exclude those changes anyway.

Android submit fails on credentials — check eas credentials still shows a Google Service Account key for Android, and that the service account has the Release manager role in the Play Console.

Android EAS cloud quota is exhausted — CI supports an explicit production recovery option. Set local_android: true on Client Release or EAS Build; an opt-in main merge with both [client-release] and [local-android] selects the same option after production tests pass. Android runs the supported eas build --local process on the existing Android runner, using EAS-managed signing credentials and the production profile's environment. iOS continues to build on EAS independently. Only production AAB builds are supported by this recovery option. Secret-visibility EAS environment variables are unavailable locally; the production profile must not depend on them without explicit runner provisioning.

The Android job requires free disk space, Expo Doctor, a valid signed AAB and the supported ARM libraries, then uses eas submit --path ... --wait with the existing production submission profile. The current profile submits to Google Play's internal track. The temporary bundle is removed after the job. OTA and the native released tag still wait for both requested platforms; a local failure does not bypass those gates or imply public store approval.

iOS submit asks for an Apple password every time — add an App Store Connect API key via eas credentials so it stops using an Apple ID.

Build succeeded but the tag didn't move — the script prints the exact command to fix it. Until you run it, the next nightly client release will rebuild and resubmit.

See also

Hermes V1 and ARM Release Acceptance (Runtime 1.2.8)

Both platforms keep Expo SDK 58's default Hermes V1 and precompiled React Native. There is no legacy compiler override, custom Babel profile or forced source build. Android shipping builds contain armeabi-v7a and arm64-v8a native libraries; x86/x86_64 are not supported shipping ABIs. The build-properties plugin controls that package policy, while fixture builds can explicitly override the ABI to match their emulator. iOS retains the standard platform defaults.

Native CI requires a current-source ARM build and verifies the packaged ABIs and engine libraries. The registered runner is Intel, so its x86 emulator UI suite is advisory. The observed Hermes/GWP-ASan startup crash remains an upstream x86 investigation in #713; this is an explicit support-scope decision, not a claim that the engine bug is fixed or that ARM cannot be affected.

ARM runtime acceptance is separate from compilation. On the dedicated ARM test emulator, explicitly select its serial and run:

ANDROID_SERIAL=emulator-5554 bash apps/mobile/e2e/check-cold-starts.sh

The script rejects non-ARM devices and requires all 20 launches to retain the same process for five seconds. It stops on the first failure, preserves app data, and does not retry crashes into a pass. Confirm the emulator's identity and installed candidate before running it. Complete signed beta notification, OCR, lifecycle and iOS checks in #625/#664; earlier legacy-CI results do not validate the V1 candidate. Publish native 1.2.8 before its matching OTA. Keep Client Release held and production push off until ARM beta acceptance passes.

Dashboard Failure and Recovery Acceptance

A failed first profile/group request must show a clear retry state without inventing zero balances or an empty account. A failed refresh keeps previously loaded groups and balances visible with a notice. Retry and pull-to-refresh reload the dashboard queries without changing authentication or API limits.

apps/mobile/tests/dashboard-recovery.test.mjs renders the actual dashboard with React and substituted platform/query boundaries. It covers initial group and profile failures, retry recovery, cached refresh failures and a genuinely empty successful response; it does not claim native layout or live networking proof. The fixture API supplies isolated groups-unavailable and profile-unavailable 503 scenarios, and .maestro/ci/07-dashboard-recovery.yaml exercises their native retry path. Run the native flow on a disposable fixture installation; it clears app state, so do not target an installed app containing a real user's saved session.

SDK 58 native rebuild

The expense date field uses a normal pressable value on both native platforms. On iOS it opens the wheel picker in a separate form sheet with a Done action, so the SwiftUI compact picker's intrinsic sizing cannot move the value outside the input border. Android keeps its native date dialog. Receipt scan status labels and delayed hints share the progress bar's width and center alignment. The group navigation Maestro flow opens and dismisses the date picker; native iOS layout still requires the iOS simulator/device check described above.

Version 1.3.0 uses SDK 58, React Native 0.88.0, React 19.3 and the SDK's native module versions. Its app-version runtime boundary excludes SDK 57 installs from these OTA bundles. Build and test new iOS and Android binaries, including push registration, sign-in, tabs, scanning and the Maestro flows, before publishing an update for this runtime. JavaScript export or prebuild alone is not device validation.

The 21 October dependency alignment uses Expo 58.0.17 and its matching SDK 58 patch versions after the registry compatibility check began requiring them. This includes native modules such as notifications and updates; include these patches in the pending native rebuild and acceptance above. No SDK/runtime upgrade or store/OTA release is performed by this dependency alignment.

The native CI push-handoff tests use :app:testReleaseUnitTest with -Pandroid.onlyEnableUnitTestForTheTestedBuildType=false. Android Gradle Plugin 9 otherwise creates only debug unit-test tasks by default; keep this flag when running the release tests locally.

The Maestro fixture emulator uses a 4 GB data partition so the Pixel profile's 12 GB default does not exhaust the Android runner's disk before booting. Before installing dependencies, native CI requires 20 GiB free and prunes generated Gradle outputs, then the same repository's Turbo cache if necessary. It retains SDKs, emulator data and downloaded dependencies. Insufficient space still blocks the required ARM build; see Android build disk space for the cleanup scope and job-log diagnostics.

The root navigator anchors cold starts to index and explicitly redirects protected routes between sign-in and Dashboard. Public About/support deep links remain accessible without becoming the default launch screen in SDK 58.

View Markdown source