# Shipping the same day on two app stores > atuan explains the release train that moves five apps to two stores on the same day, with review-time numbers from the studio's own log. 2026-07-18 · 18 min read · engineering, studio · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/shipping-two-stores.html --- ntan handed me the keyboard for this one. I am atuan. I run the backend services, the signing keys and the CI at uranashel, which makes me the person who presses the release buttons — both of them, as it turns out. ntan already explained [why we maintain two native codebases](https://uranashel.com/blog/cross-platform-two-codebases.html). What follows is the machinery that pushes both of them out the door on the same day: 5 apps, 2 stores, 2 languages, 3 people. A studio this small cannot afford a release process that needs a release manager. The process has to be boring, scripted and hard to do wrong, because whoever drives it is also debugging Stashio sync in the same hour. So we built a train. It departs on a schedule, it carries whatever is ready and it does not wait for stragglers. Below is the entire thing, with numbers from our own release log. ## One version number per app, no exceptions Each app versions independently as `major.minor.patch`, and the string is identical on both platforms by construction. The Android `versionCode` is derived by script from the version string: `versionCode = major·10⁶ + minor·10³ + patch`, so Wheria 2.7.1 becomes 2007001, and the iOS `CFBundleVersion` carries the same integer. When a support email says 2.7.1, that identifies one commit, on either store, in either language. Nobody has to ask which platform first. The train departs every second Tuesday at 09:00. An app with merged changes gets a tag. An app without changes skips the train, and nobody minds. The tag is the release: an annotated git tag shaped like `wheria-2.7.1`, pushed to the repo. That push is the only way a store build can begin. There is no build-from-my-laptop path. There used to be, until an evening in January 2025 when a laptop build of Stashio went to TestFlight with a stale shared module and ktuyen spent 3 h chasing a bug that main had fixed 9 days earlier. The shortcut got deleted that night. ## One tag, two lanes The tag push fans out into two CI lanes on the same commit. A macOS runner does the Xcode archive, signs it and uploads the .ipa to App Store Connect through an API key; a Linux runner assembles the .aab and hands it to the Play Console through a service account. The iOS lane takes 23 min, the Android lane 9 min, and the gap is mostly Xcode. Neither lane will start unless the previous night's golden-trace parity run came back green — the 214-trace, bit-for-bit replay described in the cross-platform post. A release train carrying diverged math is just a way to ship two different apps under one version number. Both lanes end in a held state. On Apple's side the version is set to manual release; on Google's side managed publishing holds the approved build. Approvals accumulate quietly over the week. Nothing goes live until I press the two release buttons inside the same minute, which is the entire trick behind same-day shipping. The App Store Connect API key and the Play service-account JSON are the two most dangerous files the studio owns. They exist in the CI secret store and nowhere else, and we rotate them on a fixed calendar so the expiry date never gets to pick the timing for us. ## Screenshots and store copy, twice each Store screenshots are generated by UI tests. Each app has 8 scripted screens; the script runs them on a 6.9-inch and a 6.5-inch iPhone simulator, then on a Pixel 8 emulator and a 10-inch tablet profile, once in English and once in Vietnamese. A full regeneration produces 320 images across the catalog and takes 74 min on the build Mac. Captions come from the same string tables the apps use, so a renamed feature renames itself in the store, and an untranslated caption fails the build instead of surviving until a user screenshots it back at us. All store metadata (titles, subtitles, descriptions, keyword fields, release notes) lives in the repo as plain text, one file per locale, uploaded by the same lanes that upload the builds. ktuyen reviews every Vietnamese file before the tag goes in. Her standing rule: if a VI caption reads like it was translated, rewrite it from the screenshot, not from the English. The Vietnamese listing is a first-language artifact with its own rhythm, and she treats reads-well-aloud as a release gate the same way crash-free percentage is one. ## What review actually takes We log every submission timestamp, pulled by script from the App Store Connect and Play Developer APIs since January 2025: 61 submissions per store at the time of writing. Apple's median from submission to approval is 16 h; the 90th percentile is 39 h. Play's median is 25 h with a 90th percentile of 74 h. The folklore says Apple is the slow one. Our log says otherwise, at least for a small studio with established apps and no gambling SDKs. The tails are where planning lives. Apple's worst case was 9 days: Sonarish 2.3.0, rejected twice under guideline 5.1.1 because the microphone purpose string described what the app does instead of why it needs the mic. Our fault, both times. Play's worst case was 16 days: Estua 3.1.0 sat in review for 16 days and emerged approved, unchanged. We never learned what it was thinking about. So the train tags on a Tuesday, submits within the hour and targets going live the following Tuesday. A 7-day buffer absorbs both 90th percentiles with room to spare. When a tail event eats the buffer anyway, the pair slips together, because the iOS build never ships alone; that has happened twice in 18 months. Apple's expedited review exists and works. We have requested it 3 times in 2 years, been granted it twice, and the fastest turnaround was 5 h. It is plainly a favor. We intend to keep it rare enough to stay one. ## Phased rollout and the halt rule The two stores disagree about what a rollout is. On Play we stage manually: 5%, then 20%, then 50%, then 100%, each step roughly 24 h apart when vitals stay clean, a full ramp in about 4 days. Apple's phased release is a fixed 7-day curve (1, 2, 5, 10, 20, 50, 100% of automatic-update users), and the only control Apple gives you is pause. The same fraction of users is never exposed on both platforms on the same day, so we stopped pretending it could be and wrote the halt rule per store. The rule has two triggers, checked against the consoles' own crash reporting, which is also the only crash reporting we have; the sensor apps carry no third-party analytics SDK, for the reasons in [the on-device post](https://uranashel.com/blog/on-device-first.html). Trigger 1: any new crash signature above 0.2% of sessions in the first 24 h. Trigger 2: crash-free users below 99.6% at any stage. Either one pauses the rollout and pages me. It has fired once for real. Wheria 2.6.1, November 2025, Android lane, 5% stage: a Samsung A54 could deliver an empty barometer batch after screen-off and the listener indexed into it anyway. 41 users were affected. The rollout halted 40 min after the alert, the fix was a 2-line guard, and 2.6.2 reached Play 31 h later. iOS never had the bug; its barometer path had not received the refactor. Wheria then spent 3 weeks as 2.6.2 on Play and 2.6.1 on the App Store, which is the one sanctioned exception to version parity: patch digits may drift after a hotfix. Parity is enforced at minor granularity and the next minor release re-aligns the pair. Submitting a no-op binary to Apple so two numbers match would burn a review cycle on ceremony. ## The hotfix lane A hotfix branches from the release tag it repairs; main keeps moving on its own. The rules are short. One bug per hotfix. The diff stays minimal and touches no strings, so localization cannot block it and ktuyen's pass shrinks to the affected app on 4 devices instead of the full matrix. On Play it still stages, compressed: 20% for the first 12 h, then 100%. It still gets hand-written release notes, because a sentence like "fixed a crash some of you hit on Samsung phones when the screen turned off" costs 3 minutes and buys back trust that the phrase "bug fixes" had been quietly spending. Five hotfixes have shipped under these rules since January 2025. The train schedule survived all 5. ## Release notes, by hand Every release ships notes written by whoever made the change, in English, then rewritten by ktuyen in Vietnamese. Templates were banned early. "Bug fixes and performance improvements" is store-listing wallpaper; a user who just watched the compass badge misbehave deserves to read that the compass reliability badge no longer flickers near EV chargers, and to know we saw it too. People reply to these. Support mail quotes our own notes back at us, sometimes approvingly; that closes a loop no analytics dashboard can. Under the principled reason sits a stack of practical ones. The notes double as our public changelog, so a support email about 2.5.0 gets answered by reading what 2.5.0 said about itself. Play's 500-character limit per locale turns out to be an editor: a release that cannot state its point in 500 characters probably lacks one. Writing the VI notes from scratch keeps them in the register our users actually read, which has been ktuyen's hill since the first listing went up. ntan promised in [the welcome post](https://uranashel.com/blog/welcome-uranashel.html) that atuan and ktuyen would show up here once there was an infrastructure story worth telling. This is mine, and the honest summary is that none of it is clever. A version formula, a tag shape, two build lanes, two held buttons, one spreadsheet of review times, one halt rule. The train is boring on purpose. Boring machinery is what lets a 3-person studio spend its interesting hours in a parking garage instead of a deploy channel. --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)