Build and ship a new version to TestFlight (iOS) and Firebase App Distribution (Android).
Each build produces TWO variants per platform: a TEST ENV build (pointing to test API) and a PROD ENV build (pointing to production API), so testers can validate both environments.
-
Bump build number in all three places (must stay in sync):
app.json→expo.android.versionCodeandroid/app/build.gradle→versionCodeios/<AppName>/Info.plist→CFBundleVersionIncrement by 2 from the current value (PROD gets the even number, TEST gets the next odd number). IMPORTANT: PROD and TEST builds MUST use separate build numbers — TestFlight rejects duplicate CFBundleVersion uploads. Convention: PROD = N, TEST = N+1.
-
Compile release notes from git log since the last build tag or recent commits.
-
Commit and push the build number bump to main.
-
Build and distribute PROD ENV variant first: a. Verify
services/api.tshasUSE_TEST_ENV = false(should already be the default). b. Build both platforms in parallel:- iOS:
xcodebuild archivethenxcodebuild -exportArchive - Android:
./gradlew assembleReleasec. Build web dist:npx expo export --platform webd. IMPORTANT: Save PROD artifacts before any TEST build overwrites them:
cp android/app/build/outputs/apk/release/app-release.apk /tmp/MyApp-prod-buildXX.apk cp /tmp/MyApp-export/MyApp.ipa /tmp/MyApp-prod-buildXX.ipa
e. Upload all in parallel:
- iOS → TestFlight
- iOS → Firebase with release notes prefixed:
🟢 PROD ENV (Build XX) — <release notes> - Android → Firebase with release notes prefixed:
🟢 PROD ENV (Build XX) — <release notes> - Android → Google Play (if service account key exists)
- iOS:
-
Build and distribute TEST ENV variant: a. Set
USE_TEST_ENV = trueinservices/api.ts. b. BumpCFBundleVersionin Info.plist to N+1 (TestFlight requires unique build numbers per upload). c. Swap ALL app icons to test variant (e.g. inverted colors):- Android: Copy
*-test.pngover the prod versions, regenerate mipmap WebP files. - iOS: Copy
icon-test.png→icon.pngAND → iOS asset catalog (MUST be RGB with no alpha channel — Apple rejects icons with transparency) - Web: Copy
favicon-test.png→favicon.pngd. Build both platforms in parallel (same commands as step 4b). e. Upload all in parallel: - iOS → TestFlight
- iOS → Firebase with release notes prefixed:
🟡 TEST ENV (Build XX) — <release notes> - Android → Firebase with release notes prefixed:
🟡 TEST ENV (Build XX) — <release notes>
- Android: Copy
-
Restore PROD as default: a. Set
USE_TEST_ENV = falseback inservices/api.ts. b. Restore all prod icons:git checkout assets/images/android-icon-*.png assets/images/icon.png assets/images/favicon.png ios/<AppName>/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.pngc. Regenerate Android mipmap WebP files from the prod source images. d. Do NOT commit the toggle/icon changes — they're only temporary for the test build.
-
Commit and push the rebuilt web dist (from the PROD build).
-
Create GitHub Release for the PROD build. CRITICAL: Use the saved PROD artifacts from step 4d, NOT the live build output paths (which will have been overwritten by the TEST build):
gh release create vX.X.X-buildXX \ /tmp/MyApp-prod-buildXX.apk#MyApp-buildXX.apk \ /tmp/MyApp-prod-buildXX.ipa#MyApp-buildXX.ipa \ --title "Build XX — PROD (vX.X.X)" \ --notes "<release notes>"
-
Report the build version, upload status for both platforms × both environments, and release notes.
For Firebase, clearly label each build:
PROD build:
🟢 PROD ENV (Build XX)
Points to: production API
Changes:
- <bullet points from git log>
TEST build:
🟡 TEST ENV (Build XX)
Points to: test API
Changes:
- <same bullet points>
Note: This build uses the TEST API environment for QA testing.
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -archivePath /tmp/MyApp.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=XXXXXXXXXX PROVISIONING_PROFILE_SPECIFIER="My Profile" "CODE_SIGN_IDENTITY=Apple Distribution"
xcodebuild -exportArchive -archivePath /tmp/MyApp.xcarchive -exportOptionsPlist scripts/ExportOptions.plist -exportPath /tmp/MyApp-export -allowProvisioningUpdates
xcrun altool --upload-app -f /tmp/MyApp-export/MyApp.ipa -t ios -u "developer@example.com" -p "@keychain:APP_SPECIFIC_PASSWORD"
firebase appdistribution:distribute /tmp/MyApp-export/MyApp.ipa --app "<FIREBASE_IOS_APP_ID>" --groups my-team --release-notes "<release notes>"
The release build is signed with the release keystore (configured in android/app/build.gradle).
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
export ANDROID_HOME=~/Library/Android/sdk
cd android && ./gradlew assembleRelease && cd ..
firebase appdistribution:distribute android/app/build/outputs/apk/release/app-release.apk --app "<FIREBASE_ANDROID_APP_ID>" --groups my-team --release-notes "<release notes>"
Requires service account key. Only upload the PROD APK to Google Play.
google-play-cli apk upload \
--config-file .certs/google-play-key.json \
--package-name com.example.myapp \
--apk android/app/build/outputs/apk/release/app-release.apk$ARGUMENTS