Keeping two apps shippable, release after release
Two React Native apps had to stay in the App Store and Google Play while the framework kept moving under them. I led a mobile team of three, set the rules we worked by, and ran almost every release myself.
- Apps
- Padel booking app, education app
- My role
- Mobile team lead
- Team
- 3 developers
- Platforms
- iOS and Android, both stores
ProblemTwo mobile apps had to keep passing store review while the framework under them changed every few months.
What I didI set up commit and pull request checks, versioning, framework upgrades, crash monitoring, update prompts and written team rules.
ResultBoth apps kept shipping to both stores while the framework changed, and almost every release went out through me.
An app that cannot ship slowly dies
A mobile app is not finished when the feature works on my phone. It is finished when a reviewer at Apple and a reviewer at Google have both accepted it, and the user has the new version installed. Every step between my laptop and that user's phone can stop a release.
I worked on two apps at the same time. One was a padel and racket-sports app for players in Europe: court booking, payments, open games, tournaments and chat. The other was an education app for students: an AI tutor, a digital library with a PDF reader, and exam practice. I led the mobile team on both, a team of three developers.
Both apps had the same weak points. Each one had to go through two app stores, and each store reviews every release. The framework under both apps, React Native with Expo, moves fast, and an upgrade can break the build in ways that have nothing to do with our code. Users who never update stay on an old version, sometimes one that no longer works with the server. And a small team can lose a whole day to a broken main branch.
None of this is the work that clients ask about. They ask for the booking screen or the AI tutor. But if the release path is fragile, the booking screen sits in a branch for weeks. I treated the release path as a feature of its own, with an owner, and that owner was me.
Two apps, four store listings, and a framework that does not wait
The first limit was simple arithmetic. Two apps on two stores means four listings, four review queues and four sets of build settings. iOS needs signing, entitlements, a privacy manifest and permission texts in its settings file. Android needs version codes, a manifest with the right permissions, and its own build tool settings. A change that is harmless on one platform can fail review on the other.
The second limit was timing. Framework upgrades never arrive at a quiet moment. The padel app moved between React Native versions while the team was building payments, activities and chat. The education app moved through two Expo SDK upgrades while we were building the library, the reader and the AI tutor. I could not stop feature work for a month to upgrade in peace.
The third limit was visibility. Once a build is in a user's hands, I cannot attach a debugger to it. If the app crashes on a cheap Android phone in a student's bag, I only learn about it if the app tells me. Without crash monitoring, the first sign of a problem is a bad review or a message from the client.
The last limit was people. A team of three has no release manager, no QA department and no platform team. Every rule we had to follow, someone had to remember. Rules that live only in one person's head get forgotten the week that person is busy. So I wanted rules that a tool would check for us.
Every change starts on its own branch, with a message that says what it is
I set up the padel app from its very first commit, so I could put the team rules in place before there was any code to argue about. On the education app I added the same rules early, while the project was still small.
Work happened on short branches named by type, such as feature or fix branches. On the padel app the work was planned in milestones, so each milestone also had its own branch, and the checks ran on those branches too. On the education app I drew the branching strategy as a diagram in the README, so a new developer could see where a branch starts and where it ends.
Commit messages followed one written convention: a type first, such as feat, fix or chore, then a short description. A commit hook checked every message before it was accepted. This sounds like a small thing, but it made the history readable. Months later I could scan the log and see every release, every upgrade and every fix without opening a single diff.
Both repositories also had issue templates for bug reports and feature requests. Every bug report arrived with the same basic details in the same order. That saved the team from the slow back-and-forth of asking each reporter the same questions.
Tools check the rules, so reviews can focus on the code
On each laptop, git hooks ran before a commit and before a push. They checked the commit message and ran the code checks, so most mistakes never reached the shared repository at all.
On the server side, a validation workflow ran on every pull request into the main branches. It installed the dependencies the same way every time and ran the linter and the type checker. A pull request with type errors could not look green.
I also moved some team habits into lint rules. For example, the education app had a lint rule about how React is imported, because the codebase had settled on one style. A rule in the lint config does not depend on me spotting the problem in review.
I reviewed the pull requests myself and was responsible for what was merged. Because the tools had already checked format, types and commit messages, my review time went to the questions that matter: does this handle the error case, does it match the rest of the app, will it survive a slow network.
One release, one commit, one version you can find later
Every release started with a release commit. It raised the version name and the build number on both platforms, in the native project files and in the app config, and nothing else. If a store build ever behaved strangely, I could find the exact commit it came from in seconds.
On the padel app, the version name carried the release date, and the build number went up by one each time. A support message that said which version a user had would tell me at once how old their build was.
Builds ran on Expo's build service. On both apps I chose to keep the version numbers in the repository instead of letting the build service count them up on its own. On the padel app I switched the version source from remote to local; on the education app I removed automatic increments from the production profile. That cost me one small manual step per release. In return, the number in the store always matched a number in our code.
Environment settings stayed out of the code. On the padel app I moved API addresses and keys into environment configuration files, with type definitions, so a release build could not accidentally point at a development server because someone forgot to change a line.
Giving the reviewer nothing to question
Then came TestFlight and the Play testing tracks, and then submission. I owned this part on both apps: signing, the testing tracks, submission and the follow-up when a reviewer asked for something.
Most review trouble comes from details that are invisible in daily development. A permission the app asks for but no longer uses. A permission text that does not explain why the app needs the camera or the microphone. A privacy manifest that does not match what the libraries actually do. A screen orientation setting that is missing.
So part of each release was cleaning those details. On the education app I added missing permission texts and updated the text that explains why the app uses the microphone. When I upgraded it to Expo SDK 55, I also removed location permissions that the app no longer used. On the padel app I adjusted the iOS privacy manifest and the supported orientations, and set up the calendar and location permissions the booking features needed.
An app should ask only for what it uses, and say plainly why. That is good for users, and it is also the cheapest way to pass review the first time.
An old version should not stay broken forever
A release is only useful once users install it. Many people never open the store to update. If the server changes and an old app version can no longer talk to it, those users see errors and blame the product.
On both apps I built a version check. On start, the app asks the server which version is current and whether an update is optional or required. An optional update shows a prompt the user can dismiss. A required update blocks the app until the user goes to the store, and the button opens the right store page for iOS or Android.
On the padel app, the update prompt was translated into all four of the app's languages: English, Spanish, French and Swedish. A required update in a language the user cannot read would only cause confusion. I also turned the check off in development builds, so the team was not blocked by a prompt while testing.
On the education app I added a welcome screen after an update, with a short list of what is new. The app remembers what it has already shown, so the same message never appears twice. Then I added Sentry for crash and error reporting, so problems in users' hands showed up on my screen instead of in a store review.
What each framework upgrade asked of us
BeforeThe upgrade changed the native setup: a new app delegate on iOS and a new Gradle plugin structure on Android. I rolled the live app back to the previous version.
AfterI built a clean 0.79 project and added our dependencies one at a time. The later version of the app runs on Expo 53, React Native 0.79 and React 19.
BeforeThe new SDK removed the old file system API that our PDF cache and book sharing code used.
AfterI moved the PDF cache to the new file and directory API, switched sharing to the new sharing module, and removed every use of the legacy API.
BeforeBuilds failed on native module versions and on duplicate copies of the same Expo package.
AfterI aligned the native module versions and removed the duplicates, so each package had one version in the build.
BeforeThe app still declared location permissions that no feature used any more.
AfterI upgraded the SDK and removed the unused permissions in the same change, before the next store submission.
Upgrading in a clean room first
The padel app gave me the most useful lesson of this work. I upgraded the live project to React Native 0.79. The upgrade touched the native files on both platforms: iOS moved to a new way of starting the app, and Android moved to a new plugin structure in its build settings. The live app had many native libraries, for maps, payments, Firebase, fonts and permissions.
I rolled the live app back to the previous version. On the same day, I started a separate, clean 0.79 project and added our dependencies to it one at a time: navigation and forms, then Firebase, then maps, fonts and permissions. One of the map libraries needed a small patch before it would build, and I applied it through a patch step that runs after every install.
This changed how I upgrade. A big framework upgrade inside a busy feature project mixes two kinds of failure. When the build breaks, I cannot tell whether the upgrade broke it or the half-finished feature did. In a clean project, each dependency is a separate step, and the step that breaks names the problem.
The later version of the padel app, which was prepared for a second market, runs on Expo 53, React Native 0.79 and React 19. On the education app I followed the same habit for the Expo 54 and 55 upgrades: one upgrade per change, the follow-up fixes right after it, and a test build before anything went to the stores.
The choices that shaped the release process
When should we take a framework upgrade?
Wait until a store or a library forces it
Upgrade in its own change, after proving it in a clean project
Forced upgrades arrive on the store's schedule, not ours. A planned upgrade can be tested without a feature mixed into it.
How do users on old versions get fixes?
Hope they open the store and update
A version check on start, with optional and required updates
An old version should never stay broken against a newer server. The required level is there for the day it is needed.
Where do version numbers live?
Let the build service count them up
In the repository, raised in one release commit
Every store build maps to one commit. The small manual step is worth the certainty.
How does a team of three keep order?
Verbal rules and careful reviews
Written branching rules, commit hooks, pull request checks and issue templates
A rule a tool checks is not forgotten in a busy week, and reviews can focus on behaviour.
The shape of the version check
type UpdatePolicy = { latest: string; minimum: string; storeUrl: string };
function compare(a: string, b: string) {
const x = a.split(".").map(Number);
const y = b.split(".").map(Number);
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0);
if (d !== 0) return Math.sign(d);
}
return 0;
}
function updateState(installed: string, policy: UpdatePolicy) {
if (compare(installed, policy.minimum) < 0) return "required";
if (compare(installed, policy.latest) < 0) return "optional";
return "current";
}What leading three developers looked like day to day
Leading the team was mostly planning and unblocking. I broke features into tasks, assigned them, and followed each one until it was merged. When a teammate was blocked, getting them moving again came before my own tasks.
I also wrote code across most areas of both apps. On the padel app I worked on booking, payments, activities, chat, translations and the app structure. On the education app I built the library, the PDF reader and the chat screen for the AI tutor. Being in the code every day helped the release work. I knew which parts were fragile before I built a release.
Releases and versioning stayed with me on both apps. One person who knows every signing setting, every store listing and every open review is faster than three people who each know a part. The risk is that the knowledge sits in one head. The written rules, the README and the templates were my answer to that risk.
On the education app, each release also had to line up with the backend, which other engineers owned. I coordinated each release with them, so a new app version never shipped before the server it needed. I also wrote the release notes.
What the release work made possible
Shipping became a routine, not an event
A release became a known sequence: a release commit, a build, the testing tracks, submission, and a check that users were moving to the new version. The team could plan features around it.
Code with lint or type errors stopped at the hooks or at the pull request, before it reached the shared branches. Permission texts, privacy settings and unused permissions were checked as part of each release, not after a reviewer asked. Users on old versions were asked to update in their own language, and the welcome screen told them what had changed.
Crash reporting changed where problems came from. A crash in a user's hands reached me with a stack trace and the device it happened on, instead of arriving later as a vague complaint.
Neither app fell out of the stores while its framework changed. That is the result I care about most. An app that falls behind its framework becomes harder to upgrade every month, until one day a store requirement makes the upgrade urgent and painful at the same time.
What I would do the same way next time
Put the team rules in on the first day. On the padel app, the commit hooks and the validation workflow existed before most of the features did. Adding rules to a project that already has habits is much harder than starting with them.
Upgrade in a clean project first, then in the real one. The rolled-back React Native upgrade cost me some time. The clean project I built afterwards showed me which dependency needed attention, and that is how I have approached big upgrades since.
Ship the update prompt before you need it. A version check is cheap to build on a calm day. On the day the server must change and old apps must stop, it is too late to add one, because the old apps do not have it.
Keep one person responsible for releases, and write down everything that person knows. Ownership makes releases fast. Writing it down makes sure the team is not stuck when that person is away.
Is your app stuck on an old framework version?
If upgrades keep getting pushed back, or store review keeps sending builds back, I can help you plan the upgrade and set up a release path your team can follow. Let's talk.