Mobile Release SOP

End-to-end runbook for shipping LibreFang mobile builds to TestFlight (iOS) and Play Internal Testing (Android). Pairs with the desktop release flow in Production — these steps cover only what is mobile-specific.

This SOP assumes the mobile CI jobs mobile_android and mobile_ios are already wired up (closed by PR #3970) and that the upload secrets in .github/SECRETS.md are configured.


Included Topics


Prerequisites — one-time per store

Track these in the issue tracker; once done they do not recur.

Apple

  1. Apple Developer Program enrollment ($99 / year). Pick the organisational variant if LibreFang has a legal entity, individual otherwise. Personnel changes inside an organisational team do not force a re-enrollment.
  2. App Store Connect app record with bundle id ai.librefang.app, primary category Productivity. Reserve early — bundle id squatting is irreversible.
  3. Distribution certificate + provisioning profile (Ad Hoc + App Store). Export the cert as .p12 and feed the four APPLE_* signing secrets in .github/SECRETS.md.
  4. App Store Connect API key with the App Manager role. Feed the three APPLE_API_KEY_* upload secrets.
  5. Privacy policy live at librefang.ai/privacy-mobile. The draft template lives at .github/templates/PRIVACY_MOBILE_TEMPLATE.md — review with legal, then publish under docs/src/app/privacy-mobile/.
  6. App Privacy questionnaire in App Store Connect. Declare:
    • "API key" → user-input contact info
    • "Agent conversation logs" → user content
    • All entries marked Data Not Linked to User (LibreFang does not bind data to an Apple ID).
  7. Encryption export compliance: the iOS build pipeline already stamps ITSAppUsesNonExemptEncryption = false into the Info.plist and passes uses-non-exempt-encryption: "false" to the TestFlight action — no manual form needed unless future code adds non-standard crypto.

Google

  1. Play Console account ($25 one-time). Enable two-step verification on the developer account before adding any artifact.
  2. App record with package ai.librefang.app, default language English. Same package id as iOS by convention; the bundle ids do not actually need to match across stores but doing so simplifies the ops surface.
  3. Upload key (separate from the app-signing key when Play App Signing is enabled — recommended). Lose the upload key and you can reset it via Play Console; lose the app-signing key Google holds and you cannot ship updates ever again. Back up offline.
  4. Service-account JSON with the Release Manager role on the app. Feed it as GOOGLE_PLAY_SERVICE_ACCOUNT_JSON.
  5. Privacy policy (same URL as iOS).
  6. Data Safety form: identical content to Apple's questionnaire.
  7. Content rating: Productivity / Tools, no objectionable content.

Version-mapping scheme

Both stores reject builds whose build numbers regress. The CI pipeline derives them deterministically:

WhereFieldFormulaExample
Bothdisplay version[workspace.package].version from root Cargo.toml, with any -betaN / -rcN suffix stripped2026.4.28-beta72026.4.28
iOSCFBundleShortVersionStringdisplay version2026.4.28
iOSCFBundleVersionYYYYMMDDNN UTC, NN = run_number % 1002026042901
AndroidversionNamedisplay version2026.4.28
AndroidversionCodesame YYYYMMDDNN integer2026042901

The binding ceiling is Play's versionCode hard cap of 2,100,000,000 (int32 has more headroom but Play caps lower). The YYYYMMDDNN scheme stays under that cap until 2100-01-01. Before then, switch the formula to a smaller domain (e.g. (year - 2024) * 1_000_000 + month * 10_000 + day * 100 + nn) and update both jobs in release.yml.

Why YYYYMMDDNN? Both stores want monotonic build numbers across all historical uploads. A date-based scheme makes "what date was this build cut?" answerable without cross-referencing git, which matters during App Review where the rollback window is hours.


Cutting a release

The end-to-end flow once the prerequisites are in place:

  1. Bump [workspace.package].version in root Cargo.toml. Conventional commits: chore(release): vYYYY.M.D-betaN.
  2. Tag the commit vYYYY.M.D-betaN and push the tag. The mobile jobs run only on tags matching refs/tags/v*.
  3. Watch the mobile_android and mobile_ios jobs in the Actions tab. Both have continue-on-error: true so a flake does not break the desktop matrix; that means you have to actually look at the summary, not just the green check at the bottom of the workflow run.
  4. On success, .aab / .apk / .ipa attach to the GitHub Release and the unattended upload pushes to Play Internal Testing / TestFlight respectively.
  5. Note the build numbers (Mobile / Android / Mobile / iOS step summaries print versionName=… versionCode=… and CFBundleShortVersionString=… CFBundleVersion=…). You will need these for the store dashboards.

If the upload secrets are not yet configured, the jobs degrade gracefully: GitHub Release upload still happens, the store-promotion step prints ::notice:: and exits 0.


TestFlight track

After mobile_ios finishes the upload step:

  1. App Store Connect → My Apps → LibreFang → TestFlight tab. The build appears in Processing (5–30 min for a clean build, longer if binary symbol upload is slow).
  2. While processing: fill in the Test Information fields (test credentials → see below; what to test; description). These are shared across all TestFlight builds — fill once, edit only when the message changes.
  3. Add the build to the librefang-internal group (up to 100 emails, no review). Internal testers receive the invite email within minutes.
  4. For the first External Testing build, submit for Beta App Review. Turnaround is ~24 h; common rejection reasons:
    • Reviewer cannot reach a daemon. Provide a test daemon URL and an API key in the App Review notes. Rotate the API key on the test daemon after the review window closes.
    • "Sign-in wall": the wizard requires a daemon URL on first launch, which Apple may flag as a sign-in wall. The mitigation is a clear paragraph in the review notes explaining that the daemon connection IS the product.
  5. Each build expires 90 days after upload. Set a calendar reminder to push a fresh internal build monthly so the beta channel never goes dark.

Play Internal Testing track

After mobile_android finishes the upload step:

  1. Play Console → LibreFang → Testing → Internal testing. The .aab appears in the active release within ~5 min.
  2. Pre-launch report: Play runs the build on a fleet of real devices and emails crash reports. Always read these before promoting to closed / open testing — the pre-launch report often surfaces device-specific layout breakage that the simulator misses.
  3. Tester management is by email list — paste internal team emails into the Testers tab. The opt-in URL is shareable.
  4. To promote to Closed Testing: Play Console → Closed testing → Promote release → pick the same .aab. No re-upload needed.
  5. Open Testing = public beta. Defer until the product is solid; open testing has the same review surface as production.

Promoting to production

This is a deliberate human gate — both stores impose review latency, and a regression caught in beta is cheap, while one caught after an auto-promote is expensive.

iOS

  1. App Store Connect → App Store tab → iOS App → New version with the target CFBundleShortVersionString. Pick the TestFlight build to submit for review.
  2. App Review turnaround: 1–3 days first time, faster on updates (often under 24 h). Common follow-ups: reviewer asks for credentials, or for a video walkthrough.
  3. Choose Manual release on the version page so the green light from App Review does not push the build to users instantly.

Android

  1. Play Console → Production → Create new release → reuse the existing AAB (do not rebuild — same artifact must traverse all tracks).
  2. Roll out gradually: 5% → 20% → 50% → 100% over 48–72 h. Watch the Vitals and ANR rate at each step. Halt and roll back if ANR rate spikes above 0.47% or crash rate above 1.09% (Play's "bad behaviour" thresholds).
  3. First production release: full review, ~3 days. Subsequent updates: typically under 12 h.

Recovering from failed uploads

SymptomLikely causeFix
mobile_android step "Upload to Play Internal Testing" fails with 403Service account lacks the Release Manager role, or the app is not yet published to Internal Testing for the first timeGrant the SA the role; if first upload, do a manual upload via Play Console once to bootstrap the track
mobile_ios step "Upload to TestFlight" fails Authentication credentials are missing or invalidAPI key revoked or APPLE_API_KEY_P8 was pasted with stripped newlinesRegenerate the key; ensure the secret value retains the BEGIN/END PEM markers verbatim
Apple build stuck in Processing > 24 hApple-side processing delay (rare); occasionally a binary issueWait 24 h, then file a TSI ticket through Apple Developer support if still stuck
Play release shows "Internal app sharing only" instead of going to Internal TestingThe CI uploaded to the internal app sharing lane (no track set)Double-check track: internal on the r0adkll/upload-google-play action invocation
versionCode X has already been usedSame calendar day, > 100 release re-runs (the NN slot wraps)Manually bump the NN slot in the workflow for the next run, or wait for the next UTC day

Calendar checklist

Annual or recurring tasks the SOP depends on — set calendar reminders:

  • Apple Distribution cert: rotate yearly (see .github/SECRETS.md → "Rotation runbook (yearly Apple cert refresh)").
  • App Store Connect API key: rotate yearly or sooner on personnel change.
  • TestFlight build refresh: monthly — push a fresh internal build before the 90-day expiry so the beta channel does not go dark.
  • Apple Developer Program: $99 renewal yearly. Auto-renew is enabled by default; verify the billing card has not expired.
  • Privacy policy: re-read yearly. Stores allow surprise audits — drift between stated and actual data practices is the most common cause of unprompted app removal.