This document covers shipping new versions of Zingo to both stores, on both production and beta channels.
Zingo ships as two parallel apps from this single repo:
| Channel | iOS bundle ID | Android applicationId | Display name | App Store / Play presence |
|---|---|---|---|---|
| Production | org.ZingoLabs.Zingo |
org.ZingoLabs.Zingo |
"Zingo" | App Store / Play Production |
| Beta | org.ZingoLabs.Zingo.Beta |
org.ZingoLabs.Zingo.Beta |
"Zingo Beta" | TestFlight External / Play Open Testing |
Both apps share the same JS bundle (app/, screens/, ui/),
the same Rust libs (libuniffi_zingo.so, Zingolib.xcframework), the same
keystores and certificates. They differ only in bundle ID, display name, and
app icon (Beta has a red BETA band). The channel is detected at runtime from
the native binary — there is no JS-side toggle.
Use release-prep to bump version/build numbers for one channel without
touching the other. The script is idempotent: running it with the current
values produces no diff.
yarn release:prod:prep <version> <build> # e.g. 2.0.19 308
yarn release:beta:prep <version> <build>What each subcommand touches:
| File | prod:prep |
beta:prep |
|---|---|---|
package.json version |
✓ | — |
ios/Zingo.xcodeproj/project.pbxproj (Debug + Release) |
✓ | — |
ios/Zingo.xcodeproj/project.pbxproj (Debug-Beta + Release-Beta) |
— | ✓ |
android/app/build.gradle.kts defaultConfig |
✓ | — |
android/app/build.gradle.kts flavor beta |
— | ✓ |
Prod and beta versionCodes/build numbers evolve independently. Same versionName is fine across channels.
After the bump, release-prep prints the suggested commit / push / tag
commands to copy-paste. Pushing the tag triggers the Android Release CI
workflow described below.
Gradle never invokes cargo, and Xcode never invokes cargo. The native libs
(android/app/src/main/jniLibs/<abi>/libuniffi_zingo.so, ios/Zingolib.xcframework)
are produced by the separate yarn rust:android / yarn rust:ios steps, and
the AAB / archive just packages whatever is already sitting there.
That ordering is visible to users. rust/lib/build.rs runs
git describe --long --match=zingo-* at cargo build time and bakes the
result into the binary; get_version() returns
<zingolib descriptor>-zm_<tag>[_<commits-since-tag>_<hash5>][_dirty], which is
the string on the zingolib line of the About screen. So it describes the
commit the .so was compiled at — not the app around it. (The version in the
About header, and what the stores show, is unrelated: it comes from
versionName/versionCode at runtime via app/utils/ZingoAppData.ts.)
Build the Rust libs before creating the tag and the About screen advertises the
previous tag plus a commit count — e.g. build 327 shipped showing
zm_beta-2.0.23-326_83_5ea12, because the .so was compiled 83 commits past
tag zingo-beta-2.0.23-326, one commit short of the commit tagged -327.
Correct order for any store build:
yarn release:<channel>:prep <ver> <build>git committhe bumpgit tag ...— the tag must exist locally before step 4; push it wheneveryarn rust:android/yarn rust:ios— recompiles the native libs sitting exactly on the tag, so the descriptor collapses to a cleanzm_<tag>- Build the AAB (Android Studio / gradle) or Archive in Xcode
Anything other than a bare zm_<tag> on the About screen means the shipped
native lib does not correspond to the tag: a _<count>_<hash> suffix means it
was built that many commits before (or after) the tag, and _dirty means it was
built from an uncommitted working tree.
This only affects the manual store builds. The tag-triggered APKs below are built by CI from source on the tagged commit, so their descriptor is always clean.
Pushing a release tag publishes a set of Android APKs as a GitHub Release. This is independent from the store uploads — it exists for sideloading, internal QA distribution, and reproducible per-commit archives.
| Channel | Tag format | GitHub Release |
|---|---|---|
| prod | zingo-<version>-<build> |
latest release, prerelease=false |
| beta | zingo-beta-<version>-<build> |
new release, prerelease=true |
The .github/workflows/android-release.yaml workflow:
-
Builds the rust native libs (4 ABIs in parallel) and the uniffi kotlin bindings from source on the tagged commit. No
actions/cacheis read or written for these outputs — all cross-job hand-off is viaupload-artifact, which is run-scoped. The maven dependency cache is used in read-only mode (artifacts are content-addressed and signed). -
Assembles
assembleProdReleaseorassembleBetaReleasewith-PsplitApk=true -PincludeUniversalApk=true. -
Publishes 5 APKs to the release:
<prefix>-armeabi-v7a-<version>-<build>.apk(32-bit ARM)<prefix>-arm64-v8a-<version>-<build>.apk(64-bit ARM, most modern phones)<prefix>-x86-<version>-<build>.apk(32-bit x86, emulators)<prefix>-x86_64-<version>-<build>.apk(64-bit x86, emulators)<prefix>-universal-<version>-<build>.apk(single APK with all 4 ABIs)
where
<prefix>iszingoorzingo-beta.
These APKs are signed with debug.keystore.
yarn release:prod:prep <ver> <build>, commit the diff, and create the tag.yarn rust:ios— rebuild the xcframework on the tag (see Release order).- Open
ios/Zingo.xcworkspacein Xcode. - Select scheme Zingo, destination Any iOS Device (arm64).
- Product → Archive.
- In the Organizer that opens: Distribute App → App Store Connect → Upload. Signing is Automatic — your account needs to be a member of the team that owns the App Store Connect record.
- Wait ~10-20 min for "build processed" email from Apple.
- App Store Connect → app "Zingo" → fill What's New for the version and submit for App Store Review.
yarn release:prod:prep <ver> <build>(use the same numbers as the matching iOS bump for consistency, even though they're independent under the hood), commit, and create the tag.yarn rust:android— rebuild the.sos on the tag (see Release order).- Android Studio → Build → Generate Signed App Bundle / APK → AAB.
- Point the wizard at the release keystore and enter its passwords (see First-time dev setup below for where they live).
- Build variant: prodRelease.
- Output AAB lands under
android/app/prod/release/. - Play Console → app "Zingo" → Production → Create new release → upload AAB → fill release notes → Review release → Roll out.
CLI equivalents (without local signing config in place, gradle falls back to the debug keystore — fine for local smoke testing, not for store upload):
yarn build:release # universal APK
yarn build:release:split # per-ABI APKs
cd android && ./gradlew bundleProdRelease # AAByarn release:beta:prep <ver> <build>, commit, and create the tag.yarn rust:ios— rebuild the xcframework on the tag (see Release order).- Xcode scheme Zingo Beta → Product → Archive → Distribute App → App Store Connect → Upload.
- After "build processed", in App Store Connect → app "Zingo Beta" →
TestFlight:
- Click the new build → Manage → mark Missing Compliance (encryption
declaration carries over from
Info.plist, just acknowledge it once per build). - External Testing → Zingo Beta Public group → Add Build → select the uploaded build.
- Fill Beta App Review info (description, what to test, "No login required" notes) → Submit for Review.
- Click the new build → Manage → mark Missing Compliance (encryption
declaration carries over from
- Apple's review of the first build of each major version takes 24-48h. Subsequent builds of the same major usually auto-approve.
- After approval, toggle on the Public Link in the External group settings.
The URL is
testflight.apple.com/join/XXXXXXXX— share with testers.
yarn release:beta:prep <ver> <build>, commit, and create the tag.yarn rust:android— rebuild the.sos on the tag (see Release order).- Android Studio → Build → Generate Signed App Bundle / APK → AAB → variant betaRelease.
- AAB at
android/app/beta/release/app-beta-release.aab. - Play Console → app "Zingo Beta" → Testing → Open testing → Create new release → upload AAB.
- Fill Release notes (EN at minimum) → Save → Review release → Roll out.
- First release of an Open testing track needs Google's content review (typically 1-3 days). After approval, share the opt-in URL from the Open testing → Testers tab.
CLI equivalents:
yarn build:beta:release # universal APK
yarn build:beta:release:split # per-ABI APKs
yarn build:beta:release:aab # AAB (what Play wants)- Get the release keystore and its credentials from a teammate. Both the
keystore file and the properties file are gitignored — drop them under
android/at the paths gradle expects (seeandroid/app/build.gradle.ktsfor the exact filenames). - Without them,
gradle assembleProdReleaseandassembleBetaReleasefall back todebug.keystore— the resulting AAB is not Play-uploadable but is usable for local install / smoke testing.
Sign into the team's Apple ID in Xcode. Automatic Signing provisions both
org.ZingoLabs.Zingo and org.ZingoLabs.Zingo.Beta on the first Archive of
each variant.
Debug,Release→ prodDebug-Beta,Release-Beta→ beta- Per-config overrides:
PRODUCT_BUNDLE_IDENTIFIER,INFOPLIST_KEY_CFBundleDisplayName,ASSETCATALOG_COMPILER_APPICON_NAME. release-prepdistinguishes channels usingASSETCATALOG_COMPILER_APPICON_NAMEas the anchor (prod =AppIcon, beta ="AppIcon-Beta").- Two shared schemes:
Zingo.xcscheme,Zingo Beta.xcscheme.
- Flavors
prodandbetaon dimensionchannel. beta:applicationIdSuffix = ".Beta",versionCodeoverride,resValue("string", "app_name", "Zingo Beta").- Beta resource overlay:
android/app/src/beta/res/mipmap-*/(icons with BETA band). - Hardcoded
app_nameremoved fromstrings.xml— provided per-flavor viaresValue.
app/utils/ZingoAppData.ts derives the displayed version from
react-native-device-info at runtime. The displayed string is always
consistent with the binary the user installed, regardless of which channel
the shared JS bundle was prepped against.
The zingolib line on the About screen is a different thing entirely: it is the source descriptor compiled into the native lib, see Release order.
- Bundle ID case sensitivity: both iOS and Android use
.Beta(capital B). TheapplicationIdSuffixinbuild.gradle.ktsis intentionally capital to match what's registered in App Store Connect and Play Console. - AAB / APK never committed: gitignored (
*.aab,*.apk,android/app/beta/release/,android/app/release/). - UniFFI bindings refresh: the Kotlin bindings are generated, not checked in.
yarn rust:androidwrites them intoandroid/app/build/generated/source/uniffi/{debug,release}/..., andnode scripts/generate_kotlin_bindings.mjswrites them on their own. After agradle clean, run one of the two before the Kotlin compile.