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. 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. 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 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.
ntan đưa bàn phím cho mình viết bài này. Mình là atuan, lo backend, khóa ký và CI ở uranashel — tức là người bấm nút release, cả hai nút. Chuyện vì sao có hai codebase native thì ntan kể rồi, trong bài hai codebase. Bài này là phần máy móc đẩy cả hai ra cửa trong cùng một ngày: 5 app, 2 store, 2 ngôn ngữ, 3 người.
Studio nhỏ cỡ này không nuôi nổi một quy trình release cần tới release manager. Quy trình phải nhàm, phải script hóa hết, phải khó làm sai, vì người vận hành nó cũng đang debug sync của Stashio đúng giờ đó. Nên bọn mình dựng một chuyến tàu. Chạy theo lịch, chở thứ gì đã sẵn sàng, không chờ ai. Dưới đây là toàn bộ, kèm số liệu từ log release của studio.
Mỗi app một số version, không ngoại lệ
Mỗi app đánh version độc lập dạng major.minor.patch, chuỗi số giống hệt trên hai nền tảng. versionCode phía Android do script sinh từ chính chuỗi version: versionCode = major·10⁶ + minor·10³ + patch, nên Wheria 2.7.1 thành 2007001, còn CFBundleVersion phía iOS mang đúng số nguyên đó. Email support ghi 2.7.1 là trỏ về đúng một commit, store nào cũng vậy, ngôn ngữ nào cũng vậy. Khỏi phải hỏi lại đang dùng nền nào.
Tàu chạy hai tuần một lần, thứ Ba, 09:00. App có thay đổi đã merge thì nhận tag. App không có gì mới thì bỏ chuyến, chẳng ai phiền. Tag chính là release: một annotated tag dạng wheria-2.7.1, push lên repo. Cú push đó là con đường duy nhất để build store bắt đầu. Không tồn tại đường build-từ-laptop. Từng tồn tại. Một tối tháng 1/2025, bản Stashio build từ laptop lên TestFlight kèm shared module cũ, ktuyen mất 3 h đuổi theo một bug mà main đã fix từ 9 ngày trước. Đường tắt bị xóa ngay tối đó.
Một tag, hai lane
Push tag là hai lane CI chạy trên cùng một commit. Runner macOS archive bằng Xcode, ký rồi đẩy .ipa lên App Store Connect qua API key. Runner Linux đóng gói .aab, giao cho Play Console qua service account. Lane iOS mất 23 phút, lane Android 9 phút, phần chênh chủ yếu do Xcode. Cả hai lane từ chối chạy nếu đêm trước bộ parity golden-trace chưa xanh — vụ replay 214 trace so từng bit đã kể trong bài hai codebase. Tàu mà chở bộ toán đã lệch thì chỉ là cách ship hai app khác nhau dưới một số version.
Cả hai lane kết thúc ở trạng thái giữ. Phía Apple đặt manual release; phía Google bật managed publishing để ghim bản đã duyệt. Approval cứ thế dồn dần trong tuần. Không gì lên sóng cho tới khi mình bấm hai nút release trong cùng một phút. Toàn bộ mẹo ship cùng ngày nằm ở đó.
API key của App Store Connect và file JSON của service account là hai file nguy hiểm nhất studio đang giữ. Chúng chỉ tồn tại trong secret store của CI. Việc xoay khóa nằm sẵn trên lịch, để ngày hết hạn không bao giờ được quyền chọn thời điểm thay bọn mình.
Screenshot và copy store, thứ gì cũng nhân đôi
Screenshot cho store do UI test sinh ra. Mỗi app có 8 màn hình theo kịch bản; script chạy trên simulator iPhone 6,9 inch và 6,5 inch, rồi emulator Pixel 8 cùng profile tablet 10 inch, một lượt tiếng Anh, một lượt tiếng Việt. Chạy đủ một vòng ra 320 ảnh cho cả catalog, tốn 74 phút trên con Mac build. Caption lấy từ đúng bảng string trong app, nên đổi tên tính năng là store tự đổi theo, còn caption chưa dịch sẽ làm build fail thay vì sống sót tới lúc user chụp màn hình gửi ngược cho bọn mình.
Metadata store (title, subtitle, mô tả, keyword, release notes) nằm trong repo dạng text thuần, mỗi locale một file, upload bởi chính hai lane build. ktuyen duyệt từng file tiếng Việt trước khi tag được push. Luật của cô ấy: caption tiếng Việt mà đọc lên nghe như bản dịch thì viết lại từ cái screenshot, đừng nhìn bản tiếng Anh nữa. Listing tiếng Việt là văn bản tiếng mẹ đẻ, có nhịp riêng; đọc to lên thấy thuận tai được cô ấy tính là cửa release, ngang hàng với tỷ lệ crash-free.
Review thật sự tốn bao lâu
Bọn mình log timestamp mọi lần nộp, script kéo từ API của App Store Connect và Play Developer, tính từ tháng 1/2025 tới lúc viết bài là 61 lần nộp mỗi store. Apple: median 16 h từ nộp tới duyệt, percentile 90 là 39 h. Play: median 25 h, percentile 90 là 74 h. Dân gian bảo Apple chậm. Log của studio nói ngược lại, ít nhất với một studio nhỏ, app lâu năm, không SDK cờ bạc.
Phần đuôi phân bố mới là chỗ để lập kế hoạch. Ca xấu nhất phía Apple: 9 ngày. Sonarish 2.3.0 bị từ chối hai lần theo guideline 5.1.1, vì chuỗi xin quyền micro mô tả app làm gì thay vì giải thích vì sao cần mic. Lỗi bọn mình, cả hai lần. Ca xấu nhất phía Play: 16 ngày — Estua 3.1.0 nằm in review suốt 16 ngày rồi được duyệt, không đổi một byte. Đến giờ vẫn không biết nó nghĩ gì mà lâu vậy. Nên tàu tag sáng thứ Ba, nộp trong vòng một giờ, nhắm lên sóng thứ Ba tuần sau. Đệm 7 ngày nuốt trọn percentile 90 của cả hai hàng chờ, còn dư. Khi đuôi dài ăn hết đệm, cả cặp lùi lịch cùng nhau, vì bản iOS không bao giờ ra một mình; 18 tháng qua chuyện đó xảy ra hai lần.
Expedited review của Apple có thật và chạy được. Hai năm bọn mình xin 3 lần, được chấp thuận 2 lần, lần nhanh nhất 5 h. Rõ ràng là một ân huệ. Bọn mình cố giữ nó đủ hiếm để nó còn là ân huệ.
Rollout theo pha và luật dừng
Hai store hiểu chữ rollout theo hai kiểu. Trên Play bọn mình staged bằng tay: 5%, rồi 20%, 50%, 100%, mỗi nấc cách nhau chừng 24 h nếu vitals sạch, đi hết thang mất khoảng 4 ngày. Phased release của Apple là đường cong 7 ngày cố định (1, 2, 5, 10, 20, 50, 100% số user bật tự động cập nhật), còn nút điều khiển duy nhất Apple đưa là pause. Cùng một ngày, tỷ lệ user nhận bản mới trên hai nền không bao giờ bằng nhau, nên bọn mình thôi giả vờ ép chúng bằng nhau. Luật dừng viết riêng cho từng store.
Luật có hai cò súng, soi trên crash reporting của chính hai console; đó cũng là nguồn duy nhất bọn mình có, vì app cảm biến không gắn SDK analytics bên thứ ba, lý do nằm trong bài on-device. Cò 1: chữ ký crash mới vượt 0,2% số session trong 24 h đầu. Cò 2: crash-free user tụt dưới 99,6% ở bất kỳ nấc nào. Dính cò nào cũng pause rollout rồi gọi mình dậy.
Luật đã nổ một lần thật. Wheria 2.6.1, tháng 11/2025, lane Android, nấc 5%: Samsung A54 có thể trả về batch áp kế rỗng sau khi tắt màn hình, còn listener cứ thế index vào. 41 user dính. Rollout dừng 40 phút sau cảnh báo, bản fix là 2 dòng guard, 2.6.2 lên Play sau 31 h. iOS không dính vì nhánh áp kế bên đó chưa nhận đợt refactor. Thế là Wheria sống 3 tuần với 2.6.2 trên Play và 2.6.1 trên App Store — ngoại lệ duy nhất được phép của luật version khớp: chữ số patch được lệch sau hotfix. Khớp version giữ ở mức minor; bản minor kế tiếp kéo hai bên thẳng hàng lại. Nộp cho Apple một binary không đổi gì chỉ để hai con số giống nhau là đốt nguyên một lượt review cho nghi thức.
Lane hotfix
Hotfix cắt nhánh từ đúng cái tag release nó sửa; main cứ chạy tiếp việc của main. Luật ngắn thôi. Mỗi hotfix một bug. Diff tối thiểu, không đụng string, để khâu dịch không chặn được nó, còn vòng test của ktuyen co lại còn app bị lỗi trên 4 máy thay vì cả ma trận. Trên Play vẫn staged nhưng nén: 20% trong 12 h đầu, xong lên 100%. Vẫn có release notes viết tay, vì một câu kiểu "đã sửa crash một số bạn gặp trên máy Samsung khi tắt màn hình" tốn 3 phút nhưng mua lại được lòng tin mà chữ "bug fixes" đã âm thầm tiêu mất. Từ tháng 1/2025 có 5 hotfix ship theo luật này. Lịch tàu sống sót cả 5 lần.
Release notes viết tay
Mỗi bản release có notes do chính người sửa code viết, bằng tiếng Anh, sau đó ktuyen viết lại bằng tiếng Việt. Template bị cấm từ sớm. "Bug fixes and performance improvements" là giấy dán tường của store listing; user vừa thấy badge la bàn dở chứng xứng đáng đọc được dòng "badge tin cậy la bàn hết nhấp nháy gần trạm sạc EV" để biết bọn mình cũng thấy. Có người trả lời mấy dòng notes đó thật. Mail support trích lại notes của chính bọn mình, đôi khi kèm lời khen; vòng phản hồi đó không dashboard analytics nào đo nổi.
Dưới lý do nguyên tắc còn một chồng lý do thực dụng. Notes kiêm luôn changelog công khai: mail hỏi về 2.5.0 thì mở notes của 2.5.0 ra đọc là trả lời được. Giới hạn 500 ký tự mỗi locale bên Play hóa ra là một biên tập viên: bản release không gói nổi ý chính trong 500 ký tự thì nhiều khả năng chưa có ý chính. Còn viết notes tiếng Việt từ đầu giữ chúng đúng cái giọng user thật sự đọc — quả đồi ktuyen chọn tử thủ từ ngày listing đầu tiên lên sóng.
Trong bài chào sân, ntan hứa atuan và ktuyen sẽ xuất hiện khi có chuyện hạ tầng đáng kể. Chuyện của mình đây. Tóm tắt thật thà: chẳng có gì thông minh cả. Một công thức version, một dạng tag, hai lane build, hai nút đang giữ, một bảng tính thời gian review, một luật dừng. Tàu nhàm chán có chủ đích. Máy móc nhàm chán là thứ cho phép studio 3 người dành những giờ thú vị trong hầm xe thay vì trong kênh deploy.