My Forced Update Reached Nobody — Build Log #6
The server said 'anyone older than this must update.' Users on old versions saw nothing at all. The cause was a design that waves you through whenever the check fails. Here's how I patched 13 generations of old builds to close it.
My iOS app has a forced update gate — the usual thing. The server returns "minimum supported version," the app compares it against its own, and if it's older, you get a wall you can't dismiss.
I published a new release and raised the server's number. Then a report came in: users on the old version were seeing nothing at all. And this was the second time — the same thing had happened on the previous release.
What was actually happening
First I checked the server. It was returning the correct, updated minimum. No problem there.
The problem was in the app. Reading the code, the check worked like this:
- A minimum version is hardcoded into the app
- On launch, the app asks the server and raises that number if the server's is higher
- If the app's own version is below the resulting number, show the wall
Looks reasonable. The trap was the hardcoded value in step 1: it was a number from many releases ago. Which means if the server call doesn't land, effectively no version is ever considered old.
Worse, if the app couldn't read its own version number for any reason, the code treated it as infinitely new. Unreadable meant newest, which meant waved through.
Put together, the gate read as:
If the network fails, or the version can't be read, let them in.
That's fail-open (a design that opens when it fails). The opposite is fail-closed (a design that shuts when it fails). A gate whose entire job is to stop people has to be fail-closed — because the situations where you need it to stop are, almost by definition, situations where something has gone wrong.
Why raising the server number couldn't fix it
Here's the bind: the flawed logic lives inside the app, and app internals normally require an app update to change. But the people I need to reach are precisely the people who haven't updated.
Chicken and egg.
Fortunately the app supports over-the-air delivery — swapping the app's code without going through store review. But it comes with a constraint: delivery is partitioned by compatible-version lineage. Ship to a new lineage and the old one never sees it.
Checking the delivery history confirmed it: every recent push had gone to the two newest lineages only, and the lineage containing the affected build had received nothing at all.
How I fixed it
Simple to state, delicate to execute: for each old build, check out the source as it existed when that build was compiled, patch only the gate, and ship to that lineage.
Why go back to the historical source? Because shipping current code to an old lineage bricks the app. The native shell (the part that passed review) and the delivered code have to agree. The instant the new code reaches for a component the old shell doesn't contain, the app won't launch.
So for each version I verified:
- The exact commit that build was compiled from
- That since then, components had only been added, with no version changes to existing ones
- That the historical source never references the new components — so nothing can leak in
Then I changed exactly two things in the gate:
- Raise the hardcoded minimum from its ancient value to the current floor, so the check stands on its own without the server
- Flip the unreadable-version fallback from "infinitely new" to "infinitely old", so an unreadable version always stops
That converts fail-open to fail-closed. Then I repeated it across all 13 affected lineages.
Doing that by hand 13 times guarantees a mistake somewhere, so partway through I moved it into a script — defaulting to a mode that only prints what it would do, with automatic reconciliation so nothing gets skipped.
The accident I caused doing it
Being honest about this one. Mid-way through, I shipped a broken state to every production user.
Here's how. The procedure above leaves the repository temporarily holding old source with a modified gate. While it was in that state, I ran an unrelated deploy command.
The delivery mechanism packages whatever is in the working tree at that moment. So the half-rewritten state went out to everyone on the current release — who then got hit with a forced-update wall despite already being up to date.
I recovered by re-shipping correct content from a clean tree, but the lesson is blunt:
Never run a delivery command while a script is rewriting the repository.
If delivery reads "whatever is in this folder right now," then deploying while something else is editing that folder is not a risk — it's a guaranteed incident. That's now a written rule at the top of my runbook.
What's still broken
Honestly: the root cause isn't fixed. Making the gate genuinely fail-open-proof requires changing the native shell and clearing store review. That's queued for the next build.
Until then, every time I raise the minimum version, I have to re-ship to every old lineage individually. The script makes it cheap, but it's still structural debt.
Takeaways
- Write gates fail-closed. Network failure, unreadable values, anything unexpected — all of it should fall on the "stop" side.
- Make the default the safest value. "The server will overwrite it later" collapses on the day the overwrite doesn't arrive.
- Logic baked into a client is the slowest thing to fix. Which is exactly why it should be written strictly from day one.
- Don't run outward-facing commands while the working tree is being rewritten.
FAQ
Do you even need a forced update gate? If old clients depend on old server behavior, you can't evolve the server. Keeping the ability to cut off old versions buys you future freedom. But the lesson here is that you shouldn't assume you have that ability until you've verified the mechanism actually fires.
Isn't over-the-air code swapping dangerous? Yes. Ship content that doesn't match the native shell and the app won't start. That's exactly why I verified, one build at a time, that components had only been added and no existing component had changed version.
The app in this post
Everything above is from building THE TASK: AI To-Do Prep (App Store, free), an iOS app I develop solo. The idea is that AI fills a written task out until it's actually actionable. The rest of the series lives in the build log.
Related reading
Comments (0)
No comments yet. Be the first to leave one.