<?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>DEV Community: 孫昊</title>
    <description>The latest articles on DEV Community by 孫昊 (@snake_sun).</description>
    <link>https://dev.to/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>DEV Community: 孫昊</title>
      <link>https://dev.to/snake_sun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/snake_sun"/>
    <language>en</language>
    <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://dev.to/snake_sun/why-9-reply-rates-still-book-zero-calls-the-b2b-funnel-gap-nobody-talks-about-3394</link>
      <guid>https://dev.to/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://dev.to/snake_sun/30-days-of-indie-ios-raw-numbers-commits-loc-rejects-content-revenue-elc</link>
      <guid>https://dev.to/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://dev.to/snake_sun/i-shipped-two-ios-apps-before-lunch-heres-the-part-nobody-talks-about-5h3p</link>
      <guid>https://dev.to/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://dev.to/snake_sun/when-apples-altool-cant-determine-the-apple-id-from-your-widget-bundle-ab8</link>
      <guid>https://dev.to/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>
    <item>
      <title>Day 61: I shipped a Twitter thread to my 60-day milestone account. Here's what n</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 00:55:46 +0000</pubDate>
      <link>https://dev.to/snake_sun/day-61-i-shipped-a-twitter-thread-to-my-60-day-milestone-account-heres-what-n-432d</link>
      <guid>https://dev.to/snake_sun/day-61-i-shipped-a-twitter-thread-to-my-60-day-milestone-account-heres-what-n-432d</guid>
      <description>&lt;h1&gt;
  
  
  Day 61: I shipped a Twitter thread to my 60-day milestone account. Here's what nobody tells you about indie launch threads.
&lt;/h1&gt;

&lt;p&gt;Yesterday I crossed Day 60. 4 iOS apps in Apple's review queue. 6 Gumroad SKUs LIVE. 84 dev.to articles. $0 revenue.&lt;/p&gt;

&lt;p&gt;Today I shipped my first 8-tweet thread on the milestone. Here's what I learned writing it that the typical "build in public" advice glosses over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tweet count is not the metric
&lt;/h2&gt;

&lt;p&gt;Default advice: "make threads 8-12 tweets, that's the engagement sweet spot."&lt;/p&gt;

&lt;p&gt;Real lesson: &lt;strong&gt;content density per tweet&lt;/strong&gt; matters 10x more than count. 4 tweets with surgical numbers will outperform 12 tweets with vibes.&lt;/p&gt;

&lt;p&gt;My thread had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tweet 1: hook with one specific number ($0 revenue, 4 apps in queue)&lt;/li&gt;
&lt;li&gt;Tweet 2-4: what I shipped (6 Gumroad / 84 articles / 4 apps — concrete)&lt;/li&gt;
&lt;li&gt;Tweet 5: the failure (zero sales)&lt;/li&gt;
&lt;li&gt;Tweet 6: the real reason (no traffic, not bad product)&lt;/li&gt;
&lt;li&gt;Tweet 7: what I'm doing about it&lt;/li&gt;
&lt;li&gt;Tweet 8: CTA to follow + Day 90 plan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;8 tweets, but each loadbearing. No tweet was "transition" filler.&lt;/p&gt;

&lt;h2&gt;
  
  
  CDP automation has 4 hidden gotchas
&lt;/h2&gt;

&lt;p&gt;I shipped via Playwright CDP attached to my port-9222 Chrome. Hit these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Composer DOM duplication.&lt;/strong&gt; X has both inline composer AND modal composer rendered simultaneously. Same &lt;code&gt;data-testid="tweetTextarea_0"&lt;/code&gt;. You MUST scope to &lt;code&gt;div[role="dialog"]&lt;/code&gt; or you'll write to the invisible one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;element.click()&lt;/code&gt; is anti-bot detected.&lt;/strong&gt; Even with &lt;code&gt;page.locator().click()&lt;/code&gt;, X anti-bot sometimes silently rolls back the action. The "Add tweet" button visually depresses but state reverts. Fix: use &lt;code&gt;page.locator(selector).click()&lt;/code&gt; with Playwright's real-mouse-event mode, NOT &lt;code&gt;page.evaluate("el.click()")&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Add tweet" button only enables AFTER current editor has content.&lt;/strong&gt; You can't pre-set up the thread structure. Must type → wait → check button enabled → click → type next.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Discard" button data-testid is &lt;code&gt;confirmationSheetCancel&lt;/code&gt;&lt;/strong&gt; (NOT &lt;code&gt;confirmationSheetConfirm&lt;/code&gt;). Reverse from intuition. Misclick this and you nuke the entire draft.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are 5-hour debugging sessions buried in a "10-minute" task. The X composer is brittle in a way that doesn't show in static HTML inspection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threads are not the funnel — they're the radar
&lt;/h2&gt;

&lt;p&gt;I went in thinking "8 tweets = 1 conversion event."&lt;/p&gt;

&lt;p&gt;Real model: each tweet is a radar ping. People who reply / quote / DM are the leads. The thread itself doesn't sell, the &lt;strong&gt;profile click rate from impressions&lt;/strong&gt; is the real metric.&lt;/p&gt;

&lt;p&gt;Targets I'm tracking 7 days from launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;impressions: &amp;gt; 10k (baseline for a 600-follower account)&lt;/li&gt;
&lt;li&gt;profile clicks: &amp;gt; 100&lt;/li&gt;
&lt;li&gt;Gumroad referrals from Twitter: &amp;gt; 20&lt;/li&gt;
&lt;li&gt;new follows: &amp;gt; 50&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If profile clicks &amp;gt; 100, the thread did its job regardless of "did anyone buy?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schedule for 9am ET, not midnight JST.&lt;/strong&gt; I posted at midnight JST (11am ET) but indie / building Twitter is 9-11am ET prime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin tweet 1 immediately.&lt;/strong&gt; Pinning is free distribution for 7 days. Default "post and pray" misses this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote-RT my own tweet 5 in 24 hours&lt;/strong&gt; with a follow-up data point. X algorithm boosts self-quotes if there's NEW info.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag 3 people in tweet 8 CTA&lt;/strong&gt; — not for friends-feel, but to trigger their notification → reply → algorithmic boost.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The thread is here
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://x.com/Snakesun_H/status/2052380908423163968" rel="noopener noreferrer"&gt;https://x.com/Snakesun_H/status/2052380908423163968&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're shipping your own milestone thread soon, the 4 CDP gotchas above will save you 5 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Day 67 (next week) I'm posting the first-week data: real impressions, real conversions, real Gumroad referrals. No vibes, just numbers.&lt;/p&gt;

&lt;p&gt;If you want the actual Python script that ships threads via CDP without the 4 gotchas, it's in the autoapp repo: &lt;code&gt;orchestrator/scripts/x_thread_publish_v4.py&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tools I used to ship this in 60 days:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://jiejuefuyou.gumroad.com/l/vszsui" rel="noopener noreferrer"&gt;ASC API Toolkit&lt;/a&gt;&lt;/strong&gt; ($499) — 10 ASC scripts I actually run daily for managing 4 apps without web UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://jiejuefuyou.gumroad.com/l/tf-debug-bible" rel="noopener noreferrer"&gt;TF Debug Bible&lt;/a&gt;&lt;/strong&gt; ($29) — 62-page playbook for the TestFlight 4-year cache bug&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt;: &lt;a href="https://jiejuefuyou.gumroad.com/l/sphytu" rel="noopener noreferrer"&gt;14 iOS Rejection Reasons PDF&lt;/a&gt; — pattern-match your rejection in 90 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Following the Day 60 → Day 90 journey: &lt;a href="https://dev.to/snake_sun"&gt;github.com/snake-sun&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>indie</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 00:55:25 +0000</pubDate>
      <link>https://dev.to/snake_sun/30-days-of-indie-ios-raw-numbers-commits-loc-rejects-content-revenue-5e8l</link>
      <guid>https://dev.to/snake_sun/30-days-of-indie-ios-raw-numbers-commits-loc-rejects-content-revenue-5e8l</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;

</description>
      <category>ios</category>
      <category>indie</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>Tue, 19 May 2026 00:54:58 +0000</pubDate>
      <link>https://dev.to/snake_sun/5-python-lints-i-wrote-after-shipping-4-ios-apps-in-75-days-and-the-bugs-they-caught-16kk</link>
      <guid>https://dev.to/snake_sun/5-python-lints-i-wrote-after-shipping-4-ios-apps-in-75-days-and-the-bugs-they-caught-16kk</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;

</description>
      <category>ios</category>
      <category>python</category>
    </item>
    <item>
      <title>The Bundle(for:) Trap: Why Your iOS Test Bundle Lies About Resources</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 00:45:06 +0000</pubDate>
      <link>https://dev.to/snake_sun/the-bundlefor-trap-why-your-ios-test-bundle-lies-about-resources-4oen</link>
      <guid>https://dev.to/snake_sun/the-bundlefor-trap-why-your-ios-test-bundle-lies-about-resources-4oen</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;

</description>
      <category>ios</category>
      <category>swift</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 00:44:56 +0000</pubDate>
      <link>https://dev.to/snake_sun/why-9-reply-rates-still-book-zero-calls-the-b2b-funnel-gap-nobody-talks-about-10k7</link>
      <guid>https://dev.to/snake_sun/why-9-reply-rates-still-book-zero-calls-the-b2b-funnel-gap-nobody-talks-about-10k7</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;

</description>
      <category>business</category>
      <category>b2b</category>
    </item>
    <item>
      <title>The Swift truncatingRemainder Trap That Took Me Five App Review Rejections</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Tue, 19 May 2026 00:44:01 +0000</pubDate>
      <link>https://dev.to/snake_sun/the-swift-truncatingremainder-trap-that-took-me-five-app-review-rejections-3cpj</link>
      <guid>https://dev.to/snake_sun/the-swift-truncatingremainder-trap-that-took-me-five-app-review-rejections-3cpj</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;

</description>
      <category>ios</category>
      <category>swift</category>
    </item>
    <item>
      <title>How I Shipped 5 iOS Apps in 30 Days as a Solo Dev (Claude Code + Fastlane + ASC API)</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Sun, 17 May 2026 16:17:52 +0000</pubDate>
      <link>https://dev.to/snake_sun/how-i-shipped-5-ios-apps-in-30-days-as-a-solo-dev-claude-code-fastlane-asc-api-31kd</link>
      <guid>https://dev.to/snake_sun/how-i-shipped-5-ios-apps-in-30-days-as-a-solo-dev-claude-code-fastlane-asc-api-31kd</guid>
      <description>&lt;p&gt;The industry's default indie pace is one to three iOS apps per year. I shipped five in 30 days — four LIVE on the App Store, one entering TestFlight today — as a solo developer, from a Windows machine, without a Mac on my desk.&lt;/p&gt;

&lt;p&gt;This article is the reproducible recipe. Not a motivational essay. Every piece of the stack is publicly available, every code snippet below is from a real shipping app, and every link goes to a real artifact.&lt;/p&gt;

&lt;p&gt;If you finish reading and decide it isn't worth doing — that's a fine outcome too. But you'll know exactly what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Apps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AutoChoice&lt;/strong&gt; — friction-free decision wheel. &lt;a href="https://apps.apple.com/app/id6765667062" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. Premium ¥980 / $6.99 one-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DaysUntil&lt;/strong&gt; — offline countdown widget. &lt;a href="https://apps.apple.com/app/id6765669356" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. Premium ¥600 / $3.99 one-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PromptVault&lt;/strong&gt; — 113 AI prompts offline (ChatGPT / Claude / Midjourney / ComfyUI). &lt;a href="https://apps.apple.com/app/id6765668776" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. Free + Pro ¥980 / $6.99.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AltitudeNow&lt;/strong&gt; — barometer + altimeter via Core Motion (no GPS battery drain). Day 16 in App Review queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FocusFlow&lt;/strong&gt; — focus timer with iOS 16 Focus Filter and AppIntents. Entering TestFlight today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tech baseline across all five: SwiftUI iOS 17+, eight languages, zero data collection, one-time IAPs (no subscriptions).&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/jiejuefuyou" rel="noopener noreferrer"&gt;github.com/jiejuefuyou&lt;/a&gt; — &lt;code&gt;autoapp-hello&lt;/code&gt;, &lt;code&gt;autoapp-days-until&lt;/code&gt;, &lt;code&gt;autoapp-prompt-vault&lt;/code&gt;, &lt;code&gt;autoapp-altitude-now&lt;/code&gt;, &lt;code&gt;autoapp-focusblock&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack — Five Layers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Layer 1: xcodegen for the Xcode project
&lt;/h3&gt;

&lt;p&gt;Hand-editing &lt;code&gt;.xcodeproj&lt;/code&gt; files is where multi-target iOS projects go to die. xcodegen reads a &lt;code&gt;project.yml&lt;/code&gt; and regenerates the &lt;code&gt;.xcodeproj&lt;/code&gt; deterministically. Every widget extension, every shared framework, every Info.plist key is declared in YAML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# project.yml (excerpt from AutoChoice)&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AutoChoice&lt;/span&gt;
&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bundleIdPrefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.jiejuefuyou&lt;/span&gt;
  &lt;span class="na"&gt;deploymentTarget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;iOS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;17.0"&lt;/span&gt;
&lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;AutoChoice&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application&lt;/span&gt;
    &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iOS&lt;/span&gt;
    &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AutoChoice&lt;/span&gt;
    &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;INFOPLIST_KEY_CFBundleLocalizations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en,ja,zh-Hans,zh-Hant,ko,es,fr,de"&lt;/span&gt;
      &lt;span class="na"&gt;INFOPLIST_KEY_NSHealthShareUsageDescription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Required&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;interact&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HealthKit."&lt;/span&gt;
      &lt;span class="na"&gt;INFOPLIST_KEY_NSHealthUpdateUsageDescription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Required&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;log&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;workouts."&lt;/span&gt;
      &lt;span class="na"&gt;DEVELOPMENT_TEAM&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;TEAM_ID&amp;gt;"&lt;/span&gt;
    &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AutoChoice/Info.plist&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;CFBundleShortVersionString&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0.6"&lt;/span&gt;
        &lt;span class="na"&gt;CFBundleVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: a developer on a fresh machine runs &lt;code&gt;xcodegen&lt;/code&gt; and gets the identical &lt;code&gt;.xcodeproj&lt;/code&gt;. No &lt;code&gt;.xcodeproj&lt;/code&gt; is checked into the repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Fastlane + match for code signing
&lt;/h3&gt;

&lt;p&gt;Fastlane's &lt;code&gt;match&lt;/code&gt; solves the "everyone needs the same certificate" problem by storing certificates and provisioning profiles in an encrypted Git repo. The CI runner reads from the same source-of-truth your local machine does. No more "works on my Mac."&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;# Fastfile (excerpt — the TestFlight lane)&lt;/span&gt;
&lt;span class="n"&gt;default_platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:ios&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:ios&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Build, sign, upload to TestFlight"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:beta&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;setup_ci&lt;/span&gt; &lt;span class="k"&gt;if&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;"CI"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;match&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="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CI"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;:&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;git_url: &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;"MATCH_GIT_URL"&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="s2"&gt;"com.jiejuefuyou.autochoice"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;increment_build_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;build_number: &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;"GITHUB_RUN_NUMBER"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;build_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;scheme: &lt;/span&gt;&lt;span class="s2"&gt;"AutoChoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;export_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;clean: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&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_path: &lt;/span&gt;&lt;span class="s2"&gt;"fastlane/asc_api_key.json"&lt;/span&gt;&lt;span class="p"&gt;,&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="ss"&gt;changelog: &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;"CHANGELOG"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"Bug fixes and improvements."&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key flag: &lt;code&gt;skip_waiting_for_build_processing: true&lt;/code&gt;. Don't make CI sit and wait — return immediately and let the App Store Connect API poll for build readiness asynchronously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: App Store Connect API for everything ASC
&lt;/h3&gt;

&lt;p&gt;The ASC web UI is a beautiful demo and a terrible production tool. Every state-changing operation in this portfolio — patching metadata in eight languages, attaching builds to TestFlight internal groups, filing review submissions, adding IAPs — happens via the ASC API V1/V2 over HTTPS, authenticated by a JWT signed with an ES256 key.&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="c1"&gt;# Generate the JWT for ASC API V1
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;asc_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_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;issuer_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;private_key_path&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;str&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="n"&gt;private_key_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&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;private_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;header&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;alg&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;ES256&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;kid&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_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;typ&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;JWT&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;issuer_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;iat&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="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;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# 20 min, ASC max
&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="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;private_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="n"&gt;header&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;asc_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ABC123XYZ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer_id&lt;/span&gt;&lt;span class="o"&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;private_key_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AuthKey_ABC123XYZ.p8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Example: list all apps
&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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/v1/apps&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;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="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;apps&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;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&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="s"&gt;Found &lt;/span&gt;&lt;span class="si"&gt;{&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;apps&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="si"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every metadata patch, every IAP attach, every build-to-group attach is one &lt;code&gt;urllib.request&lt;/code&gt; away. The 4-app portfolio dashboard polls this API every 30 minutes via cron.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: GitHub Actions macos-15 for CI
&lt;/h3&gt;

&lt;p&gt;The TestFlight upload runs on a macOS runner. I never touch a Mac. The workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/testflight.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TestFlight Build&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;macos-15&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruby/setup-ruby@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ruby-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.3'&lt;/span&gt;
          &lt;span class="na"&gt;bundler-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/Library/Caches/org.swift.swiftpm&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Decode ASC API key&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;mkdir -p fastlane&lt;/span&gt;
          &lt;span class="s"&gt;echo "${{ secrets.ASC_API_KEY_JSON_B64 }}" | base64 --decode &amp;gt; fastlane/asc_api_key.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup match SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;webfactory/ssh-agent@v0.9.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ssh-private-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MATCH_DEPLOY_KEY }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install xcodegen&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;brew install xcodegen&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate project&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xcodegen&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fastlane beta&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;MATCH_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MATCH_PASSWORD }}&lt;/span&gt;
          &lt;span class="na"&gt;MATCH_GIT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MATCH_GIT_URL }}&lt;/span&gt;
          &lt;span class="na"&gt;CHANGELOG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.head_commit.message }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle exec fastlane beta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git push --tag v1.0.6&lt;/code&gt; from my Windows machine triggers this workflow. ~12 minutes later, the build is on TestFlight. No human touches the Mac.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 5: The ASC submission flow (the part nobody talks about)
&lt;/h3&gt;

&lt;p&gt;Submitting a version for App Review used to require clicking through the ASC web UI. The 2026 ASC API V1 added &lt;code&gt;reviewSubmissions&lt;/code&gt; and &lt;code&gt;reviewSubmissionItems&lt;/code&gt; — four API calls and you're submitted:&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="c1"&gt;# 4-step ASC submission flow (replaces the old CDP web automation)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&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;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="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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;resp&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&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;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATCH&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;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="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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;resp&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Step 1: cancel any existing in-progress submission
# (PATCH state=CANCELED if reviewSubmission exists in IN_REVIEW state)
# Step 2: create a new reviewSubmission for the platform
&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&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/v1/reviewSubmissions&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;reviewSubmissions&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;platform&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;IOS&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;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="p"&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;sub_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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;data&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="c1"&gt;# Step 3: attach each item (appStoreVersion, IAP, etc.) to the submission
&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/v1/reviewSubmissionItems&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;reviewSubmissionItems&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;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;reviewSubmission&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;reviewSubmissions&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;sub_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;appStoreVersion&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;appStoreVersions&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;version_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;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Step 4: flip the submission to SUBMITTED
&lt;/span&gt;&lt;span class="nf"&gt;patch&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;https://api.appstoreconnect.apple.com/v1/reviewSubmissions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sub_id&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="n"&gt;token&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;reviewSubmissions&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;sub_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;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;submitted&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="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;p&gt;That replaced ~120 lines of CDP browser automation that used to flake on App Store Connect's SPA re-hydration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Hurt (Five Production Lessons)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Lesson 1: &lt;code&gt;truncatingRemainder&lt;/code&gt; on negatives keeps the sign
&lt;/h3&gt;

&lt;p&gt;Swift returns &lt;code&gt;-150&lt;/code&gt; for &lt;code&gt;(-2310).truncatingRemainder(dividingBy: 360)&lt;/code&gt;. Mathematical mod would return &lt;code&gt;210&lt;/code&gt;. AutoChoice's wheel rotation accumulator drifted across spins because of this, Apple's reviewer caught it as 2.1(a) ("pointer on tacos, result shows pizza"), and the fix was a floor-divide anchor:&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;// Wrong (drifts across spins on negative accumulator)&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;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="n"&gt;rounds&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;targetAngle&lt;/span&gt;

&lt;span class="c1"&gt;// Right (resets to integer multiple of 360 every spin)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;anchor&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="n"&gt;currentRotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;targetAngle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lesson 2: &lt;code&gt;@testable import&lt;/code&gt; lies about &lt;code&gt;Bundle(for:)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;@testable import YourApp&lt;/code&gt;, &lt;code&gt;Bundle(for: YourClass.self)&lt;/code&gt; inside an XCTest returns the test bundle, not the host app bundle. Test bundle has no &lt;code&gt;.lproj&lt;/code&gt; folders, so every localization test fails with "Missing key 'Wheel'." Fix: scan &lt;code&gt;Bundle.allBundles&lt;/code&gt; for the one with a &lt;code&gt;.app&lt;/code&gt; suffix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 3: Widget extensions need their own Matchfile entry
&lt;/h3&gt;

&lt;p&gt;Adding a widget extension is two new bundle IDs in ASC plus two new Matchfile entries. Forgetting the second is the most common "Provisioning profile doesn't include com.foo.widget" error in CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 4: Apple's reviewer has no IAP entitlement
&lt;/h3&gt;

&lt;p&gt;The reviewer account behaves like a fresh sandbox user. &lt;code&gt;Product.products(for:)&lt;/code&gt; may return empty for the reviewer even when it works for everyone else. Render the paywall buttons unconditionally and surface IAP failure as inline UI, not as a hidden loading spinner. Two 2.1(b) rejects taught me this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 5: &lt;code&gt;github.com/&amp;lt;user&amp;gt;/&amp;lt;repo&amp;gt;/issues&lt;/code&gt; is not a Support URL
&lt;/h3&gt;

&lt;p&gt;Apple 1.5 wants a real support page per app. 90 lines of HTML on GitHub Pages, with FAQ + contact email + system requirements, is the minimal compliant artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest State
&lt;/h2&gt;

&lt;p&gt;Revenue today across the five apps: approximately zero. Target: $300/mo by Day 90 via portfolio cross-promo, dev.to backlinks, this article, and one-time IAP conversions. The bet is on durable indie revenue, not subscription juice.&lt;/p&gt;

&lt;p&gt;If you build iOS, fork the public repos and steal whatever's useful. If you don't, but this was interesting, the &lt;a href="https://autoappnotes.substack.com" rel="noopener noreferrer"&gt;Substack&lt;/a&gt; and &lt;a href="https://jiejuefuyou.gumroad.com" rel="noopener noreferrer"&gt;Gumroad templates&lt;/a&gt; are where the rest lives.&lt;/p&gt;

&lt;p&gt;— Hao Sun, 2026-05-17, Tokyo&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>indiedev</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Day 60: First iOS App Live (NewDaysUntil) — After 2.1(b) IAP Recovery in 90 Minutes</title>
      <dc:creator>孫昊</dc:creator>
      <pubDate>Fri, 08 May 2026 13:01:51 +0000</pubDate>
      <link>https://dev.to/snake_sun/day-60-first-ios-app-live-newdaysuntil-after-21b-iap-recovery-in-90-minutes-1jn8</link>
      <guid>https://dev.to/snake_sun/day-60-first-ios-app-live-newdaysuntil-after-21b-iap-recovery-in-90-minutes-1jn8</guid>
      <description>&lt;p&gt;NewDaysUntil just cleared Apple review. Day 60. The first of 4 iOS apps I shipped in 60 days is now live on the App Store.&lt;/p&gt;

&lt;p&gt;This is the Day 60 report. Not a victory lap — a raw snapshot of where I actually stand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is NewDaysUntil?
&lt;/h2&gt;

&lt;p&gt;A countdown app. Count down to anything that matters — a birthday, a flight, a product launch, a goal you've set yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Home screen and lock screen widgets&lt;/li&gt;
&lt;li&gt;iCloud sync across iPhone + iPad&lt;/li&gt;
&lt;li&gt;No ads. No SDK trackers. No subscription required.&lt;/li&gt;
&lt;li&gt;One-time unlock IAP (lifetime access)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://apps.apple.com/app/newdaysuntil/id6765669356" rel="noopener noreferrer"&gt;Download on the App Store →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 57: All 4 Apps Rejected on the Same Day
&lt;/h2&gt;

&lt;p&gt;On May 5 — Day 57 — Apple rejected all 4 apps. Same reason. Same code. Same day.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;2.1(b) App Completeness — In-App Purchases are not visible to reviewers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For anyone who hasn't hit this: 2.1(b) means Apple's reviewer opened your app, navigated around, never reached your paywall or IAP, and marked your app incomplete. It doesn't mean your IAP is broken. It means the reviewer's path never surfaced it.&lt;/p&gt;

&lt;p&gt;All 4 apps. One afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 90-Minute Fix
&lt;/h2&gt;

&lt;p&gt;I rebuilt the submission flow from scratch using ASC API V2 — fully programmatic, no App Store Connect UI clicks. Then used browser CDP automation to verify the IAP surfaced correctly in the reviewer's expected navigation flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total time: 90 minutes. NewDaysUntil cleared review 2 days later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full breakdown with code: &lt;a href="https://dev.to/snake_sun/how-i-fixed-apples-iap-21b-app-completeness-rejection-in-90-minutes-using-asc-api-v2-13pg"&gt;How I Fixed Apple's IAP 2.1(b) App Completeness Rejection in 90 Minutes Using ASC API V2&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Lessons from 2.1(b)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Apple reviewers follow a path, not an exploration.&lt;/strong&gt;&lt;br&gt;
If your IAP paywall is 3 taps deep and the reviewer only went 2 taps, you're rejected. Design your review flow explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Sandbox account setup matters.&lt;/strong&gt;&lt;br&gt;
A sandbox tester without the right entitlements sees a broken purchase flow. Use the same test account Apple will use — configure it before submission, not after rejection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ASC API V2 is worth learning.&lt;/strong&gt;&lt;br&gt;
The UI is slow and opaque. The API gives you full visibility into submission state, IAP linkage, and review history. Related: &lt;a href="https://dev.to/snake_sun"&gt;Day 61 — ASC API V1/V2 Quirks That Wasted My Time&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. CDP automation for ASC pricing is a force multiplier.&lt;/strong&gt;&lt;br&gt;
ASC's SPA takes 22–25 seconds to hydrate per page. Automating the 7-step IAP pricing flow via browser CDP cut my setup time from 40+ minutes to 4. Details in &lt;a href="https://dev.to/snake_sun"&gt;Day 62&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  60-Day Snapshot (Honest Numbers)
&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;Apps shipped&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apps live (approved)&lt;/td&gt;
&lt;td&gt;1 (today)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.to articles published&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack subscribers&lt;/td&gt;
&lt;td&gt;~600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple 2.1(b) rejections&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Days from rejection to re-approval&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;$0 revenue. That's the number I'm not hiding. The apps are live (or clearing). Monetization is the Day 61+ problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;3 more apps still in review post-re-submission: AltitudeNow, AutoChoice, PromptVault. All re-submitted with the 2.1(b) fix applied.&lt;/p&gt;

&lt;p&gt;I'm tracking everything in public. If you're an iOS indie dev fighting Apple review loops, or studying what a real 60-day zero-to-ship arc looks like — including the uncomfortable parts — follow along.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources for iOS Indies
&lt;/h2&gt;

&lt;p&gt;Two things I've packaged from this journey:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://snake-sun.gumroad.com" rel="noopener noreferrer"&gt;iOS Indie Survival Kit Bundle — $59&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
ASC API scripts, CDP automation flows, submission checklist, IAP recovery playbook, 87-article dev.to archive. Everything I actually used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free: 14 iOS Rejection Reasons (lead magnet)&lt;/strong&gt;&lt;br&gt;
The 14 most common Apple rejection codes with exact fix steps. Free download at the same link.&lt;/p&gt;




&lt;p&gt;Day 60. One app live. Three in the queue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apps.apple.com/app/newdaysuntil/id6765669356" rel="noopener noreferrer"&gt;NewDaysUntil on the App Store&lt;/a&gt;&lt;/p&gt;

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