Skip to content
All writing

Shipping to both stores: the release runbook

6 min read

mobile
expo
startup
buildinpublic

On 27 August, Canny Cart went live in both stores — the App Store and Google Play — as v1.0.1. Twenty-nine parts of building in public and the actual shipping turned out to be its own project, with its own runbook: ten numbered documents covering accounts, blockers, credentials, listings, privacy declarations, ratings, testing tracks, submission, and launch day.

This post is that runbook, compressed to what I'd tell someone releasing a solo app for the first time. The headline: the two things that decided the launch date weren't code, the highest-consequence failure mode was silent, and the one rejection that actually arrived was about a button.

TL;DR — Start Google Play's closed test first: new personal accounts need 12 testers continuously opted in for 14 days before you can even apply for production, and the clock starts when the twelfth joins. Get a domain early — Play won't roll out to any track without a Data Safety form, which needs a privacy-policy URL. Fix the blockers ranked by rejection probability: real in-app account deletion (Apple 5.1.1(v)), email deliverability (a reviewer's test account must receive its verification code), and third-party data disclosure. Guard the silent killer: a missing production outputs file makes the build ship pointed at your sandbox without a single error. Expect one rejection anyway — mine was 5.1.1(iv): a camera permission gate with an "Allow" button. And approved ≠ released: phased/staged rollout, manual release, so approval doesn't put you live at 3 a.m.

(Part 30 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The two things that decided the date (neither was code)

1. Google Play's closed test. New personal developer accounts must run a closed test with at least 12 testers, continuously opted in, for 14 days — before you can even apply for production access. The 14 days start when you have 12 testers, not when you create the track; drop below twelve and the clock can reset. Nothing else in the release takes as long, so the runbook's first instruction is: start this first, today, and let it run in the background while you do everything else. (You'll also need twelve friends who'll actually install a test build. Budget the social capital.)

2. A domain. I didn't have one. It gates far more than a pretty listing: Play won't roll out to any track — closed testing included — without a completed Data Safety form, and that form needs a privacy-policy URL that resolves. The compliance sprint had built the legal pages; without a domain they weren't anywhere. Buy the domain before you think you need it.

Everything else, in the runbook's words, "is paperwork, assets and a build."

The code blockers, ranked by rejection probability

The blockers document ordered the repo's problems by how likely each was to get the app rejected. Three mattered:

🔴 Account deletion was a request, not a deletion. The app filed a DeletionRequest for me to fulfil by hand — and Apple's 5.1.1(v) expects deletion initiated and completed from the app. "Email us and we'll get to it" is the classic rejection. The cheapest fix was to actually automate it: a server-side deletion Lambda (verify the caller → S3 objects → DynamoDB rows → the Cognito user, last). A day of work that removed the risk permanently; it gets its own part.

🔴 Email deliverability. The transactional-email service was still in its sandbox mode, where mail only reaches pre-verified addresses. The consequence isn't cosmetic: a reviewer creating a test account would never receive their verification code, and the sign-up flow needs that code. This alone can fail review. Auth emails were confirmed on Cognito's own default sender (unaffected), and app emails moved to a provider with a verified domain — no code path in the review flow depended on a sandboxed sender.

🟠 An undisclosed third party. Barcode lookups call the Open Food Facts databases — a data flow to a third party that the privacy declarations had to name. Store data-safety forms are an inventory of every external call your app makes; the provider chain had quietly added one.

The highest-consequence silent failure

The mobile app selects its backend config per build profile from committed per-environment files, with a fallback to the root file when the profile's file is missing (Part 2's mechanism). Robust for development. Catastrophic for a first release: amplify_outputs.production.json didn't exist yet — because the production backend hadn't been deployed — and the selector would have silently fallen back to the root file, which is generated from my personal sandbox.

A production build made that day would have shipped to both stores pointed at my dev backend, with zero errors anywhere in the pipeline. The runbook flags it as the highest-consequence silent failure in the whole release, and the pre-build checklist now includes:

ls -la amplify_outputs.production.json
grep -o '"user_pool_id": "[^"]*"' amplify_outputs.production.json   # NOT your sandbox pool
npm ci --dry-run                                                     # EAS installs with npm ci
Enter fullscreen mode Exit fullscreen mode

Deploy main, generate the production outputs, commit, then build. Fallbacks that are convenient in dev are exactly the ones that hurt in release.

The rejection that came anyway

Build 6 of 1.0.1 was rejected under 5.1.1(iv) — on the barcode scanner's camera permission gate. The scanner opened with an explanation screen: an "Allow camera" button and a "Not now" button that let you delay the request. Both are violations, and the rejection letter itself explained why: the permission button's wording must be neutral (not "Allow"), and the flow must not let the user defer the system prompt.

The fix followed the letter's own recommendation. Tapping "Barcode" is the intent, so the system prompt fires the moment the screen opens, with the explanation text as a backdrop rather than a gate in front. A denied state shows "Continue" (re-askable, Android) or "Open Settings" (iOS), and leaving is a standard ✕ — navigation chrome, not a permission choice. Then a sweep of every other permission surface — voice, receipt camera, photo-to-list, avatar, notifications — confirmed they already called the system prompt directly. The resubmission passed.

The lesson: permissions are UX that reviewers grade. Your carefully explained pre-prompt is, to Apple, a gate between the user and the system dialog. Ask in context, ask immediately, let the OS do the asking.

Approved is not released

Two runbook rules for launch day:

  • Phased / staged rollout, always. Apple's Phased Release spreads updates over seven days (1% → 100%) and can be paused; Play's staged rollout goes 20% → 50% → 100% and can be halted — but a 100% release can't be cleanly unpublished. For a backend that's never been load-tested by strangers, this is the only sane default.
  • Manual release, not automatic — so approval doesn't put you live at 3 a.m. while you're asleep. Approval is a notification; release is a decision.

And one thing that turns on at production that nobody expects: the prod → dev replicator is wired only in the production deployment, so it had been dormant for a month. On the first real user it woke up and began mirroring masked writes into dev — exactly as designed, and still slightly startling to watch.

Only after both listings existed did the website's store badges become real links; they'd shipped as disabled placeholders until then, per a note in the repo's own instructions — "until the listings exist."

After: the cadence

v1.0.1 was live on 27 August. Since then: 1.0.2 (the receipt-linking schema), 1.0.3 (stored sessions survive a failed token refresh; dismissed announcements survive sign-out), and 1.0.4 (save a receipt from Expenses and link it on the full page) is built and heading to the stores. Each one runs the same pre-build checklist, the same npm ci --dry-run, the same staged rollout. The runbook didn't retire at launch; it became the release process.

What I took away

  • Start the slowest external clock first. Twelve testers for fourteen days is the launch date; everything else fits inside it.
  • Buy the domain early. It gates privacy forms, which gate every store track, which gate the testers.
  • Rank blockers by rejection probability, not by how annoying they are to fix. Deletion, deliverability, disclosure.
  • Hunt the silent fallback. The scariest bug in a release is the one that produces a working build pointed at the wrong world.
  • Permissions are graded UX. No "Allow" buttons, no "Not now" — the OS dialog, in context, immediately.
  • Approved ≠ released. Manual release, phased rollout, and sleep.

Next up

Part 31: the backend moved to a fresh AWS account mid-flight — and the env-scoped naming convention from the earliest days of the series is what let a staged table rename deploy clean.

What was the non-code thing that actually decided your launch date? I'd bet it wasn't in your roadmap either.


0 reactions · 0 comments

Discuss on dev.to