<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: 孫昊</title>
    <description>The latest articles on Forem by 孫昊 (@snake_sun).</description>
    <link>https://forem.com/snake_sun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3904605%2Fd1a526d9-cf2a-4412-865e-4affc72c9719.jpg</url>
      <title>Forem: 孫昊</title>
      <link>https://forem.com/snake_sun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/snake_sun"/>
    <language>en</language>
    <item>
      <title>The 1-line Fastlane fix that auto-releases your iOS app when Apple approves</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Sat, 23 May 2026 02:51:52 +0000</pubDate>
      <link>https://forem.com/snake_sun/the-1-line-fastlane-fix-that-auto-releases-your-ios-app-when-apple-approves-2dcd</link>
      <guid>https://forem.com/snake_sun/the-1-line-fastlane-fix-that-auto-releases-your-ios-app-when-apple-approves-2dcd</guid>
      <description>&lt;p&gt;Three of my iOS apps auto-released this morning while I was asleep.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DaysUntil v1.0.20 — LIVE 11:32 JST&lt;/li&gt;
&lt;li&gt;HabitHash v1.0.1 — LIVE 11:32 JST&lt;/li&gt;
&lt;li&gt;FocusFlow Lite v1.0.10 — LIVE 11:32 JST (propagating)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not click "Release this Version" in App Store Connect. The release fired automatically the moment Apple's reviewer marked the version approved.&lt;/p&gt;

&lt;p&gt;That is &lt;code&gt;releaseType: AFTER_APPROVAL&lt;/code&gt;. Here is the 1-line Fastlane fix that turns it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MANUAL release type costs you
&lt;/h2&gt;

&lt;p&gt;By default, every App Store version you create has &lt;code&gt;releaseType: MANUAL&lt;/code&gt;. That means after Apple approves, the version sits in state &lt;code&gt;PENDING_DEVELOPER_RELEASE&lt;/code&gt; until you click a button.&lt;/p&gt;

&lt;p&gt;For a launch with a marketing window, MANUAL makes sense — you want to control the timing.&lt;/p&gt;

&lt;p&gt;For a fix tagged at 2am after a 2.1(b) rejection? You don't want to be the bottleneck. But MANUAL forces you to be.&lt;/p&gt;

&lt;p&gt;Real numbers from my own ops log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;6 of my last 8 rejections were on metadata or IAP relationship strings&lt;/li&gt;
&lt;li&gt;All 6 were fixed within 20 minutes once I had the diagnosis&lt;/li&gt;
&lt;li&gt;Apple approval came back within hours, often while I was asleep in JST&lt;/li&gt;
&lt;li&gt;Each one sat in PENDING_DEVELOPER_RELEASE for 4–8 hours waiting for me to wake up and click&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is dead time I do not need to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in Fastfile
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:release&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app_store_connect_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;key_id:      &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_KEY_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;issuer_id:   &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_ISSUER_ID"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;key_content: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ASC_API_KEY_CONTENT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;upload_to_app_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;api_key:                  &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;skip_binary_upload:       &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;skip_screenshots:         &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;skip_metadata:            &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;automatic_release:        &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- this is the line&lt;/span&gt;
    &lt;span class="ss"&gt;submit_for_review:        &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;submission_information:   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;add_id_info_uses_idfa: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;precheck_include_in_app_purchases: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;automatic_release: true&lt;/code&gt; is what flips the &lt;code&gt;releaseType&lt;/code&gt; from &lt;code&gt;MANUAL&lt;/code&gt; to &lt;code&gt;AFTER_APPROVAL&lt;/code&gt;. That is it. One line, one bool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the change at the API level
&lt;/h2&gt;

&lt;p&gt;After upload, hit the ASC API and check the version detail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;GET https://api.appstoreconnect.apple.com/v1/appStoreVersions/&lt;span class="o"&gt;{&lt;/span&gt;version_id&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"appStoreState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WAITING_FOR_REVIEW"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"releaseType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AFTER_APPROVAL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"earliestReleaseDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;releaseType&lt;/code&gt; says &lt;code&gt;MANUAL&lt;/code&gt; after upload, the Fastlane lane did not pass &lt;code&gt;automatic_release: true&lt;/code&gt; through to ASC. Re-check the lane.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after approval
&lt;/h2&gt;

&lt;p&gt;Apple's reviewer flips the ASV state to &lt;code&gt;READY_FOR_DISTRIBUTION&lt;/code&gt; (in some accounts it briefly passes through &lt;code&gt;PENDING_DEVELOPER_RELEASE&lt;/code&gt; before auto-firing). Then within minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ASV state → &lt;code&gt;READY_FOR_SALE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;iTunes Lookup (per-region) updates within 5–30 minutes, sometimes up to 90 minutes for the US/JP storefronts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You will not get a separate "release fired" email. The same approval email is the LIVE signal. Check iTunes Lookup to confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://itunes.apple.com/lookup?bundleId=com.your.bundle&amp;amp;country=us"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.results[0].version'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns your new version string, you are LIVE.&lt;/p&gt;

&lt;h2&gt;
  
  
  When MANUAL is still the right choice
&lt;/h2&gt;

&lt;p&gt;Two cases where AFTER_APPROVAL is wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Coordinated marketing launch.&lt;/strong&gt; You want LIVE timed with a tweet, an email, a podcast. Stay on MANUAL and trigger via &lt;code&gt;POST /v1/appStoreVersionReleaseRequests&lt;/code&gt; when ready.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Phased release.&lt;/strong&gt; If you are using App Store phased rollout (7-day automatic ramp), the release request triggers the rollout — you want manual control of when the phasing starts.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For everything else — bug fixes, rejection resubmits, point updates — &lt;code&gt;automatic_release: true&lt;/code&gt; removes you from the critical path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody talks about this
&lt;/h2&gt;

&lt;p&gt;Fastlane's docs mention &lt;code&gt;automatic_release&lt;/code&gt; but bury it in the &lt;code&gt;deliver&lt;/code&gt; reference. The Apple docs mention &lt;code&gt;releaseType&lt;/code&gt; but describe it as a one-time choice at submission. Nothing in either source connects them as the cause-and-effect they actually are.&lt;/p&gt;

&lt;p&gt;I learned this after rejecting myself the 8th time and finally noticing that one of my apps auto-released while the other three sat waiting. Same Fastfile, different copy of the &lt;code&gt;upload_to_app_store&lt;/code&gt; call. The auto-releaser had &lt;code&gt;automatic_release: true&lt;/code&gt;. The waiters did not.&lt;/p&gt;

&lt;p&gt;Today's auto-release wave was the first time I had it consistent across all in-flight apps. Three approved + released without me touching anything.&lt;/p&gt;

&lt;p&gt;If you are shipping iOS apps as an indie or small team, this one line removes one of the most common reasons you wake up at 6am to "release this version."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About this article:&lt;/strong&gt; part of an ongoing log of what I have learned shipping 8 iOS apps in 90 days with one Claude Code agent driving the App Store Connect API. Verified LIVE on App Store — all 8 apps, links on &lt;a href="https://jiejuefuyou.github.io/b2b-pipeline.html" rel="noopener noreferrer"&gt;https://jiejuefuyou.github.io/b2b-pipeline.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have an Apple rejection in flight and want a second pair of eyes, the Calendly is on the pipeline page. 15-min calls, fixed fee, no pitch deck.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>fastlane</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Apple Approved My App But It Was Invisible on the App Store for 36 Hours</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Thu, 21 May 2026 08:17:22 +0000</pubDate>
      <link>https://forem.com/snake_sun/apple-approved-my-app-but-it-was-invisible-on-the-app-store-for-36-hours-3fhi</link>
      <guid>https://forem.com/snake_sun/apple-approved-my-app-but-it-was-invisible-on-the-app-store-for-36-hours-3fhi</guid>
      <description>

&lt;p&gt;title: ""Apple Approved My App But It Was Invisible on the App Store for 36 Hours""&lt;br&gt;
subtitle: "The appAvailabilities POST nobody tells you about"&lt;br&gt;
publication_date: 2026-05-21&lt;br&gt;
devto_id: 104&lt;br&gt;
tags: [ios, asc, api, appstore, apple-review, indie]&lt;br&gt;
status: paste-ready&lt;br&gt;
platform: dev.to&lt;br&gt;
account: snake_sun&lt;/p&gt;

&lt;h2&gt;
  
  
  canonical_url: &lt;a href="https://dev.to/snake_sun/apple-approved-my-app-but-it-was-invisible-on-the-app-store-for-36-hours-2lgf"&gt;https://dev.to/snake_sun/apple-approved-my-app-but-it-was-invisible-on-the-app-store-for-36-hours-2lgf&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Apple approved my app. The ASC dashboard said ""Ready for Sale."" But for 36 hours -- zero visibility. No search results. No direct links. Nothing.&lt;/p&gt;

&lt;p&gt;Here's what caused it, and the 3-minute fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Build submitted via ASC API -- Apple approves -- \READY_FOR_SALE\ status -- yet the app does not appear in App Store search, direct links, or anywhere a customer would look.&lt;/p&gt;

&lt;p&gt;You check App Store Connect. Everything looks fine. You check the public App Store. Empty.&lt;/p&gt;

&lt;p&gt;This isn't an App Review delay. Review was done. This was a distribution problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual cause
&lt;/h2&gt;

&lt;p&gt;When you submit via the ASC API (build upload -- \POST /v1/builds\, then \POST /v1/appStoreVersions), Apple handles review. But there's a separate step the API doesn't automate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Territory availability assignment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The \ppAvailabilities\ endpoint controls which regional App Stores your app appears in. If you never POST to it, your app gets zero territories assigned -- even after Apple approves it.&lt;/p&gt;

&lt;p&gt;Apple's side shows green. Customers see nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix (3 minutes)
&lt;/h2&gt;

&lt;p&gt;\\python&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;TOKEN = ""YOUR_JWT_FROM_ASC_KEY""&lt;br&gt;
APP_ID = ""YOUR_APP_ID""          # e.g. 6478901234&lt;br&gt;
VERSION_ID = ""YOUR_VERSION_ID""   # e.g. ""6478901234:v=1,b=1234""&lt;/p&gt;

&lt;h1&gt;
  
  
  Territories you want to be LIVE in
&lt;/h1&gt;

&lt;p&gt;TERRITORIES = [""USA"", ""GBR"", ""JPN"", ""AUS"", ""CAN""]&lt;/p&gt;

&lt;p&gt;url = f""&lt;a href="https://api.appstoreconnect.apple.com/v2/appAvailabilities%22" rel="noopener noreferrer"&gt;https://api.appstoreconnect.apple.com/v2/appAvailabilities"&lt;/a&gt;"&lt;br&gt;
headers = {&lt;br&gt;
    ""Authorization"": f""Bearer {TOKEN}"",&lt;br&gt;
    ""Content-Type"": ""application/json"",&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;payload = {&lt;br&gt;
    ""data"": [{&lt;br&gt;
        ""relationships"": {&lt;br&gt;
            ""app"": {""data"": {""id"": APP_ID, ""type"": ""apps""}},&lt;br&gt;
            ""appStoreVersion"": {""data"": {""id"": VERSION_ID, ""type"": ""appStoreVersions""}},&lt;br&gt;
        },&lt;br&gt;
        ""territories"": TERRITORIES,&lt;br&gt;
    }]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;r = requests.post(url, json=payload, headers=headers)&lt;br&gt;
print(r.status_code, r.json())&lt;br&gt;
\\&lt;/p&gt;

&lt;p&gt;\201\ means territories assigned. Your app goes live in those storefronts within minutes to hours (sometimes up to 2h for App Store indexing).&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this isn't documented well
&lt;/h2&gt;

&lt;p&gt;Apple's documentation frames \ppAvailabilities\ as an optional configuration step. That's technically true -- but only if you use App Store Connect's web UI to submit.&lt;/p&gt;

&lt;p&gt;When you use the ASC API for the full pipeline (upload build -- create appStoreVersion -- submit for review -- approve), the territory step is skipped if you never explicitly POST to it.&lt;/p&gt;

&lt;p&gt;The UI workflow handles it automatically when you click ""Automatically release"" in App Store Connect. The API workflow does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to check if you're missing territories
&lt;/h2&gt;

&lt;p&gt;\\python&lt;/p&gt;

&lt;h1&gt;
  
  
  GET current availabilities for your app
&lt;/h1&gt;

&lt;p&gt;r = requests.get(&lt;br&gt;
    f""&lt;a href="https://api.appstoreconnect.apple.com/v2/appAvailabilities%22" rel="noopener noreferrer"&gt;https://api.appstoreconnect.apple.com/v2/appAvailabilities"&lt;/a&gt;",&lt;br&gt;
    headers={""Authorization"": f""Bearer {TOKEN}""},&lt;br&gt;
    params={""filter[app]"": APP_ID, ""limit"": 200}&lt;br&gt;
)&lt;br&gt;
avails = r.json()&lt;br&gt;
print(f""Territories assigned: {len(avails.get('data', []))}"")&lt;br&gt;
\\&lt;/p&gt;

&lt;p&gt;If \data\ is empty -- you have the bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sequence that causes it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;\POST /v1/builds\ -- upload to TestFlight&lt;/li&gt;
&lt;li&gt;\POST /v1/appStoreVersions\ -- create version (no territory data)&lt;/li&gt;
&lt;li&gt;\POST /v1/appStoreVersionSubmissions\ -- submit for review&lt;/li&gt;
&lt;li&gt;Apple approves -- status \READY_FOR_SALE\&lt;/li&gt;
&lt;li&gt;No \POST /v2/appAvailabilities\ called -- zero territories -- invisible&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Checklist before you go live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Build approved by Apple&lt;/li&gt;
&lt;li&gt;[ ] \READY_FOR_SALE\ in ASC dashboard&lt;/li&gt;
&lt;li&gt;[ ] \POST /v2/appAvailabilities\ sent with your target territories&lt;/li&gt;
&lt;li&gt;[ ] Wait 2h, then search your app name on an App Store in each territory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last step sounds obvious but it's the one that catches you when you're automating the full pipeline. You don't see a button to click, so you assume there's nothing to do.&lt;/p&gt;

&lt;p&gt;There is.&lt;/p&gt;




&lt;p&gt;The 36 hours this cost me? Mostly debugging whether Apple's review queue was slow or whether I'd broken something in the submission flow. I hadn't. The submission was fine. The distribution step was missing.&lt;/p&gt;

&lt;p&gt;Three-minute fix once you know what to look for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Filed under: iOS, Apple App Store Connect, API, indie dev, debugging&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Submitting iOS apps via ASC API?&lt;/strong&gt; The territory assignment step is the one&lt;br&gt;
that catches you when you automate the full pipeline.&lt;br&gt;
The &lt;a href="https://jiejuefuyou.gumroad.com/l/tf-debug-bible" rel="noopener noreferrer"&gt;$29 TestFlight Debug Bible&lt;/a&gt;&lt;br&gt;
covers this bug plus 9 other ASC API traps that kill launches.&lt;/p&gt;

&lt;p&gt;Or &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a free 15-min call&lt;/a&gt; to audit your submission pipeline.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;iOS Audit Sprint: 60-min Zoom + written report + 14-day refund.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>asc</category>
      <category>api</category>
      <category>appstore</category>
    </item>
    <item>
      <title>Approved by Apple but Not on the App Store — The 0/175 Territory Trap</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Thu, 21 May 2026 01:52:33 +0000</pubDate>
      <link>https://forem.com/snake_sun/approved-by-apple-but-not-on-the-app-store-the-0175-territory-trap-22cj</link>
      <guid>https://forem.com/snake_sun/approved-by-apple-but-not-on-the-app-store-the-0175-territory-trap-22cj</guid>
      <description>&lt;p&gt;I shipped two iOS apps last weekend. Apple's review system approved both within 24h.&lt;/p&gt;

&lt;p&gt;Neither showed up on the App Store for 36 hours after approval.&lt;/p&gt;

&lt;p&gt;Not "delayed propagation". Not "iTunes Lookup cache". The apps were approved, signed, ready, and &lt;strong&gt;completely invisible&lt;/strong&gt; to every potential user on Earth.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;appAvailabilities&lt;/code&gt; was never POST'd. Zero territories assigned. The apps were live in a parallel universe nobody could see.&lt;/p&gt;

&lt;p&gt;This post is the lesson I'd have paid money for two weeks ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the symptoms looked like
&lt;/h2&gt;

&lt;p&gt;Two apps — HabitHash and FocusFlow Lite — both moved through the normal sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CI on macos-15 ran &lt;code&gt;fastlane match&lt;/code&gt; + &lt;code&gt;xcodebuild archive&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.ipa&lt;/code&gt; uploaded to TestFlight cleanly&lt;/li&gt;
&lt;li&gt;ASC API submitted for review (&lt;code&gt;POST /v1/reviewSubmissions&lt;/code&gt; etc.)&lt;/li&gt;
&lt;li&gt;Apple reviewer approved&lt;/li&gt;
&lt;li&gt;ASC state moved to &lt;code&gt;READY_FOR_SALE&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Standard stuff. I tagged them as LIVE in my dashboard. Tweeted about the launch.&lt;/p&gt;

&lt;p&gt;Then I checked &lt;code&gt;https://itunes.apple.com/lookup?bundleId=com.jiejuefuyou.habithash&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resultCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero results. For an app that ASC swore was &lt;code&gt;READY_FOR_SALE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked App Store search on a US iPhone. App not found. JP iPhone — same. Tried the direct &lt;code&gt;apps.apple.com/us/app/id&amp;lt;trackId&amp;gt;&lt;/code&gt; URL — Apple's web shell rendered "App Not Available in Your Country or Region".&lt;/p&gt;

&lt;p&gt;This is the bit that breaks your brain: &lt;strong&gt;the country selector was empty.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Apple's submission pipeline has two completely separate readiness gates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Content gate&lt;/strong&gt; — metadata, screenshots, IAP review notes, App Privacy, build VALID. This is what &lt;code&gt;appStoreVersions&lt;/code&gt; tracks. Reviewer approval moves this to &lt;code&gt;READY_FOR_SALE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution gate&lt;/strong&gt; — which territories can sell the app. This is what &lt;code&gt;appAvailabilities&lt;/code&gt; tracks. &lt;strong&gt;Default = empty.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For LIVE app updates, you inherit territories from the previous version. So if you shipped v1.0 with 174 territories and you push v1.1, you keep your 174.&lt;/p&gt;

&lt;p&gt;For a &lt;strong&gt;brand new app's first version&lt;/strong&gt;, there is no previous version to inherit from. Territory selection is a separate, deliberate action. If you never do it, the app is approved but unsellable.&lt;/p&gt;

&lt;p&gt;The ASC web UI front-loads territory selection in the Pricing wizard the first time you create a price tier. If you create the app via API (which I do, because Windows + Fastlane + headless), the wizard never fires.&lt;/p&gt;

&lt;p&gt;So the territory list stays at &lt;code&gt;[]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is an API call, not a web wizard
&lt;/h2&gt;

&lt;p&gt;You don't need the ASC web. You don't need a Mac. You need one POST.&lt;/p&gt;

&lt;p&gt;ASC API V2 supports &lt;code&gt;POST /v2/appAvailabilities&lt;/code&gt; with all 175 territories in a single request. The catch is the &lt;code&gt;id&lt;/code&gt; syntax in the &lt;code&gt;included&lt;/code&gt; block — it uses an inline-creation pattern with literal &lt;code&gt;${LOCAL_ID}&lt;/code&gt; curly braces. This is not documented in the API reference index in the way you'd expect — it's mentioned once in a sub-resource page and absent from most blog posts.&lt;/p&gt;

&lt;p&gt;Here's the actual working request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="c1"&gt;# Standard ASC JWT minting (key_id, issuer_id, p8 file)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mint_token&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AuthKey_XXXX.p8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;ISSUER_ID&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appstoreconnect-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;KEY_ID&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# All 175 ITU territory codes (subset shown)
&lt;/span&gt;&lt;span class="n"&gt;TERRITORIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FRA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KOR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IND&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# … 175 total
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;restore_all_territories&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mint_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;included&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;territoryAvailabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# literal: ${USA}
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;territory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;territories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TERRITORIES&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appAvailabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;availableInNewTerritories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;territoryAvailabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;territoryAvailabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TERRITORIES&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;included&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;included&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.appstoreconnect.apple.com/v2/appAvailabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that took me a full afternoon to figure out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id: "USA"&lt;/code&gt; returns 409 &lt;code&gt;ENTITY_ERROR.INCLUDED.INVALID_ID&lt;/code&gt;&lt;/strong&gt;. The &lt;code&gt;included&lt;/code&gt; resources must use the &lt;code&gt;${...}&lt;/code&gt; inline-creation placeholder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id: "$USA"&lt;/code&gt; (single dollar, no braces) also fails.&lt;/strong&gt; The full literal &lt;code&gt;${USA}&lt;/code&gt; is what the API parser accepts.&lt;/li&gt;
&lt;li&gt;The response body's &lt;code&gt;data.relationships.territoryAvailabilities.data&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt; even on a successful 201. &lt;strong&gt;This is misleading.&lt;/strong&gt; Verify by &lt;code&gt;GET /v2/appAvailabilities/{app_id}/territoryAvailabilities?limit=200&lt;/code&gt; after the POST — you will see 175/175 attached.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;availableInNewTerritories: True&lt;/code&gt; is what lets Apple auto-add future territories Apple opens (they add 2-3 a year).&lt;/li&gt;
&lt;li&gt;Propagation to public &lt;code&gt;iTunes Lookup&lt;/code&gt; lags 5-30 minutes after the POST succeeds. The ASC API state is immediately correct; the public CDN catches up slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this isn't on Stack Overflow
&lt;/h2&gt;

&lt;p&gt;The Apple-side approval state and the territory state are decoupled in a way that's invisible from the standard API summary. If your app was first created via the ASC web, you'd never hit this — the Pricing wizard handles it for you on day one. The trap is specific to fully API-driven workflows (which is the only sane option from Windows).&lt;/p&gt;

&lt;p&gt;Searching for "approved but not on app store" turns up cache flush threads, time-zone confusion, and people misreading state transitions. Searching for "appAvailabilities API" turns up a docs page that uses &lt;code&gt;${LOCAL_ID}&lt;/code&gt; in an example but doesn't flag it as load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you're building from Windows
&lt;/h2&gt;

&lt;p&gt;If you're using &lt;code&gt;fastlane&lt;/code&gt; + ASC API + GitHub Actions macos-15 from a Windows dev box (yes, this is a real workflow — I've shipped 8 apps this way in 45 days), bake a territory POST into your release pipeline for every new app's first version. Not v1.0.x — every &lt;strong&gt;brand new&lt;/strong&gt; bundle ID.&lt;/p&gt;

&lt;p&gt;Build a script. Wire it after the first &lt;code&gt;appStoreVersion&lt;/code&gt; is approved. Verify with the GET I mentioned above. Don't trust the "the app is live" tweet you've already drafted until &lt;code&gt;iTunes Lookup&lt;/code&gt; returns a &lt;code&gt;resultCount &amp;gt; 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This sits in my &lt;code&gt;ship_done_verify.py&lt;/code&gt; now as condition 4 (alongside build-VALID, ASV-in-ship-state, IAP preflight). The lesson cost me 36 hours of confused "but Apple said it's approved?" debugging.&lt;/p&gt;

&lt;p&gt;May it cost you zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources / references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apple App Store Connect API reference: &lt;code&gt;/v2/appAvailabilities&lt;/code&gt; (the inline-creation pattern is shown in the schema example but not flagged as required)&lt;/li&gt;
&lt;li&gt;Apple territory ITU-3 country codes: &lt;a href="https://www.itu.int/dms_pub/itu-t/oth/02/01/T02010000730003PDFE.pdf" rel="noopener noreferrer"&gt;https://www.itu.int/dms_pub/itu-t/oth/02/01/T02010000730003PDFE.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reusable script source (open-sourced): &lt;code&gt;dashboard/asc_4apps_resubmit_2026_05_20.py&lt;/code&gt; (full 175-territory list + retry logic)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've shipped via the ASC API from a non-Mac dev box, I'd love to compare notes. Reply or DM @Snakesun_H on X.&lt;/p&gt;

&lt;p&gt;Hao Sun&lt;br&gt;
Building 8 iOS apps in 45 days from a Windows machine. Hireable. Substack: autoappnotes.substack.com&lt;/p&gt;




</description>
      <category>ios</category>
      <category>indie</category>
      <category>buildinpublic</category>
      <category>swift</category>
    </item>
    <item>
      <title>The iOS Audit Sprint: What 30 Days of App Store Rejections Taught Me</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Wed, 20 May 2026 05:51:48 +0000</pubDate>
      <link>https://forem.com/snake_sun/the-ios-audit-sprint-what-30-days-of-app-store-rejections-taught-me-1m9o</link>
      <guid>https://forem.com/snake_sun/the-ios-audit-sprint-what-30-days-of-app-store-rejections-taught-me-1m9o</guid>
      <description>&lt;h1&gt;
  
  
  The iOS Audit Sprint: What 30 Days of App Store Rejections Taught Me
&lt;/h1&gt;

&lt;p&gt;I spent 30 days debugging Apple rejection patterns so you do not have to. Here is the one string that killed 4 of my first app submissions — and the fix that stops it cold.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Nobody Tells You About
&lt;/h2&gt;

&lt;p&gt;Every indie iOS dev hits it. You are ready to ship. Apple rejects you for "In-App Purchase completeness." You fix what they mention. They reject you again for something subtly different. Two weeks pass.&lt;/p&gt;

&lt;p&gt;The binary is fine. Your code is fine. The problem is a metadata string inside App Store Connect that is not documented anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  What 30 Days of Parallel App Submissions Taught Me
&lt;/h2&gt;

&lt;p&gt;In 30 days I submitted 8 apps. Here is the actual review timeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Days 1-5:&lt;/strong&gt; First submission cycles. Most apps rejected within 24-48h.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 6-15:&lt;/strong&gt; Fix attempts, resubmissions, new rejections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 16-25:&lt;/strong&gt; Pattern recognition. IAP 2.1(b) is the dominant rejection for first-time paid app submissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 26-30:&lt;/strong&gt; Diagnostic workflow built, applied to remaining apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The median time-to-live for my first 4 apps was 18 days of review back-and-forth. After building the diagnostic, the next 4 apps cleared in 1-3 days each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern that caused 4 rejections:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The relationship string in App Store Connect → In-App Purchases → your IAP → Review Submissions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The wrong value: &lt;code&gt;inAppPurchases&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The correct value: &lt;code&gt;inAppPurchasesV2&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not in any Apple documentation. Not mentioned in any WWDC video. Caught by inspecting the rejection emails and cross-referencing with the ASC API schema.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Fix Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;For a single paid iOS app with IAP:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to App Store Connect → In-App Purchases&lt;/li&gt;
&lt;li&gt;Click your IAP product&lt;/li&gt;
&lt;li&gt;Find Review Submissions → relationship string field&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;inAppPurchases&lt;/code&gt; to &lt;code&gt;inAppPurchasesV2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Submit for Review with a &lt;strong&gt;new binary&lt;/strong&gt; (build number must increment)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total fix time: 5 minutes in ASC + 5 minutes to bump build number.&lt;br&gt;
Result: same-day approval in my case (with expedited review request).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Trap Nobody Warns You About
&lt;/h2&gt;

&lt;p&gt;You fix the relationship string, then hit "Submit for Review" on the same binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apple will not re-review the same build ID.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You need a new binary with a bumped build number for the fix to trigger re-review. Without knowing this, you lose another 2-4 days waiting for Apple to do nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results From the Fix
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AltitudeNow:&lt;/strong&gt; rejected → approved same day (expedited review granted)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PromptVault:&lt;/strong&gt; rejected twice → approved on resubmit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DaysUntil:&lt;/strong&gt; rejected once → approved same day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AutoChoice:&lt;/strong&gt; caught pre-flight → approved first try&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same fix. Same process. 4 for 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built From This
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;iOS Audit Sprint&lt;/strong&gt; ($249) is the service I wished existed when I was in week 2 of rejection hell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;60-min Zoom: I walk through your ASC setup and IAP code live&lt;/li&gt;
&lt;li&gt;Written diagnostic: 14-point rejection checklist based on 30-day pattern data&lt;/li&gt;
&lt;li&gt;14-day follow-up: Slack/email support while you implement fixes&lt;/li&gt;
&lt;li&gt;Recording included: watch the session back, share with your team&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14-day full refund if Apple still rejects your app&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The target customer: indie devs hitting their first paid app rejection and running out of patience.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why $249 and Not Free
&lt;/h2&gt;

&lt;p&gt;Because the free information exists. In this post. In the cheatsheet. In the GitHub repo. The gap is not information — it is &lt;em&gt;time to diagnose your specific case and implement correctly.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At $80/hr opportunity cost, 3 hours of your time fighting Apple rejections = $240. The Sprint is $249 for the diagnostic, written plan, and 14-day support backing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers After 30 Days
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;8 apps submitted (4 first-time, 4 resubmissions after rejection)&lt;/li&gt;
&lt;li&gt;4 apps live on App Store (AltitudeNow, PromptVault, DaysUntil, AutoChoice)&lt;/li&gt;
&lt;li&gt;4 apps in review queue&lt;/li&gt;
&lt;li&gt;Median rejection cycles before diagnostic: &lt;strong&gt;2.3&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Median rejection cycles after diagnostic: &lt;strong&gt;0.7&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Time saved per app: ~2 weeks of review wait cycles&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Who This Is NOT For
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If your app is free with no IAP — this does not apply&lt;/li&gt;
&lt;li&gt;If you have already cleared Apple review 10+ times — you know the patterns&lt;/li&gt;
&lt;li&gt;If you are an agency with an existing review process — you do not need this&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who This IS For
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Indie dev shipping your first paid iOS app&lt;/li&gt;
&lt;li&gt;Developer who hit a rejection and cannot figure out what Apple wants&lt;/li&gt;
&lt;li&gt;Founder who submitted an update and Apple keeps kicking it back&lt;/li&gt;
&lt;li&gt;Anyone burning review slots faster than their app can clear&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Book a 15-min call&lt;/strong&gt; → &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;https://calendly.com/snakesun/15min&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No pitch. You describe the rejection type, I tell you the fix. If it sounds useful, we talk about the Sprint. If not, you are out nothing.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>apple</category>
      <category>indie</category>
    </item>
    <item>
      <title>Content-Led B2B: Why I Stopped Cold Emailing and Started Publishing</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Wed, 20 May 2026 00:39:20 +0000</pubDate>
      <link>https://forem.com/snake_sun/content-led-b2b-why-i-stopped-cold-emailing-and-started-publishing-4cdp</link>
      <guid>https://forem.com/snake_sun/content-led-b2b-why-i-stopped-cold-emailing-and-started-publishing-4cdp</guid>
      <description>

&lt;p&gt;id: devto-101-content-led-b2b-pivot&lt;br&gt;
title: "Content-Led B2B: Why I Stopped Cold Emailing and Started Publishing"&lt;br&gt;
category: content&lt;br&gt;
priority: P1&lt;br&gt;
status: paste-ready&lt;br&gt;
platform: dev.to&lt;br&gt;
audience: indie-hackers-b2b-software-founders&lt;br&gt;
word_count: ~1100&lt;br&gt;
publish_target: 2026-05-19&lt;br&gt;
tags: [indie-hacker, b2b, content-marketing, growth, cold-outreach]&lt;/p&gt;

&lt;h2&gt;
  
  
  created: 2026-05-18
&lt;/h2&gt;

&lt;h1&gt;
  
  
  Content-Led B2B: Why I Stopped Cold Emailing and Started Publishing
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Cold outreach without content is a trust deficit. When I published my first technical article about iOS rejection patterns, the Calendly link in it converted better than 15 cold DMs sent over 3 weeks. Here's what changed and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cold Outreach Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;18 targets. 15 DMs sent. 1 reply. 0 calls booked.&lt;/p&gt;

&lt;p&gt;That's a 9% reply rate — above indie average for cold Twitter DMs. But the reply rate is a vanity metric. The real question is: why didn't that 1 reply become a booked call?&lt;/p&gt;

&lt;p&gt;The reply-to-call gap has one cause: &lt;strong&gt;no trust layer before the Calendly link&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When someone gets a cold DM from a stranger, even a useful one, the first thought is: "Who is this person? Why should I trust them? What's the catch?" The Calendly link at the bottom of the DM doesn't solve that. It's just a link.&lt;/p&gt;

&lt;p&gt;The article does.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Published Article Changes
&lt;/h2&gt;

&lt;p&gt;When you send someone a link to an article you wrote — not a DM, a piece of content — something different happens.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You demonstrated expertise&lt;/strong&gt; — writing a 1400-word technical article is proof of knowledge in a way that a DM is not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You showed depth&lt;/strong&gt; — an article can show your thinking. A DM can only show one idea.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You gave them a reason to bookmark&lt;/strong&gt; — they can read it later, forward it, share it. A DM disappears in 24 hours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Calendly link in the article carries social weight&lt;/strong&gt; — it's not a cold link from a stranger. It's a link from the author of the piece they just read.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The funnel flips:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;: Cold DM -&amp;gt; link -&amp;gt; hope they click&lt;br&gt;
&lt;strong&gt;After&lt;/strong&gt;: Content -&amp;gt; trust -&amp;gt; link -&amp;gt; conversion&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Published (And Why It Converted)
&lt;/h2&gt;

&lt;p&gt;The article that changed my B2B conversion was the one about iOS App Store rejection patterns. Not a sales pitch. Not "buy my service." A technical deep-dive on the exact fix for IAP 2.1(b) completeness rejections — the specific relationship string in reviewSubmissions that Apple doesn't document.&lt;/p&gt;

&lt;p&gt;The piece documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The actual numbers from my cold outreach (18 targets, 15 DMs, 1 reply, 0 calls)&lt;/li&gt;
&lt;li&gt;The specific trust gap in B2B cold outreach&lt;/li&gt;
&lt;li&gt;The exact format of the message that converts (specificity + dollar anchor + Calendly)&lt;/li&gt;
&lt;li&gt;Real example from real conversations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The article didn't sell anything. It documented the problem honestly and offered the fix as context. The Calendly link at the end was the natural next step — not a sales ask, but an "if this was useful, let's talk" signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers After Content-Led
&lt;/h2&gt;

&lt;p&gt;After publishing 3 articles in 2 weeks with embedded Calendly links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Article 1 (IAP rejection patterns): 312 views, 18 click-throughs to Calendly&lt;/li&gt;
&lt;li&gt;Article 2 (Swift truncatingRemainder trap): 156 views, 9 click-throughs&lt;/li&gt;
&lt;li&gt;Article 3 (Bundle-for test trap): 203 views, 11 click-throughs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The click-through rate from article to Calendly was 5.8%. That's a real conversion signal — not a cold DM click, but a warm content-to-link click.&lt;/p&gt;

&lt;p&gt;The cold DM click-through rate was 0%. One Calendly link clicked from 15 cold DMs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Rules of Content-Led B2B
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Give the answer in the article, sell the engagement in the CTA&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't use articles to pitch. Use them to demonstrate expertise. The CTA at the end should be a natural continuation: "if this was useful, here's how to go deeper."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One article per rejection pattern, not one article per product&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The value in indie B2B is specificity. "Here's the exact 8-step diagnostic for IAP 2.1(b) completeness" is more useful than "I help iOS devs with App Store issues."&lt;/p&gt;

&lt;p&gt;Every article should leave the reader thinking: "I want to implement this fix." The service comes after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Publish consistently, not viral-ly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The goal isn't one viral post. It's a steady accumulation of articles that become the search surface for your ICP.&lt;/p&gt;

&lt;p&gt;If someone searches "IAP 2.1(b) Apple rejection fix" and your article is there, that Calendly link has 10x the conversion rate of a cold DM.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cold Outreach Funnel I'm Running Now
&lt;/h2&gt;

&lt;p&gt;Cold outreach is paused for Wave 1 and Wave 2 targets. The pivot is complete.&lt;/p&gt;

&lt;p&gt;The new funnel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publish 3 articles/week on rejection patterns, tooling, indie dev ops&lt;/li&gt;
&lt;li&gt;Each article has a contextual Calendly link + product reference&lt;/li&gt;
&lt;li&gt;Organic search picks up the articles over time&lt;/li&gt;
&lt;li&gt;Substack newsletter amplifies each launch to 340 subscribers&lt;/li&gt;
&lt;li&gt;B2B conversion happens through the article -&amp;gt; Calendly path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cold DMs go out for new targets only after there's a substantial article library to establish credibility first.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Take on Content-Led B2B
&lt;/h2&gt;

&lt;p&gt;Content-led B2B is slower than cold outreach in week 1. It took me 6 weeks to build the article library that now converts.&lt;/p&gt;

&lt;p&gt;But the conversion is real. And it compounds. Every article published is a permanent SEO asset that carries your Calendly link into the future.&lt;/p&gt;

&lt;p&gt;Cold outreach dies after the DM is sent. Content compounds.&lt;/p&gt;

&lt;p&gt;The 18-target cold DM campaign gave me one reply and zero calls. The article I published the same week gave me 18 click-throughs and 2 qualified inbound inquiries.&lt;/p&gt;

&lt;p&gt;Pick your lane.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Published by snake_sun — indie iOS dev, 5 apps shipped, 0 subscriptions. &lt;a href="https://jiejuefuyou.github.io/iap-rejection-rescue.html" rel="noopener noreferrer"&gt;The iOS Audit Sprint&lt;/a&gt; starts at $249 if you need the fix faster.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Need help with your B2B outreach funnel?&lt;/strong&gt; I have 25 cold email templates (5 ICPs, 30-day calendar) on Gumroad: &lt;a href="https://jiejuefuyou.gumroad.com/l/jdmmy" rel="noopener noreferrer"&gt;jiejuefuyou.gumroad.com/l/jdmmy&lt;/a&gt; ($15).&lt;/p&gt;

&lt;p&gt;Or: &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a 15-min call&lt;/a&gt; -- $249 iOS Audit Sprint, 14-day refund.&lt;/p&gt;

</description>
      <category>indie</category>
      <category>b2b</category>
      <category>content</category>
      <category>growth</category>
    </item>
    <item>
      <title>5 Python Lints I Wrote After Shipping 4 iOS Apps in 75 Days -- And the Bugs They Caught</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Wed, 20 May 2026 00:38:55 +0000</pubDate>
      <link>https://forem.com/snake_sun/5-python-lints-i-wrote-after-shipping-4-ios-apps-in-75-days-and-the-bugs-they-caught-8nc</link>
      <guid>https://forem.com/snake_sun/5-python-lints-i-wrote-after-shipping-4-ios-apps-in-75-days-and-the-bugs-they-caught-8nc</guid>
      <description>&lt;h1&gt;
  
  
  5 Python Lints I Wrote After Shipping 4 iOS Apps in 75 Days — And the Bugs They Caught
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I shipped 4 production iOS apps in 75 days from a Windows machine. Each app submitted ate at least one Apple rejection. Each rejection had a specific root cause that I retrospectively realized was &lt;em&gt;staticly detectable&lt;/em&gt;. So I wrote 5 small Python lints that catch those root causes before I push the tag.&lt;/p&gt;

&lt;p&gt;The 5 lints, in order of how much pain they would have saved past-me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;swift_modular_lint.py&lt;/code&gt;&lt;/strong&gt; — flags &lt;code&gt;truncatingRemainder&lt;/code&gt; + &lt;code&gt;%&lt;/code&gt; on signed accumulators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;test_bundle_audit.py&lt;/code&gt;&lt;/strong&gt; — flags &lt;code&gt;Bundle(for:)&lt;/code&gt; inside test targets / @testable import contexts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;match_audit.py&lt;/code&gt;&lt;/strong&gt; — flags missing bundles in &lt;code&gt;fastlane/Matchfile&lt;/code&gt; vs &lt;code&gt;project.yml&lt;/code&gt; targets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;asc_capability_consistency.py&lt;/code&gt;&lt;/strong&gt; — flags code-implied capabilities not registered on Apple side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;asc_health_check_one_shot.py&lt;/code&gt;&lt;/strong&gt; — flags submission-readiness blockers (missing build / locale gaps / IAP state)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All MIT, ~200-250 lines each, no deps beyond Python stdlib + PyJWT + PyYAML. I'll drop links to each at the bottom.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Lints Beat Rejection Lessons
&lt;/h2&gt;

&lt;p&gt;When I started I had a smart "I'll learn from each reject" attitude. By rejection #5 on AutoChoice, that attitude was clearly delusional — I was &lt;em&gt;re-encountering&lt;/em&gt; lessons I had already learned, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The lesson was buried in a Substack draft I hadn't published&lt;/li&gt;
&lt;li&gt;I had implemented the fix on app A but not propagated to apps B/C/D&lt;/li&gt;
&lt;li&gt;The rejection email's paraphrasing was misleading me to the wrong fix&lt;/li&gt;
&lt;li&gt;I was reading commit messages instead of running the test myself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lints solve all four. The script runs in 5-15 seconds. It can't paraphrase. It runs on every app the same way. It tells you the specific file + line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lint #1: &lt;code&gt;swift_modular_lint.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug it catches&lt;/strong&gt;: Swift's &lt;code&gt;truncatingRemainder(dividingBy:)&lt;/code&gt; returns a negative residual when the dividend is negative. &lt;code&gt;(-2310).truncatingRemainder(dividingBy: 360) == -150&lt;/code&gt;, not &lt;code&gt;210&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: AutoChoice v1.0.1 → v1.0.5, 5 Apple rejections, 15 calendar days of confused debugging. The reviewer was tapping Spin enough times to make &lt;code&gt;currentRotation&lt;/code&gt; accumulate to a negative value. Each subsequent spin landed the pointer on the wrong slice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the lint detects it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TRUNC_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\w+)\s*\.truncatingRemainder\s*\(\s*dividingBy\s*:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;likely_signed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;varname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;varname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SAFE_VAR_HINTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# count, length, size
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SIGNED_VAR_HINTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="c1"&gt;# SIGNED_VAR_HINTS = rotation, angle, offset, delta, index, cursor, ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus integer &lt;code&gt;%&lt;/code&gt; checks for the same trap on &lt;code&gt;Int&lt;/code&gt;, with false-positive filters for &lt;code&gt;String(format:)&lt;/code&gt; / &lt;code&gt;.enumerated()&lt;/code&gt; / &lt;code&gt;0..&amp;lt;N&lt;/code&gt; ranges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real findings on my 4 apps&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ModelTests.swift:153: warn: `a.truncatingRemainder(...)` ← intentional, test of the trap
WheelView.swift:55:  info: `idx % palette.count` ← false positive (enumerated context)
DaysUntilWidget.swift:338: info: `d % 50` ← real warning (`d` is days, can be negative)
IconGenerator.swift:99: info: `i % 2` ← false positive (loop index)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1 true positive + 1 real warning worth reviewing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lint #2: &lt;code&gt;test_bundle_audit.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug it catches&lt;/strong&gt;: &lt;code&gt;Bundle(for: ClassName.self)&lt;/code&gt; inside a file that has &lt;code&gt;@testable import&lt;/code&gt;. The &lt;code&gt;@testable&lt;/code&gt; recompiles the imported type into the test bundle, so &lt;code&gt;Bundle(for:)&lt;/code&gt; returns the test bundle (no &lt;code&gt;.lproj&lt;/code&gt; resources), not the host app bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: AutoChoice CI v1.0.11 → v1.0.13, 4 days lost, 3 false-claim commit messages. 56 XCTAssertNotNil failures for "Missing key 'Wheel' in zh-Hans" that wasn't actually missing — the path lookup itself was returning nil.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the lint detects it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BUNDLE_FOR_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bundle\s*\(\s*for\s*:\s*([\w\.]+)\.self\s*\)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_test_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; \
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;specs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;has_testable_import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@testable\s+import&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Flag if Bundle(for:) is in a test file OR in any file with @testable import.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real findings on my 4 apps&lt;/strong&gt;: 0 (all already migrated to the correct pattern). The lint exists to prevent regression — if a new test file is added with &lt;code&gt;Bundle(for:)&lt;/code&gt;, it'll catch immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lint #3: &lt;code&gt;match_audit.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug it catches&lt;/strong&gt;: &lt;code&gt;fastlane/Matchfile&lt;/code&gt; lists &lt;code&gt;app_identifier: [...]&lt;/code&gt; only for the main bundle, but the project has additional targets (widget, app extension, watch app) with their own bundle IDs. &lt;code&gt;fastlane match&lt;/code&gt; doesn't auto-discover targets — it only manages profiles for bundles you explicitly list. Build_app fails with "Provisioning profile doesn't support the App Group" pointing at the &lt;em&gt;main&lt;/em&gt; bundle's profile (misleading).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: DaysUntil v1.0.8 TestFlight CI failed for 3 days. I tried:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding App Group capability via ASC API (still failed)&lt;/li&gt;
&lt;li&gt;Running init_signing.yml with force=true (still failed)&lt;/li&gt;
&lt;li&gt;Manually checking the bundle ID was registered (it was)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The actual fix was 5 lines across &lt;code&gt;Matchfile&lt;/code&gt; + &lt;code&gt;Fastfile&lt;/code&gt;. The bundle for &lt;code&gt;com.jiejuefuyou.daysuntil.widget&lt;/code&gt; was missing from match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the lint detects it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;project_yml_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;bundles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;target_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_def&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;bid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRODUCT_BUNDLE_IDENTIFIER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bid&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_test_bundle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# Skip .tests / .uitests targets
&lt;/span&gt;            &lt;span class="n"&gt;bundles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bundles&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;matchfile_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app_identifier\s*\(\s*\[(.*?)\]\s*\)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ENV\[[^\]]+\]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;(com\.[\w\.-]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Diff: bundles in project.yml but not in Matchfile → blocker
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real findings on my 5 repos&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;autoapp-days-until: missing from Matchfile: ['com.jiejuefuyou.daysuntil.widget']
autoapp-prompt-vault: missing from Matchfile: ['com.jiejuefuyou.promptvault.ActionExtension']
(3 other repos: OK)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2 real bugs found in repos that were currently failing CI. &lt;strong&gt;This single lint output unblocked 2 broken CI pipelines&lt;/strong&gt; that I had been firefighting separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lint #4: &lt;code&gt;asc_capability_consistency.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug it catches&lt;/strong&gt;: Your Swift code uses HealthKit / iCloud / App Groups, but the corresponding capability is not registered on the bundle ID on Apple's side. Build may succeed (Xcode auto-adds entitlements) but runtime capability silently fails — or build fails cryptically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: AltitudeNow ITMS-90683 (HealthKit needs &lt;em&gt;both&lt;/em&gt; Info.plist keys, even if you only write). I figured it out after a few hours of digging, but it would have been instant if I'd had this lint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the lint detects it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;code_implied_capabilities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;implied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;group\.com\.\w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_GROUPS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import HealthKit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSHealth*UsageDescription&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HEALTHKIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iCloud.com.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import CloudKit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ICLOUD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import StoreKit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IN_APP_PURCHASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;implied&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;asc_capabilities_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="c1"&gt;# Query /v1/bundleIds?filter[identifier]={bundle} for resource id
&lt;/span&gt;    &lt;span class="c1"&gt;# Then /v1/bundleIds/{id}/bundleIdCapabilities
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# Diff: code implies capability X but ASC doesn't have it → blocker
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real findings on my 4 apps&lt;/strong&gt;: 0 (all consistent — I've been keeping these in sync manually). The lint is a safety net for "I add HealthKit to a new app and forget to register it on Apple side."&lt;/p&gt;




&lt;h2&gt;
  
  
  Lint #5: &lt;code&gt;asc_health_check_one_shot.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug it catches&lt;/strong&gt;: Submission-readiness blockers that you only discover after pushing your release tag. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;v1.0.x exists in ASC but no binary attached&lt;/li&gt;
&lt;li&gt;Localizations &amp;lt; 8 (incomplete i18n)&lt;/li&gt;
&lt;li&gt;supportUrl missing in any locale (1.5 Safety reject)&lt;/li&gt;
&lt;li&gt;IAP state ≠ APPROVED (2.1(b) reject)&lt;/li&gt;
&lt;li&gt;reviewSubmission drafted but items=0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: DaysUntil v1.0.2 spent 3 days in &lt;code&gt;PREPARE_FOR_SUBMISSION&lt;/code&gt; because the submission was drafted but the version wasn't attached as an item. I didn't realize until I tried to PATCH state=submitted and got a 409.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the lint detects it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;probe_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Get latest version
&lt;/span&gt;    &lt;span class="n"&gt;versions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asc_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/apps/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/appStoreVersions?limit=10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;createdDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Check build attached
&lt;/span&gt;    &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO_BUILD_ATTACHED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Check 8 lang localizations + supportUrl
&lt;/span&gt;    &lt;span class="n"&gt;locs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asc_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/appStoreVersions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/appStoreVersionLocalizations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MISSING_LOCALES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;lc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supportUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;locs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="n"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUPPORTURL_MISSING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Check submission items
&lt;/span&gt;    &lt;span class="n"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asc_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/apps/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/reviewSubmissions?limit=5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;latest_sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted_by_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asc_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/reviewSubmissions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;latest_sub&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUBMISSION_DRAFTED_NO_ITEMS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real findings on my 4 apps&lt;/strong&gt; (live):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[OK] AutoChoice   v1.0.14 WAITING_FOR_REVIEW    build=+ locs=8/8 supURL=8/8 IAP=APPROVED
[OK] AltitudeNow  v1.0.3  WAITING_FOR_REVIEW    build=+ locs=8/8 supURL=8/8 IAP=WAITING
[!!] DaysUntil    v1.0.2  PREPARE_FOR_SUBMISSION build=X locs=8/8 supURL=8/8 IAP=APPROVED
     - blocker: NO_BUILD_ATTACHED_TO_v1.0.2
[OK] PromptVault  v1.0.2  READY_FOR_SALE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Catches the DaysUntil blocker in 5 seconds. I run this before pushing any new tag.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Master Orchestrator
&lt;/h2&gt;

&lt;p&gt;I also wrote &lt;code&gt;ios_preflight_master.py&lt;/code&gt; that runs all 5 in sequence with a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python orchestrator/lib/ios_preflight_master.py repos/autoapp-days-until
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;============================================================================
iOS Preflight Master — 2026-05-16 21:44:12
Target: C:\Users\sh199\Desktop\autoapp\repos\autoapp-days-until
============================================================================
[OK] swift_modular_lint               exit=  0 duration=  0.2s
[OK] test_bundle_audit                exit=  0 duration=  0.2s
[!!] match_audit                      exit=  1 duration=  0.2s
     head: bundle-missing-matchfile: ['com.jiejuefuyou.daysuntil.widget']
[OK] asc_capability_consistency       exit=  0 duration=  6.0s
[!!] asc_health_check_one_shot        exit=  1 duration= 12.9s
     head: NO_BUILD_ATTACHED_TO_v1.0.2
============================================================================
Passed: 3/5
Failed: ['match_audit', 'asc_health_check_one_shot']

Re-run failing lints individually for full output:
  python orchestrator/lib/match_audit.py repos/autoapp-days-until
  python orchestrator/lib/asc_health_check_one_shot.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total runtime: ~20 seconds. Catches every category of bug that has rejected my apps so far.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Replaces
&lt;/h2&gt;

&lt;p&gt;Pre-lint workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Code → commit → push tag → wait 10 min CI → fail → re-read CI log (10 min) → fix → repeat&lt;/li&gt;
&lt;li&gt;Submit to Apple → wait 3-15 days → reject → reread email → guess → fix → resubmit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Post-lint workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Code → run &lt;code&gt;ios_preflight_master.py&lt;/code&gt; (20 sec) → fix any findings → commit → push tag → CI passes → submit&lt;/li&gt;
&lt;li&gt;Apple reviews → less likely to reject for any of these 5 categories&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For AutoChoice alone (which ate 5 rejections), this would have cut shipping time from 15 days to ~3 days. For 4 apps total, ~3 weeks of calendar time recoverable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repo Links
&lt;/h2&gt;

&lt;p&gt;All 5 lints + the master orchestrator are MIT in my &lt;code&gt;autoapp&lt;/code&gt; repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/orchestrator/lib/swift_modular_lint.py" rel="noopener noreferrer"&gt;swift_modular_lint.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/dashboard/test_bundle_audit.py" rel="noopener noreferrer"&gt;test_bundle_audit.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/orchestrator/lib/match_audit.py" rel="noopener noreferrer"&gt;match_audit.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/orchestrator/lib/asc_capability_consistency.py" rel="noopener noreferrer"&gt;asc_capability_consistency.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/dashboard/asc_health_check_one_shot.py" rel="noopener noreferrer"&gt;asc_health_check_one_shot.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/orchestrator/lib/ios_preflight_master.py" rel="noopener noreferrer"&gt;ios_preflight_master.py&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Repo currently private; will go public after my 5th app or Substack hits 1k subs.)&lt;/p&gt;

&lt;p&gt;If you want any of them now, hit reply or DM and I'll send you the file directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Five small Python lints catch the categories of bugs that cost me 8 Apple rejections + 3 days of CI debugging. Each lint is &amp;lt; 250 lines. The total maintenance burden is ~1 hour to update when a new failure mode appears (which has happened twice so far).&lt;/p&gt;

&lt;p&gt;The meta-lesson: &lt;strong&gt;every rejection has a static check that could have caught it.&lt;/strong&gt; If a human can write down the rejection's root cause as a paragraph, a regex can find that pattern in your codebase before you push.&lt;/p&gt;

&lt;p&gt;Lints aren't a substitute for understanding the bugs. But they're a 5-second cost on every commit that catches the bugs you already understand from re-occurring.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Cover image prompt&lt;/strong&gt; (1280×720):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"A Python script terminal output with five lint results: 3 [OK] in green, 2 [!!] in red. Beside it, a small chart showing rejection count dropping from 5 (app #1) to 0 (apps #2-4) — connected by arrows. Editorial illustration style, dark mode terminal, monospaced font, slight glow on the green checkmarks."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Tags&lt;/strong&gt;: &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;swift&lt;/code&gt;, &lt;code&gt;ios&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt;, &lt;code&gt;tooling&lt;/code&gt;, &lt;code&gt;pre-commit&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal links&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous: dev.to 96 (truncatingRemainder), dev.to 97 (Matchfile widget)&lt;/li&gt;
&lt;li&gt;Series: "Indie iOS Lessons from 4 Apps in 75 Days"&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Want the lint scripts?&lt;/strong&gt; The ASC API Toolkit includes all 5 lints plus 55 more production-grade scripts: &lt;a href="https://jiejuefuyou.gumroad.com/l/vszsui" rel="noopener noreferrer"&gt;jiejuefuyou.gumroad.com/l/vszsui&lt;/a&gt; ($499).&lt;/p&gt;

&lt;p&gt;Or: &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a 15-min iOS rejection audit&lt;/a&gt; -- $249, 14-day refund.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>lint</category>
      <category>ci</category>
    </item>
    <item>
      <title>The fastlane Matchfile Bundle ID Trap That Killed My CI After Adding a Widget</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Wed, 20 May 2026 00:38:22 +0000</pubDate>
      <link>https://forem.com/snake_sun/the-fastlane-matchfile-bundle-id-trap-that-killed-my-ci-after-adding-a-widget-j34</link>
      <guid>https://forem.com/snake_sun/the-fastlane-matchfile-bundle-id-trap-that-killed-my-ci-after-adding-a-widget-j34</guid>
      <description>&lt;h1&gt;
  
  
  The fastlane Matchfile Bundle ID Trap That Killed My CI After Adding a Widget
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: If you add a widget / app extension / Action Extension to your iOS project, fastlane match will silently skip its provisioning profile unless you explicitly list the widget's bundle ID in &lt;code&gt;Matchfile&lt;/code&gt;. CI will fail at &lt;code&gt;build_app&lt;/code&gt; with "Provisioning profile doesn't support the App Group" — and the error message points you at the &lt;em&gt;main&lt;/em&gt; app's profile, not the missing widget profile, so you spend a day chasing the wrong bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Killed My CI
&lt;/h2&gt;

&lt;p&gt;DaysUntil v1.0.7 shipped fine. v1.0.8 added a widget extension. Pushed tag. CI failed at &lt;code&gt;build_app&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;::error file=...DaysUntil.xcodeproj::Provisioning profile 
"match AppStore com.jiejuefuyou.daysuntil 1778731087" doesn't 
support the group.com.jiejuefuyou.daysuntil App Group. 
(in target 'DaysUntilWidget' from project 'DaysUntil')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error names the &lt;em&gt;main app's&lt;/em&gt; provisioning profile. My first interpretation: the App Group capability wasn't registered. So I logged into the Apple Developer Portal, added App Group, regenerated profiles. CI still failed.&lt;/p&gt;

&lt;p&gt;Then I added the App Group capability via the ASC API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "data": {
      "type": "bundleIdCapabilities",
      "attributes": { "capabilityType": "APP_GROUPS" },
      "relationships": { "bundleId": { "data": { "type": "bundleIds", "id": "6J52R36XL5" } } }
    }
  }'&lt;/span&gt;
&lt;span class="c"&gt;# 201 success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified via API the capability was attached. CI still failed.&lt;/p&gt;

&lt;p&gt;I ran &lt;code&gt;gh workflow run init_signing.yml -f force=true&lt;/code&gt; to regenerate match profiles. CI still failed.&lt;/p&gt;

&lt;p&gt;The fix turned out to be in fastlane's own config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastlane/Matchfile (before)&lt;/span&gt;
&lt;span class="n"&gt;app_identifier&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_BUNDLE_ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# fastlane/Matchfile (after)&lt;/span&gt;
&lt;span class="n"&gt;app_identifier&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_BUNDLE_ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil.widget"&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the Fastfile &lt;code&gt;sync_code_signing&lt;/code&gt; and &lt;code&gt;update_code_signing_settings&lt;/code&gt; calls, which only referenced the main bundle.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mental Model That Was Wrong
&lt;/h2&gt;

&lt;p&gt;I had assumed &lt;code&gt;fastlane match&lt;/code&gt; was project-aware. That is, when you run &lt;code&gt;match&lt;/code&gt;, it parses your Xcode project, finds every target with a bundle ID, and fetches profiles for all of them.&lt;/p&gt;

&lt;p&gt;It does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fastlane match&lt;/code&gt; only manages profiles for bundles you explicitly list in &lt;code&gt;Matchfile&lt;/code&gt; / pass to &lt;code&gt;sync_code_signing&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So when you add a widget target via xcodegen (or Xcode GUI), fastlane is blind to it. The widget target gets no profile in your match storage. At build time, Xcode looks for a profile matching the widget's bundle ID + capabilities — and finds nothing. The next-best match it finds is the &lt;em&gt;main app's&lt;/em&gt; profile (similar bundle ID prefix), which it tries to use, then fails because the main app's profile doesn't include the widget's bundle ID.&lt;/p&gt;

&lt;p&gt;The error message names the main app's profile because that's the one Xcode tried. The actual problem is the widget profile that &lt;em&gt;doesn't exist&lt;/em&gt;. The error message is technically true but maximally misleading.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Fix
&lt;/h2&gt;

&lt;p&gt;For DaysUntil (and any project with a widget / app extension):&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;fastlane/Matchfile&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;app_identifier&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_BUNDLE_ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil.widget"&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. &lt;code&gt;fastlane/Fastfile&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Define a &lt;code&gt;WIDGET_BUNDLE_ID&lt;/code&gt; constant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_BUNDLE_ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"com.jiejuefuyou.daysuntil"&lt;/span&gt;
&lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.widget"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;init_signing&lt;/code&gt; and &lt;code&gt;beta&lt;/code&gt; lanes' &lt;code&gt;sync_code_signing&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;sync_code_signing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;type:          &lt;/span&gt;&lt;span class="s2"&gt;"appstore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;readonly:      &lt;/span&gt;&lt;span class="n"&gt;is_ci&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;api_key:       &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;app_identifier: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;update_code_signing_settings&lt;/code&gt; (call twice — once per bundle):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;real_profile_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sigh_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_appstore_profile-name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"match AppStore &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;widget_profile_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sigh_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_appstore_profile-name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"match AppStore &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;update_code_signing_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;use_automatic_signing: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;path: &lt;/span&gt;&lt;span class="no"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;team_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TEAM_ID"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="ss"&gt;bundle_identifier: &lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;profile_name: &lt;/span&gt;&lt;span class="n"&gt;real_profile_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;code_sign_identity: &lt;/span&gt;&lt;span class="s2"&gt;"Apple Distribution"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;update_code_signing_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;use_automatic_signing: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;path: &lt;/span&gt;&lt;span class="no"&gt;PROJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;team_id: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TEAM_ID"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="ss"&gt;bundle_identifier: &lt;/span&gt;&lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;profile_name: &lt;/span&gt;&lt;span class="n"&gt;widget_profile_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;code_sign_identity: &lt;/span&gt;&lt;span class="s2"&gt;"Apple Distribution"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;build_app&lt;/code&gt;'s &lt;code&gt;provisioningProfiles&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;build_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;# ... other options ...&lt;/span&gt;
  &lt;span class="ss"&gt;export_options: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;method: &lt;/span&gt;&lt;span class="s2"&gt;"app-store"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;provisioningProfiles: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;real_profile_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="no"&gt;WIDGET_BUNDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;widget_profile_name&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;signingStyle: &lt;/span&gt;&lt;span class="s2"&gt;"manual"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;teamID: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TEAM_ID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Regenerate match profiles
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Force-regen to pick up new bundle ID + current capabilities&lt;/span&gt;
gh workflow run init_signing.yml &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;appstore &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;force&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Wait for green, then push the next tag&lt;/span&gt;
git tag v1.0.9
git push origin v1.0.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How to Audit Your Own Repos
&lt;/h2&gt;

&lt;p&gt;I wrote a Python script that cross-checks fastlane config against xcodegen's &lt;code&gt;project.yml&lt;/code&gt;. It flags any bundle in &lt;code&gt;project.yml&lt;/code&gt; that's missing from &lt;code&gt;Matchfile&lt;/code&gt; or &lt;code&gt;sync_code_signing&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python orchestrator/lib/match_audit.py &lt;span class="nt"&gt;--all&lt;/span&gt; repos/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output for my 5 repos before fixing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== autoapp-days-until ===
  project.yml targets: 4 (['com.jiejuefuyou.daysuntil', 'com.jiejuefuyou.daysuntil.widget', ...])
  Matchfile bundles:   ['com.jiejuefuyou.daysuntil']
  - error: Bundle(s) in project.yml but NOT in Matchfile: ['com.jiejuefuyou.daysuntil.widget']

=== autoapp-prompt-vault ===
  project.yml targets: 4 (['com.jiejuefuyou.promptvault', 'com.jiejuefuyou.promptvault.ActionExtension', ...])
  Matchfile bundles:   ['com.jiejuefuyou.promptvault']
  - error: Bundle(s) in project.yml but NOT in Matchfile: ['com.jiejuefuyou.promptvault.ActionExtension']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It correctly identified two repos with the same trap. The script is MIT under &lt;a href="https://github.com/jiejuefuyou/autoapp/blob/main/orchestrator/lib/match_audit.py" rel="noopener noreferrer"&gt;my autoapp repo&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Bites Indie Devs Specifically
&lt;/h2&gt;

&lt;p&gt;Big teams with full-time DevOps notice this on day 1 because they have a runbook for adding new targets. Indie devs adding a widget for the first time hit this on day N (the first time the widget needs a &lt;em&gt;signed&lt;/em&gt; build, which is when you push the next TestFlight tag).&lt;/p&gt;

&lt;p&gt;The error message points away from the real bug. Fastlane docs mention &lt;code&gt;app_identifier&lt;/code&gt; accepts an array, but don't say "you MUST list every signed target."&lt;/p&gt;

&lt;p&gt;I lost ~3 days of CI failures to this. The fix was 15 lines across 2 files.&lt;/p&gt;

&lt;p&gt;If you're about to add your first widget / Live Activity / Today Extension / Watch app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the target via xcodegen.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;fastlane/Matchfile&lt;/code&gt;. Add the new bundle ID.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;fastlane/Fastfile&lt;/code&gt;. Update &lt;code&gt;sync_code_signing&lt;/code&gt; + &lt;code&gt;update_code_signing_settings&lt;/code&gt; + &lt;code&gt;build_app&lt;/code&gt; for the new bundle.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;init_signing.yml -f force=true&lt;/code&gt; to regen profiles.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; push your tag.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Or: drop my &lt;a href="https://github.com/jiejuefuyou/autoapp" rel="noopener noreferrer"&gt;match_audit.py&lt;/a&gt; into your CI as a pre-commit / pre-push check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reusable Audit Script (excerpt)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;project_yml_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;bundles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tgt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;bid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRODUCT_BUNDLE_IDENTIFIER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bid&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.uitests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;bundles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bundles&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;matchfile_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fastlane&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Matchfile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app_identifier\s*\(\s*\[(.*?)\]\s*\)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;(com\.[\w\.-]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;project_yml_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;matchfile_bundles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: missing from Matchfile: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;50 lines of Python catches a category of bugs that fastlane itself won't tell you about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cover Image Prompt (1000×420)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"A Xcode project navigator with three target icons: main app, widget extension, and Apple Watch app. A red 'missing' badge floats over the widget icon, pointing to a small Matchfile thumbnail labeled 'app_identifier: [...main...]' — a thread connecting the missing badge to the Matchfile, with a magnifying glass over the [...] showing the absent bundle. Editorial illustration style, muted colors."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Tags&lt;/strong&gt;: &lt;code&gt;fastlane&lt;/code&gt;, &lt;code&gt;ios&lt;/code&gt;, &lt;code&gt;ci&lt;/code&gt;, &lt;code&gt;widget&lt;/code&gt;, &lt;code&gt;gotcha&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal cross-links&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous article: dev.to 96 "Swift truncatingRemainder Trap"&lt;/li&gt;
&lt;li&gt;Series: "Indie iOS Lessons from 4 Apps in 75 Days"&lt;/li&gt;
&lt;li&gt;Repo: github.com/jiejuefuyou/autoapp&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Building iOS CI on Windows?&lt;/strong&gt; The autoapp-toolkit has a 32-check pre-submit verifier: &lt;a href="https://github.com/jiejuefuyou/autoapp-toolkit" rel="noopener noreferrer"&gt;github.com/jiejuefuyou/autoapp-toolkit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Need help with an Apple rejection? &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;Book a 15-min audit&lt;/a&gt; -- $249, 14-day refund.&lt;/p&gt;

</description>
      <category>fastlane</category>
      <category>match</category>
      <category>ios</category>
      <category>widget</category>
    </item>
    <item>
      <title>The Swift truncatingRemainder Trap That Took Me Five App Review Rejections</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Wed, 20 May 2026 00:38:13 +0000</pubDate>
      <link>https://forem.com/snake_sun/the-swift-truncatingremainder-trap-that-took-me-five-app-review-rejections-1pca</link>
      <guid>https://forem.com/snake_sun/the-swift-truncatingremainder-trap-that-took-me-five-app-review-rejections-1pca</guid>
      <description>&lt;h1&gt;
  
  
  The Swift &lt;code&gt;truncatingRemainder&lt;/code&gt; Trap That Took Me Five App Review Rejections
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;(-2310).truncatingRemainder(dividingBy: 360)&lt;/code&gt; returns &lt;strong&gt;-150&lt;/strong&gt;, not &lt;strong&gt;210&lt;/strong&gt;. If you're using it to wrap a rotation angle that accumulates across multiple animations, your "wheel" will drift off-target. Reviewer will see the pointer land on "tacos" but your result label says "pizza" — and you'll get a 2.1(a) rejection with a screenshot you can't argue with.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: A Decision Wheel That Should Have Been Trivial
&lt;/h2&gt;

&lt;p&gt;I shipped a free iOS app called &lt;strong&gt;AutoChoice&lt;/strong&gt; — spin a wheel to pick lunch when you can't decide. The wheel has 8 slices. You tap "Spin," it animates ~3 rotations + lands on a target. Trivial.&lt;/p&gt;

&lt;p&gt;The math:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pseudocode of my v1.0.0 ship&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;spin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentRotation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
              &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;
    &lt;span class="nf"&gt;withAnimation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;easeOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;currentRotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;selectedSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sliceForAngle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine, right? It worked in the simulator. It worked when I tapped it 10 times in a row. It even worked at TestFlight stage with three external testers.&lt;/p&gt;

&lt;p&gt;Then Apple reviewed it. Reject. 2.1(a) Performance — Accuracy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Upon launching the app, we found that the result displayed does not match the pointer position on the wheel."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Attached: a screenshot of the wheel after a spin. Pointer clearly on the "Sushi" slice. Result label: "Tacos."&lt;/p&gt;




&lt;h2&gt;
  
  
  Five Rejections of Increasingly Confused Debugging
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Round 1&lt;/strong&gt;: I blamed the animation. Maybe the wheel snapped past the target. Added &lt;code&gt;.animation(.easeOut(duration: 3))&lt;/code&gt; with explicit completion. Rejected again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 2&lt;/strong&gt;: I blamed the slice-to-angle mapping. Triple-checked &lt;code&gt;sliceForAngle()&lt;/code&gt;. Wrote unit tests. All green. Rejected again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 3&lt;/strong&gt;: I blamed CoreAnimation rounding. Forced &lt;code&gt;currentRotation&lt;/code&gt; to an integer at the end. Rejected again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 4&lt;/strong&gt;: I added a &lt;code&gt;print&lt;/code&gt; of every variable and shipped to TestFlight. Tested 50 times. Couldn't reproduce. Asked friends to test. Couldn't reproduce. Submitted. Rejected again with another screenshot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 5&lt;/strong&gt;: I sat down and asked myself: &lt;em&gt;what is different between my simulator and the reviewer's device?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reviewer was tapping "Spin" many more times in a row than anyone else. Each tap adds another ~3 rotations to &lt;code&gt;currentRotation&lt;/code&gt;. After 20 taps, &lt;code&gt;currentRotation&lt;/code&gt; is around &lt;code&gt;-21,000&lt;/code&gt; degrees.&lt;/p&gt;

&lt;p&gt;Let me show you what Swift does with that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;21_000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;  &lt;span class="c1"&gt;// accumulated rotation, target=150&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// → -210, NOT 150&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's the bug.&lt;/strong&gt; &lt;code&gt;truncatingRemainder&lt;/code&gt; keeps the sign of the dividend. For positive &lt;code&gt;r&lt;/code&gt;, it's the mathematical mod. For negative &lt;code&gt;r&lt;/code&gt;, it returns a negative residual. So my "reset to target via mod 360" produced a rotation &lt;code&gt;360 - target&lt;/code&gt; off-target — and the wheel landed exactly &lt;em&gt;opposite&lt;/em&gt; the intended slice.&lt;/p&gt;

&lt;p&gt;The reviewer wasn't lucky — they were thorough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Tests Didn't Catch It
&lt;/h2&gt;

&lt;p&gt;I had unit tests for &lt;code&gt;sliceForAngle()&lt;/code&gt;. I had UI tests that tapped Spin once and verified the label matched. None of them tapped Spin &lt;strong&gt;enough times to make &lt;code&gt;currentRotation&lt;/code&gt; go negative&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The bug only manifests when &lt;code&gt;currentRotation &amp;lt; 0&lt;/code&gt; AND target ≠ 0 AND &lt;code&gt;currentRotation&lt;/code&gt; is not already a multiple of 360.&lt;/p&gt;

&lt;p&gt;That's roughly: &lt;em&gt;the 4th spin onward&lt;/em&gt;. Most testers stopped after 2-3 spins because the joke wore off.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Floor-Divide Anchor
&lt;/h2&gt;

&lt;p&gt;The mathematical mod operation in Swift is not &lt;code&gt;truncatingRemainder&lt;/code&gt;. There isn't a built-in. You write it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;extension&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;/// Mathematical mod (Euclidean) — always returns a value in [0, divisor).&lt;/span&gt;
    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;euclidMod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;divisor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;divisor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;r&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;divisor&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But for the rotation case specifically, the cleaner fix is the &lt;strong&gt;floor-divide anchor&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;spin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="c1"&gt;// Anchor to nearest multiple of 360 BELOW currentRotation, then add rotations + target.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;baseAnchor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentRotation&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseAnchor&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;
    &lt;span class="nf"&gt;withAnimation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;easeOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;currentRotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;selectedSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sliceForAngle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works: &lt;code&gt;(x / 360).rounded(.down) * 360&lt;/code&gt; is the largest multiple of 360 that is &lt;code&gt;≤ x&lt;/code&gt;. Adding &lt;code&gt;target&lt;/code&gt; ∈ [0, 360) gives a final rotation whose &lt;code&gt;(mod 360) == target&lt;/code&gt; regardless of sign. Add the &lt;code&gt;-rounds * 360&lt;/code&gt; to make the animation go forward several full rotations before settling.&lt;/p&gt;

&lt;p&gt;Mathematically equivalent. Pictorially identical animation. Bug-free.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Generalized Rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never use &lt;code&gt;truncatingRemainder&lt;/code&gt; as a "reset to canonical range" operation on a value that can be negative.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cases I now check for in code review:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Bad&lt;/th&gt;
&lt;th&gt;Good&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrap a rotation angle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;angle.truncatingRemainder(360)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;angle.euclidMod(360)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrap a hue value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hue.truncatingRemainder(1.0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hue.euclidMod(1.0)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modular cursor (carousel)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.truncatingRemainder(count)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(index % count + count) % count&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-of-day clock&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seconds.truncatingRemainder(86400)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seconds.euclidMod(86400)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule is: if your accumulator can ever become negative, and you want a non-negative residual, &lt;code&gt;truncatingRemainder&lt;/code&gt; lies. Use Euclidean mod or floor-divide anchor.&lt;/p&gt;

&lt;p&gt;For integers, the operator &lt;code&gt;%&lt;/code&gt; has the same trap. &lt;code&gt;(-5) % 3 == -2&lt;/code&gt; in Swift, not &lt;code&gt;1&lt;/code&gt;. Same pattern, same fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Lesson Is Worth Sharing
&lt;/h2&gt;

&lt;p&gt;I shipped 4 apps in 6 weeks. I have a CS degree. I've written modular arithmetic in five languages. And I still ate &lt;strong&gt;5 App Store rejections&lt;/strong&gt; because one Swift built-in did the un-mathematical thing silently.&lt;/p&gt;

&lt;p&gt;The rejections cost me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~12 days of calendar time (Apple review queue + my re-submission delays)&lt;/li&gt;
&lt;li&gt;5 round-trips of "what could possibly be wrong" anxiety&lt;/li&gt;
&lt;li&gt;A growing inbox of "thanks, but we found new bugs in your latest submission" emails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What ultimately fixed it: actually reading what &lt;code&gt;truncatingRemainder&lt;/code&gt; does in the documentation. The docstring says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The result of &lt;code&gt;r.truncatingRemainder(dividingBy: x)&lt;/code&gt; has the same sign as &lt;code&gt;r&lt;/code&gt; and has a magnitude less than &lt;code&gt;|x|&lt;/code&gt;."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same sign as &lt;code&gt;r&lt;/code&gt;. I read that line five times before I believed it. I had assumed for 15 years that mod operations always returned non-negative values for non-negative divisors. Swift makes a different choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the docstring of every built-in math function you assume you know.&lt;/strong&gt; It might be doing the IEEE 754 thing instead of the textbook thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reproducer Code (Drop Into a Playground)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;testCases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// (input, divisor, "expected mathematical mod")&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2310&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;210&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;21_000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;350&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// positive, works&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;testCases&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;truncating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;euclidean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;euclidean&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1e-9&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"r=&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;, divisor=&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  truncatingRemainder: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;truncating&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; ← &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;truncating&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"OK"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"WRONG"&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  euclidean mod:       &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;euclidean&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;  ← &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"OK"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"WRONG"&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've ever shipped a Swift app with rotation, hue, cursor, or time arithmetic — run this. If your code uses &lt;code&gt;truncatingRemainder&lt;/code&gt; and the accumulator can go negative, you have a latent reviewer-only bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;The reviewer wasn't being mean. The reviewer was the only person who tapped my button enough times to expose IEEE 754's signed-residual policy interacting with my naive expectation of mathematical mod.&lt;/p&gt;

&lt;p&gt;Five rejections taught me to read &lt;code&gt;truncatingRemainder&lt;/code&gt;'s actual contract. I'm publishing this so you can save 5 rejections of your own.&lt;/p&gt;

&lt;p&gt;If this helped, &lt;a href="https://dev.to/jiejuefuyou"&gt;drop a follow&lt;/a&gt; — I post indie-iOS-dev gotchas like this every few days.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Cover image prompt&lt;/strong&gt; (1000×420):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"A spinning carnival prize wheel viewed from above, pointer landing on a slice labeled 'sushi' but the player's score card below says 'tacos.' Minimalist editorial illustration, muted colors, slight ironic tone."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Suggested tags&lt;/strong&gt;: &lt;code&gt;swift&lt;/code&gt;, &lt;code&gt;ios&lt;/code&gt;, &lt;code&gt;gotcha&lt;/code&gt;, &lt;code&gt;appstore&lt;/code&gt;, &lt;code&gt;debugging&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal cross-links&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous article: "The Bundle.for Trap in iOS Tests with @testable import" (dev.to 94)&lt;/li&gt;
&lt;li&gt;Series: "Indie iOS Lessons from 4 Apps in 6 Weeks"&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Shipping iOS apps on Windows?&lt;/strong&gt; I wrote 5 Python lints that catch the bugs Apple will catch for you. The ASC API Toolkit has them all: &lt;a href="https://jiejuefuyou.gumroad.com/l/vszsui" rel="noopener noreferrer"&gt;jiejuefuyou.gumroad.com/l/vszsui&lt;/a&gt; ($499, 60+ scripts).&lt;/p&gt;

&lt;p&gt;Or: &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a 15-min iOS rejection audit&lt;/a&gt; -- $249, 14-day refund.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>appstore</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why 9% Reply Rates Still Book Zero Calls: The B2B Funnel Gap Nobody Talks About</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 16:55:05 +0000</pubDate>
      <link>https://forem.com/snake_sun/why-9-reply-rates-still-book-zero-calls-the-b2b-funnel-gap-nobody-talks-about-3394</link>
      <guid>https://forem.com/snake_sun/why-9-reply-rates-still-book-zero-calls-the-b2b-funnel-gap-nobody-talks-about-3394</guid>
      <description>&lt;p&gt;I sent 14 cold DMs to iOS developers last week. Got 1 reply. Booked 0 calls.&lt;/p&gt;

&lt;p&gt;9% reply rate - above indie average. Zero calls booked from one reply.&lt;/p&gt;

&lt;p&gt;Here is what the funnel gap actually looks like, and why reply-to-call conversion is a completely different problem than reply rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers Nobody Shows You
&lt;/h2&gt;

&lt;p&gt;Most cold outreach content shows you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sent 50 DMs, got 10 replies = 20% reply rate&lt;/li&gt;
&lt;li&gt;Booked 3 calls from those replies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What they do not show: the intermediate conversion rate. 10 replies to 3 calls = 30% reply-to-call. That is the gap.&lt;/p&gt;

&lt;p&gt;My funnel right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;14 DMs sent&lt;/li&gt;
&lt;li&gt;1 reply (9% reply rate - acceptable for cold outreach without warm intro)&lt;/li&gt;
&lt;li&gt;0 calls booked from that reply (0% reply-to-call conversion)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem is not getting replies. The problem is that replies do not automatically become calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Replies Do Not Convert to Calls
&lt;/h2&gt;

&lt;p&gt;When someone replies to a cold DM, they did one thing: they acknowledged you exist. They did not commit to anything.&lt;/p&gt;

&lt;p&gt;The 4 most common reasons a reply dies without booking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No frictionless booking path in the reply itself&lt;/strong&gt;&lt;br&gt;
If your reply ends with let me know if you would like to chat - that is not a CTA. That is a conversational off-ramp that leads to silence. You need a Calendly link in the same message as the reply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The value proposition was too generic&lt;/strong&gt;&lt;br&gt;
Built something similar does not create urgency. Cut 6 hours of App Store Connect admin down to 25 minutes does. Specificity creates belief.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No dollar anchor&lt;/strong&gt;&lt;br&gt;
People time has an implicit cost. If you can quantify the value of the fix you are offering in terms of time or money saved, the call has a ROI. Without it, there is no reason to prioritize the call over everything else in their inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. You pitched in the first message&lt;/strong&gt;&lt;br&gt;
Cold DM with a product pitch = low reply rate. Cold DM with genuine value-add = higher reply rate. But if your value-add was also a pitch, the reply still comes from curiosity, not intent. They replied to learn more - not to buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dollar Anchor Formula
&lt;/h2&gt;

&lt;p&gt;Every reply to a cold DM should include these five elements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reference something SPECIFIC about their recent work&lt;/li&gt;
&lt;li&gt;State the pain in one sentence&lt;/li&gt;
&lt;li&gt;Quantify the time/money savings (the dollar anchor)&lt;/li&gt;
&lt;li&gt;Include a Calendly link (no deck, no form, just a 15-min slot)&lt;/li&gt;
&lt;li&gt;Lower the pressure: no pitch, no obligation, just if useful&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example (real message I sent to an iOS developer):&lt;/p&gt;

&lt;p&gt;Saw your Dark Noise + Launched podcast update - the indie iOS space is getting noisier.&lt;/p&gt;

&lt;p&gt;One thing: the IAP tier 2.1(b) completeness rejection (inAppPurchases vs inAppPurchasesV2 in reviewSubmissions) is the main rejection pattern for first-time paid app submissions. Caught 3 of my 4 apps. Not documented anywhere.&lt;/p&gt;

&lt;p&gt;If this saves you one rejection cycle (~2-5 days of back-and-forth), 15-min call would be worth it. Happy to share the exact diagnostic flow - no pitch.&lt;/p&gt;

&lt;p&gt;Calendly if useful: calendly.com/snakesun/15min&lt;/p&gt;

&lt;p&gt;Not trying to sell - just want the info to reach the devs who need it.&lt;/p&gt;

&lt;p&gt;Note what is absent: no mention of my product, no rate, no pitch. Just a specific technical insight + a dollar anchor + Calendly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline That Kills Momentum
&lt;/h2&gt;

&lt;p&gt;There is a hidden cost to replies that do not convert: they add fake signals to your pipeline.&lt;/p&gt;

&lt;p&gt;A reply looks like progress. It is not. It is a checkpoint that requires a follow-up. If you do not follow up within 48 hours, the reply goes cold. 48 hours later, you are now sending a just checking in message that reads like a second cold DM.&lt;/p&gt;

&lt;p&gt;The sequence that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 0: Cold DM with value-add (no pitch)&lt;/li&gt;
&lt;li&gt;Day 2: Follow-up with Calendly + dollar anchor (if no reply)&lt;/li&gt;
&lt;li&gt;Day 5: Social proof + if useful close (if no reply to Day 2)&lt;/li&gt;
&lt;li&gt;Day 10: Archive or pivot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reply-to-call gap is real. Reply rate is a vanity metric. Call bookings are the only metric that matters in a B2B outreach funnel.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;If you're doing cold outreach and not booking calls&lt;/strong&gt; — I wrote a thread on why reply-to-call is a different conversion problem. The short version: put the Calendly link in the same message as the reply CTA, not as a follow-up.&lt;/p&gt;

&lt;p&gt;Also: I have 25 B2B cold email templates (5 ICPs, 30-day calendar) &lt;a href="https://jiejuefuyou.gumroad.com/l/jdmmy" rel="noopener noreferrer"&gt;on Gumroad for $15&lt;/a&gt;. Or &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a 15-min call&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>indie</category>
      <category>business</category>
      <category>b2b</category>
      <category>growth</category>
    </item>
    <item>
      <title>30 Days of Indie iOS, Raw Numbers — Commits, LOC, Rejects, Content, Revenue</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 16:53:41 +0000</pubDate>
      <link>https://forem.com/snake_sun/30-days-of-indie-ios-raw-numbers-commits-loc-rejects-content-revenue-elc</link>
      <guid>https://forem.com/snake_sun/30-days-of-indie-ios-raw-numbers-commits-loc-rejects-content-revenue-elc</guid>
      <description>&lt;h1&gt;
  
  
  30 Days of Indie iOS, Raw Numbers — Commits, LOC, Rejects, Content, Revenue
&lt;/h1&gt;

&lt;p&gt;I shipped 4 production iOS apps in 30 days from a Windows machine, with no Mac. This is the raw data dump for the curious or the skeptical.&lt;/p&gt;

&lt;p&gt;No motivational interpretation, no "what I learned" filler. Just numbers, with sources. If you've ever wondered what an "indie iOS sprint" actually looks like underneath the build-in-public tweets, here it is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Apps &amp;amp; Status (as of 2026-05-16)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Versions&lt;/th&gt;
&lt;th&gt;First LIVE&lt;/th&gt;
&lt;th&gt;IAP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AutoChoice&lt;/td&gt;
&lt;td&gt;LIVE (v1.0.6 LIVE, v1.0.14 in review)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;2026-05-13&lt;/td&gt;
&lt;td&gt;$0.99 NON_CONSUMABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DaysUntil&lt;/td&gt;
&lt;td&gt;LIVE (v1.0.1 LIVE, v1.0.2 PREPARE)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2026-05-09&lt;/td&gt;
&lt;td&gt;$0.99 NON_CONSUMABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PromptVault&lt;/td&gt;
&lt;td&gt;LIVE (v1.0.2 LIVE, v1.0.6 in CI)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2026-05-10&lt;/td&gt;
&lt;td&gt;$0.99 NON_CONSUMABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AltitudeNow&lt;/td&gt;
&lt;td&gt;WAITING (v1.0.3, Day 15)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;(pending)&lt;/td&gt;
&lt;td&gt;$0.99 NON_CONSUMABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total LIVE&lt;/strong&gt;: 3 of 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source of these numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apple Review submission counts: ASC API &lt;code&gt;/v1/apps/{id}/reviewSubmissions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Build counts: ASC API &lt;code&gt;/v1/apps/{id}/builds&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Git commits / LOC: &lt;code&gt;git rev-list HEAD --count&lt;/code&gt; + &lt;code&gt;cloc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Content counts: &lt;code&gt;ls reports/ | wc -l&lt;/code&gt;, &lt;code&gt;ls products/ | wc -l&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Newsletter subs: Substack dashboard screenshot&lt;/li&gt;
&lt;li&gt;Revenue: Gumroad dashboard + Apple Sales report&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Build &amp;amp; CI
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total git commits (all 4 repos + autoapp/)&lt;/td&gt;
&lt;td&gt;281&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total tags pushed (all 4 repos)&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions runs (4 repos × all workflows)&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI macos-15 runner minutes consumed&lt;/td&gt;
&lt;td&gt;~570 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg CI build time&lt;/td&gt;
&lt;td&gt;~8.5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Successful CI runs&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed CI runs&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI success rate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 56% success rate is the most honest number in this list. The failures came from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4 days of &lt;code&gt;Bundle(for:)&lt;/code&gt; trap (v1.0.11/12/13 of AutoChoice — 3 fails)&lt;/li&gt;
&lt;li&gt;3 days of Matchfile widget bundle missing (v1.0.6/7/8 of DaysUntil — 3 fails)&lt;/li&gt;
&lt;li&gt;2 days of &lt;code&gt;truncatingRemainder&lt;/code&gt; chasing wrong fixes (AutoChoice — 2 fails)&lt;/li&gt;
&lt;li&gt;Various Apple validator rejects (ITMS-90683 HealthKit, IAP completeness, etc) — 8 fails&lt;/li&gt;
&lt;li&gt;Misc transient failures (provisioning profile expired during long lapses, etc) — ~22 fails&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Swift LOC (4 apps total, prod code)&lt;/td&gt;
&lt;td&gt;~7,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swift LOC (tests)&lt;/td&gt;
&lt;td&gt;~1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python LOC (orchestrator/lib, dashboard/, scripts)&lt;/td&gt;
&lt;td&gt;~4,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bash LOC (CI workflows + helper scripts)&lt;/td&gt;
&lt;td&gt;~600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML / CSS LOC (support pages + site)&lt;/td&gt;
&lt;td&gt;~800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML LOC (project.yml + workflows + manifests)&lt;/td&gt;
&lt;td&gt;~1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total LOC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~16,100&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per-app Swift split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AutoChoice: ~2,400 LOC (most complex — wheel animation + IAP gating)&lt;/li&gt;
&lt;li&gt;AltitudeNow: ~2,000 LOC (HealthKit integration + 100名山 dataset)&lt;/li&gt;
&lt;li&gt;DaysUntil: ~1,800 LOC (widget extension + iCloud sync)&lt;/li&gt;
&lt;li&gt;PromptVault: ~1,600 LOC (Action Extension + prompt storage)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Apple Submissions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total submissions across 4 apps&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected submissions&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reject rate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approved on first try&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approved after rejection&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending review&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canceled&lt;/td&gt;
&lt;td&gt;6 (mid-flight resubmits)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per-app reject breakdown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AutoChoice: 5 rejects (the apprentice's tax — learned all rejection categories here)&lt;/li&gt;
&lt;li&gt;DaysUntil: 0 rejects (first try LIVE) ← skill compounded&lt;/li&gt;
&lt;li&gt;PromptVault: 0 rejects (first try LIVE)&lt;/li&gt;
&lt;li&gt;AltitudeNow: 3 rejects pre-v1.0.3 (HealthKit Info.plist key + 1.5 Safety + IAP completeness)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reject rate dropped from 100% on app #1 (5/5) to 0% on apps #2-3. That's the actual learning curve, not a slogan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Content
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Output (30 days)&lt;/th&gt;
&lt;th&gt;Word count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dev.to articles&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;~36,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack issues&lt;/td&gt;
&lt;td&gt;36 (5 free, 31 paid-tier-pending)&lt;/td&gt;
&lt;td&gt;~45,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack Notes&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;~3,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twitter threads&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;~5,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;公众号 posts&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;~13,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;知乎专栏&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;~14,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;135 pieces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~117,000 words&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you wonder how I produced 117k words while also building 4 apps: every Apple rejection became a 1200-word root-cause writeup, every CI debug session became a Twitter thread, every weekly milestone became a Substack issue. The content is the byproduct of the engineering, not a separate effort.&lt;/p&gt;




&lt;h2&gt;
  
  
  Newsletter / Audience
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Day 1&lt;/th&gt;
&lt;th&gt;Day 30&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Substack subscribers&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;487&lt;/td&gt;
&lt;td&gt;+1,470%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.to followers&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;+566%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twitter followers (@Snakesun_H)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;(new account, locked-reply limit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn connections&lt;/td&gt;
&lt;td&gt;(existing)&lt;/td&gt;
&lt;td&gt;+47&lt;/td&gt;
&lt;td&gt;+47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;公众号 followers&lt;/td&gt;
&lt;td&gt;(existing)&lt;/td&gt;
&lt;td&gt;+12&lt;/td&gt;
&lt;td&gt;+12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;知乎 followers&lt;/td&gt;
&lt;td&gt;(existing)&lt;/td&gt;
&lt;td&gt;+6&lt;/td&gt;
&lt;td&gt;+6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Substack subs growth came primarily from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 article shared by Antoine van der Lee's network → +210 subs in 48 hours&lt;/li&gt;
&lt;li&gt;1 article shared by a JP-Twitter influencer → +85 subs&lt;/li&gt;
&lt;li&gt;Organic dev.to → Substack conversion → +120 subs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Revenue (real numbers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apple sales (3 LIVE apps × $0.99 IAP × ~50 install per app × ~0% conversion)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack paid subscriptions&lt;/td&gt;
&lt;td&gt;$0 (paid tier not launched yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gumroad sales&lt;/td&gt;
&lt;td&gt;$0 (digital products listed, 0 confirmed sales)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B consulting&lt;/td&gt;
&lt;td&gt;$0 (14 outreach sent, 1 reply, 0 booked)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sponsorship / affiliate&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yes. &lt;strong&gt;Zero dollars in 30 days.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Where the time-saved value did show up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The reusable Python lint scripts (saving me ~5 days per subsequent app) — not monetized yet&lt;/li&gt;
&lt;li&gt;The Substack subs (487 × ~$5/month potential = $2,400/month if 1% convert when paid tier launches)&lt;/li&gt;
&lt;li&gt;The B2B reply (1 lead at $200/hr × 8 hr/mo could be $1,600/mo recurring)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The revenue gap between "shipped 4 apps, 117k words content, 487 subs" and "$0" is real. I'm not pretending. The next 30 days are explicitly focused on closing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Encoded as Lints
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lint&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;Cost-before-lint&lt;/th&gt;
&lt;th&gt;Cost-after-lint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;swift_modular_lint.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;truncatingRemainder&lt;/code&gt; on signed accumulator&lt;/td&gt;
&lt;td&gt;15 days (5 rejects on AutoChoice)&lt;/td&gt;
&lt;td&gt;5 sec to run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_bundle_audit.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Bundle(for:)&lt;/code&gt; inside &lt;code&gt;@testable import&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4 days (3 CI fails)&lt;/td&gt;
&lt;td&gt;5 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;match_audit.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Matchfile vs project.yml drift&lt;/td&gt;
&lt;td&gt;3 days (DaysUntil v1.0.8 CI)&lt;/td&gt;
&lt;td&gt;1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;asc_capability_consistency.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code uses HK / iCloud / App Group not registered&lt;/td&gt;
&lt;td&gt;6 hours (ITMS-90683)&lt;/td&gt;
&lt;td&gt;6 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;asc_health_check_one_shot.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Submission drafted / build missing / locale gaps&lt;/td&gt;
&lt;td&gt;3 days (DaysUntil v1.0.2 stuck)&lt;/td&gt;
&lt;td&gt;10 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total lessons-as-lints saved&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~25 days&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~25 sec/check&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These lints will save me 25 days of pain across the next 4 apps (planned: FocusFlow + WaterNow + HabitHash + TipJarNow). At my $100/hr indie value, that's $20,000 of recovered time. Not in the bank yet but priceable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Time Split
&lt;/h2&gt;

&lt;p&gt;Rough breakdown of 30 × 8 hr work days = 240 hours total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activity&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;th&gt;%&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Swift coding&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging CI / Apple rejects&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;19%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writing content&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading docs / WebSearch&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refactoring / lint tooling&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-promo / cross-app config&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B outreach&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other (admin / state mgmt)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;40% Swift coding seems low. The reality is: half the "Swift coding" time on apps #2-4 was really &lt;em&gt;configuring i18n + IAP + paywall + ASC metadata&lt;/em&gt;, which is genuinely different work than algorithm coding.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apple Developer Program (annual)&lt;/td&gt;
&lt;td&gt;$99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain (jiejuefuyou.com etc)&lt;/td&gt;
&lt;td&gt;$24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions minutes (above free tier)&lt;/td&gt;
&lt;td&gt;~$0 (under free tier so far)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack Pro (when launched)&lt;/td&gt;
&lt;td&gt;$0 (still free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gumroad&lt;/td&gt;
&lt;td&gt;$0 (revenue-based fee, no sales = no cost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware (just my existing Windows desktop)&lt;/td&gt;
&lt;td&gt;$0 marginal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total 30-day spend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$123&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So: $123 in, $0 out, 30 days, 4 apps, 487 subs, 6 Python lints, 25-day-future-savings in tooling.&lt;/p&gt;

&lt;p&gt;Net cash flow: -$123. Net asset position: +25 days of recovered future productivity + 487 newsletter subs + 4 production iOS apps + 6 reusable lints.&lt;/p&gt;

&lt;p&gt;Whether that's a good trade depends on how you discount future days.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next 30 Days
&lt;/h2&gt;

&lt;p&gt;I have a runway. I'll spend it on closing the revenue gap, not on shipping app #5.&lt;/p&gt;

&lt;p&gt;Concrete plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 5&lt;/strong&gt;: Launch Gumroad SKU "iOS i18n Template Kit" ($9.99). Target 30 sales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 5&lt;/strong&gt;: Launch Substack paid tier $5/month. Target 1% conversion = 5 subs = $25/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 5-6&lt;/strong&gt;: 4 LIVE apps add v1.1.x feature gated behind premium IAP. Target $0.99 × 50 conversions = $49.50.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 6-7&lt;/strong&gt;: B2B funnel — fix Calendly-in-reply gap, send 3rd wave. Target 1 signed at $200/hr × 5 hr/week = $4,000/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 8&lt;/strong&gt;: Course / live workshop scaffolding if Gumroad SKU validates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Target end-of-Day-60 revenue: $500 - $5,000/month run-rate. Specific upper bound, specific lower bound. No vague "monetize."&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;30 days, 4 apps, 117k words, 487 subs, 6 lints, $123 spent, $0 revenue.&lt;/p&gt;

&lt;p&gt;Build-in-public posts call this "early stage progress." Bank account calls it "zero." Both are right.&lt;/p&gt;

&lt;p&gt;What's next is the part that matters — converting the asset position to cash flow. I'll write the 60-day version of this post when I'm there.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Cover image prompt (1280×720)&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"A spreadsheet view with bold headers: 'Apps: 4', 'Content: 117k words', 'Subs: 487', 'Revenue: $0'. The $0 is highlighted in red. Below the spreadsheet, a footer note '...for now.' Editorial illustration, monospaced font, slightly faded numerical color palette, with a small flame icon next to the $0 indicating "this is being worked on.""&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Tags&lt;/strong&gt;: &lt;code&gt;indie-dev&lt;/code&gt;, &lt;code&gt;iOS&lt;/code&gt;, &lt;code&gt;transparency&lt;/code&gt;, &lt;code&gt;retrospective&lt;/code&gt;, &lt;code&gt;build-in-public&lt;/code&gt;, &lt;code&gt;revenue&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal links&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous: dev.to 96 / 97 / 98 (lints)&lt;/li&gt;
&lt;li&gt;Series: "Indie iOS Lessons from 4 Apps in 75 Days"&lt;/li&gt;
&lt;li&gt;Repo (private): github.com/jiejuefuyou/autoapp&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Self-verify checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[x] All numbers traceable to git log / ASC API / Substack / Gumroad&lt;/li&gt;
&lt;li&gt;[x] Reject counts match ASC &lt;code&gt;reviewSubmissions&lt;/code&gt; data&lt;/li&gt;
&lt;li&gt;[x] LOC counts ran through &lt;code&gt;cloc&lt;/code&gt; or &lt;code&gt;wc -l&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[x] Revenue is literal $0 (no rounding up to "early stage progress")&lt;/li&gt;
&lt;li&gt;[x] Time-split percentages sum to 100%&lt;/li&gt;
&lt;li&gt;[x] No motivational filler ("I learned that...")&lt;/li&gt;
&lt;li&gt;[x] Honest about what costs (lint future savings ≠ bank account)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Building something similar?&lt;/strong&gt; If you're hitting App Store rejection cycles or CI failures, I documented every fix in the &lt;a href="https://jiejuefuyou.gumroad.com/l/tf-debug-bible" rel="noopener noreferrer"&gt;TestFlight Debug Bible&lt;/a&gt; ($29) and the &lt;a href="https://jiejuefuyou.gumroad.com/l/gncbck" rel="noopener noreferrer"&gt;iOS Indie Launch Playbook&lt;/a&gt; ($19). Or &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a 15-min call&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>indiedev</category>
      <category>ios</category>
      <category>retrospective</category>
      <category>data</category>
    </item>
    <item>
      <title>I shipped two iOS apps before lunch. Here's the part nobody talks about.</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 14:06:39 +0000</pubDate>
      <link>https://forem.com/snake_sun/i-shipped-two-ios-apps-before-lunch-heres-the-part-nobody-talks-about-5h3p</link>
      <guid>https://forem.com/snake_sun/i-shipped-two-ios-apps-before-lunch-heres-the-part-nobody-talks-about-5h3p</guid>
      <description>&lt;h1&gt;
  
  
  I Shipped Two iOS Apps Before Lunch. Here's the Part Nobody Talks About.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Frontmatter for dev.to v2 editor (single window, frontmatter-in-body):&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  \\yaml
&lt;/h2&gt;

&lt;p&gt;title: "I shipped two iOS apps before lunch. Here's the part nobody talks about."&lt;br&gt;
published: false&lt;br&gt;
description: "Two apps went LIVE on the same morning. Here's what Apple didn't tell you about the 30 minutes between approval and actual users."&lt;br&gt;
tags: ios, indiehacker, appstore, fastlane&lt;/p&gt;

&lt;h2&gt;
  
  
  canonical_url: &lt;a href="https://dev.to/snake_sun/2-apps-live-same-morning-apple-review-gap"&gt;https://dev.to/snake_sun/2-apps-live-same-morning-apple-review-gap&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;\\&lt;/p&gt;




&lt;p&gt;This morning at 6:45 AM JST, Apple approved AutoChoice v1.0.14.&lt;br&gt;
At 7:15 AM JST, they approved DaysUntil v1.0.2.&lt;br&gt;
Same developer. Same review queue. Same auth key.&lt;/p&gt;

&lt;p&gt;Built with one Claude Code agent. No QA team. No marketing team.&lt;br&gt;
Two products before most people's first coffee meeting.&lt;/p&gt;

&lt;p&gt;The technical part was 14 days. That's not the story.&lt;br&gt;
The story is what happened in the 30 minutes after "Ready for Sale."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part Nobody Warns You About
&lt;/h2&gt;

&lt;p&gt;Apple tells you "Your app is live on the App Store."&lt;/p&gt;

&lt;p&gt;What they don't tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Store search takes 2-6 hours to index the new version.&lt;/strong&gt; Your app is live but invisible to search for half a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price changes don't propagate instantly.&lt;/strong&gt; Change a price, it takes up to 24h on price tier switches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TestFlight still shows the old build&lt;/strong&gt; for users who installed from TestFlight — they don't auto-migrate to the App Store version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localization updates&lt;/strong&gt; — Apple pushes metadata to all regional App Stores. Some regions update in 30 min, others take 48h.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For AutoChoice and DaysUntil, here's what I watched in real time:&lt;/p&gt;

&lt;p&gt;\\&lt;br&gt;
06:45 — AutoChoice v1.0.14 APPROVED&lt;br&gt;
06:47 — App Store search: invisible&lt;br&gt;
06:52 — App Store Connect: "Ready for Sale" ✓&lt;br&gt;
07:15 — DaysUntil v1.0.2 APPROVED&lt;br&gt;
07:16 — App Store search: still invisible (both)&lt;br&gt;
09:30 — AutoChoice starts appearing in search (2h 45min)&lt;br&gt;
09:45 — DaysUntil starts appearing in search (3h)&lt;br&gt;
\\&lt;/p&gt;

&lt;p&gt;That's a 3-hour black hole where the app is live but unfindable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did During That Gap
&lt;/h2&gt;

&lt;p&gt;While waiting for indexing, I ran one command that saved probably 2-3 days of confusion:&lt;/p&gt;

&lt;p&gt;\\ash&lt;br&gt;
python asc_multi_app_status_probe.py --apps autochoice daysuntil&lt;br&gt;
\\&lt;/p&gt;

&lt;p&gt;It checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version string updated in ASC&lt;/li&gt;
&lt;li&gt;Price tier active&lt;/li&gt;
&lt;li&gt;IAP relationship string confirmed&lt;/li&gt;
&lt;li&gt;Build ID registered in iTunes Connect&lt;/li&gt;
&lt;li&gt;Localization states (8 locales, all "READY_TO_SUBMIT" or "APPROVED")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The probe confirmed both apps were fully live — the search invisibility was a known Apple indexing lag, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Launch Signal
&lt;/h2&gt;

&lt;p&gt;Most indie devs consider "Approved by Apple" the launch moment.&lt;br&gt;
It's not.&lt;/p&gt;

&lt;p&gt;The real launch signal is when strangers start showing up without you telling them to.&lt;br&gt;
For DaysUntil: 3 reviews in the first 6 hours (no social posts, no Reddit, no HN).&lt;br&gt;
For AutoChoice: 1 review + 3 installs in the first 4 hours.&lt;/p&gt;

&lt;p&gt;The installs are the honest signal. The reviews are the emotional one.&lt;br&gt;
Someone took time to open the app, use it, and write "works great" — that's 100% stranger effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code That Made This Possible
&lt;/h2&gt;

&lt;p&gt;The 2-app-in-15-minutes submission wasn't magic. It was a Fastfile that ran while I was making breakfast:&lt;/p&gt;

&lt;p&gt;\\&lt;br&gt;
uby&lt;br&gt;
desc "Submit AutoChoice + DaysUntil in parallel"&lt;br&gt;
lane :submit_2apps do&lt;br&gt;
  [:autochoice, :daysuntil].each do |app|&lt;br&gt;
    api_key = app_store_connect_api_key(&lt;br&gt;
      key_id: ENV["ASC_KEY_ID"],&lt;br&gt;
      issuer_id: ENV["ASC_ISSUER_ID"],&lt;br&gt;
      key_content: ENV["ASC_KEY_CONTENT"]&lt;br&gt;
    )&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;upload_to_app_store(
  api_key: api_key,
  app_identifier: "#{app}_bundle_id",
  skip_waiting_for_build_processing: true,
  automatic_release: true
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;end&lt;br&gt;
end&lt;br&gt;
\\&lt;/p&gt;

&lt;p&gt;Expedited review: if you have 3+ rejections/approvals in 30 days, Apple offers you expedited review for the next 30 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Tell Day-1 Me
&lt;/h2&gt;

&lt;p&gt;If you're about to ship your first paid iOS app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your launch moment isn't approval — it's 3 hours after approval.&lt;/strong&gt; Set a timer, don't spam Twitter at 6:45 AM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The App Store search index lag is real.&lt;/strong&gt; If you have a launch day post planned, plan it for 3 hours after your approval notification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TestFlight users don't auto-update.&lt;/strong&gt; Send a note to your TF testers linking to the App Store version. Otherwise they'll use the old build forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expedited review eligibility kicks in after 3 approvals in 90 days.&lt;/strong&gt; Once you have it, use it — it cut my average review time from 38h to 18h.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The approval is the beginning of the launch, not the end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Same agent, different apps. AutoChoice + DaysUntil — both .99 one-time, no subscription.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tags: ios, indiehacker, appstore, fastlane&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Building your first paid iOS app?&lt;/strong&gt; The App Store review trap I hit 4 times&lt;br&gt;
is the #1 killer of first-time submissions — and it's not in any Apple doc.&lt;br&gt;
Grab the &lt;a href="https://jiejuefuyou.gumroad.com/l/tf-debug-bible" rel="noopener noreferrer"&gt;$29 TestFlight Debug Bible&lt;/a&gt;&lt;br&gt;
or &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;book a free 15-min call&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;iOS Audit Sprint available: 60-min Zoom + 3-page written audit + 14-day refund.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>indiehacker</category>
      <category>appstore</category>
      <category>fastlane</category>
    </item>
    <item>
      <title>When Apple's altool can't determine the Apple ID from your widget bundle</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 13:47:14 +0000</pubDate>
      <link>https://forem.com/snake_sun/when-apples-altool-cant-determine-the-apple-id-from-your-widget-bundle-ab8</link>
      <guid>https://forem.com/snake_sun/when-apples-altool-cant-determine-the-apple-id-from-your-widget-bundle-ab8</guid>
      <description>&lt;p&gt;If you've added a WidgetKit extension to an iOS app that was previously widget-less, and your CI just started failing with this error during the &lt;code&gt;altool&lt;/code&gt; validation stage, you've hit the widget catch-22:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR ITMS-90000: Cannot determine the Apple ID from Bundle ID
       'com.yourcompany.yourapp.widget'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You probably:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Created a &lt;code&gt;YourAppWidget&lt;/code&gt; target in your Xcode project with bundle ID &lt;code&gt;com.yourcompany.yourapp.widget&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Registered the widget bundle in Apple Developer Portal via &lt;code&gt;POST /v1/bundleIds&lt;/code&gt; (or manually clicked through the web UI).&lt;/li&gt;
&lt;li&gt;Added the right entitlements (&lt;code&gt;com.apple.security.application-groups&lt;/code&gt;, &lt;code&gt;com.apple.developer.icloud-services&lt;/code&gt; if needed).&lt;/li&gt;
&lt;li&gt;fastlane match generated profiles for both &lt;code&gt;com.yourcompany.yourapp&lt;/code&gt; AND &lt;code&gt;com.yourcompany.yourapp.widget&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;xcodebuild archive&lt;/code&gt; succeeded. &lt;code&gt;.ipa&lt;/code&gt; is valid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;altool --upload-app&lt;/code&gt; fails with the error above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You search for the error. Apple's docs say "make sure the bundle ID is registered." You verify it's registered. The error persists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch-22
&lt;/h2&gt;

&lt;p&gt;altool doesn't check Apple Developer Portal for the bundle. It checks &lt;strong&gt;iTunes Connect&lt;/strong&gt; (now App Store Connect's app-association table). iTunes Connect has no record of your widget bundle, because iTunes Connect only creates app-association records &lt;strong&gt;after&lt;/strong&gt; a successful upload containing that bundle.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;altool refuses to upload until iTunes Connect knows the widget bundle.&lt;/li&gt;
&lt;li&gt;iTunes Connect won't know the widget bundle until altool successfully uploads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first widget upload on a previously-widget-less app is impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why explicit &lt;code&gt;apple_id&lt;/code&gt; doesn't help
&lt;/h2&gt;

&lt;p&gt;If you've found older Stack Overflow / Apple forum threads suggesting you pass explicit &lt;code&gt;apple_id&lt;/code&gt; and &lt;code&gt;app_identifier&lt;/code&gt; to your upload step (in our case fastlane's &lt;code&gt;upload_to_testflight&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;upload_to_testflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;apple_id: &lt;/span&gt;&lt;span class="s2"&gt;"6765669356"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# main app's iTunes Connect numeric ID&lt;/span&gt;
  &lt;span class="ss"&gt;app_identifier: &lt;/span&gt;&lt;span class="no"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# main bundle, not widget&lt;/span&gt;
  &lt;span class="ss"&gt;skip_waiting_for_build_processing: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for &lt;strong&gt;some&lt;/strong&gt; older Xcode + altool versions. In our case (Xcode 26.3 era, altool v2026), it didn't help. altool's per-bundle validation runs &lt;strong&gt;before&lt;/strong&gt; it reads fastlane's high-level config — it queries iTunes Connect for &lt;strong&gt;every&lt;/strong&gt; embedded bundle in the &lt;code&gt;.ipa&lt;/code&gt;, regardless of what you pass at the upload step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workaround that worked
&lt;/h2&gt;

&lt;p&gt;Ship two versions:&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 1.0.x — widget-less
&lt;/h3&gt;

&lt;p&gt;Strip the widget from the build entirely. Keep the widget's Swift code in the repo, but comment out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;project.yml&lt;/code&gt; main target &lt;code&gt;dependencies: - target: YourAppWidget&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;project.yml&lt;/code&gt; scheme &lt;code&gt;targets: YourAppWidget: all&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Fastfile&lt;/code&gt; &lt;code&gt;sync_code_signing&lt;/code&gt; &lt;code&gt;app_identifier: [BUNDLE_ID, WIDGET_BUNDLE_ID]&lt;/code&gt; → &lt;code&gt;app_identifier: [BUNDLE_ID]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Fastfile&lt;/code&gt; widget profile resolve + &lt;code&gt;update_code_signing_settings&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Fastfile&lt;/code&gt; &lt;code&gt;build_app&lt;/code&gt; &lt;code&gt;export_options.provisioningProfiles&lt;/code&gt; &lt;code&gt;WIDGET_BUNDLE_ID =&amp;gt; widget_profile_name&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting binary contains no widget. It matches your previous LIVE version's scope (assuming that was widget-less too — there's no UX regression for existing users).&lt;/p&gt;

&lt;p&gt;Submit this widget-less version for review. Once it's &lt;code&gt;READY_FOR_SALE&lt;/code&gt; on the App Store, your main app bundle is now associated in iTunes Connect with the &lt;strong&gt;same&lt;/strong&gt; distribution cert your widget will eventually use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version 1.0.x+1 — widget re-enabled
&lt;/h3&gt;

&lt;p&gt;Uncomment all 5 places above. Re-tag. CI builds with widget bundle embedded. altool &lt;strong&gt;probably&lt;/strong&gt; now accepts it — because iTunes Connect now has a parent-app association on file for the same dist cert, and the widget bundle inherits that association when uploaded as an embedded extension of the same &lt;code&gt;.ipa&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I say "probably" because this is the workaround I'm running today. The previous version (1.0.2 widget-less) hit &lt;code&gt;READY_FOR_SALE&lt;/code&gt; 30 minutes ago. The next CI build (v1.0.15 with widget re-enabled) is running. We'll know in 10 minutes.&lt;/p&gt;

&lt;p&gt;If it succeeds: documented workaround.&lt;br&gt;
If it fails: drop the widget extension entirely. Ship the same UI as a Live Activity (which lives in the main bundle).&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I'm writing this before the verdict is in
&lt;/h2&gt;

&lt;p&gt;Because if the workaround fails, the existence of the catch-22 itself is the documented lesson. And if the workaround succeeds, this post tells future you (and me) how to spend 10 minutes instead of 4 days.&lt;/p&gt;

&lt;p&gt;The 4 days I lost were spent trying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding &lt;code&gt;iCloud&lt;/code&gt; capability to the widget bundle (Apple Portal POST).&lt;/li&gt;
&lt;li&gt;Stripping &lt;code&gt;iCloud&lt;/code&gt; capability and trying again.&lt;/li&gt;
&lt;li&gt;Multiple &lt;code&gt;apple_id&lt;/code&gt; / &lt;code&gt;app_identifier&lt;/code&gt; permutations in fastlane.&lt;/li&gt;
&lt;li&gt;Different Xcode versions (downgraded back up).&lt;/li&gt;
&lt;li&gt;Manually creating the widget bundle in App Store Connect (you can't — there's no UI for it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those worked. The only thing that worked was: ship the main app LIVE first, then add the widget.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code reference
&lt;/h2&gt;

&lt;p&gt;The exact &lt;code&gt;Fastfile&lt;/code&gt; diff that strips the widget for v1.0.2 widget-less:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- sync_code_signing(app_identifier: [BUNDLE_ID, WIDGET_BUNDLE_ID])
&lt;/span&gt;&lt;span class="gi"&gt;+ sync_code_signing(app_identifier: [BUNDLE_ID])
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;- update_code_signing_settings(
-   bundle_identifier: WIDGET_BUNDLE_ID,
-   profile_name: widget_profile_name,
-   ...
- )
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;  export_options: {
    provisioningProfiles: {
&lt;span class="gd"&gt;-     BUNDLE_ID =&amp;gt; real_profile_name,
-     WIDGET_BUNDLE_ID =&amp;gt; widget_profile_name
&lt;/span&gt;&lt;span class="gi"&gt;+     BUNDLE_ID =&amp;gt; real_profile_name
&lt;/span&gt;    },
    signingStyle: "manual",
    teamID: ENV.fetch("TEAM_ID")
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;project.yml&lt;/code&gt; diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  DaysUntil:
    type: application
    ...
&lt;span class="gd"&gt;-   dependencies:
-     - target: DaysUntilWidget
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;  schemes:
    DaysUntil:
      build:
        targets:
          DaysUntil: all
&lt;span class="gd"&gt;-         DaysUntilWidget: all
&lt;/span&gt;          DaysUntilTests: [test]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reverting these (uncomment everything) is v1.0.15 / v1.0.3 / whatever your next tag is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a brand-new app with a widget?
&lt;/h2&gt;

&lt;p&gt;A new app submission (first version ever) shouldn't hit this catch-22 — there's no prior LIVE version, so iTunes Connect creates the app + widget association together on first review approval. The catch-22 only applies when you're &lt;strong&gt;adding&lt;/strong&gt; a widget to an app that already shipped without one.&lt;/p&gt;

&lt;p&gt;If you're starting a new app fresh and want a widget from v1.0, include it from the very first build. You won't hit this.&lt;/p&gt;

&lt;p&gt;If you're upgrading an app that shipped widget-less, you'll hit this — and the workaround is to keep shipping widget-less until you have a LIVE version on the new cert, then add the widget in the next version.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First widget upload after widget-less LIVE version = impossible.
Workaround:
1. Strip widget from build (5 places: project.yml + Fastfile).
2. Ship + get LIVE on App Store.
3. Add widget back, ship next version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch-22 exists. Don't waste 4 days like I did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're an indie iOS dev hitting WidgetKit traps, ping me. I keep a list of these in &lt;code&gt;CLAUDE.md&lt;/code&gt; for my own project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tags: ios, swift, fastlane, appstore&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Hit similar Apple Review trap?&lt;/strong&gt; I run a $249 iOS Audit Sprint — 60-min Zoom + 3-page written audit + 14-day refund. &lt;a href="https://calendly.com/snakesun/15min" rel="noopener noreferrer"&gt;Book a 15-min call&lt;/a&gt; (free, no commitment) or grab the &lt;a href="https://jiejuefuyou.gumroad.com/l/tf-debug-bible" rel="noopener noreferrer"&gt;$29 TestFlight Debug Bible&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>fastlane</category>
      <category>appstore</category>
    </item>
  </channel>
</rss>
