DEV Community

孫昊
孫昊

Posted on • Originally published at autoappnotes.substack.com

How I Shipped 5 iOS Apps in 30 Days as a Solo Dev (Claude Code + Fastlane + ASC API)

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.

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.

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.

The Five Apps

  • AutoChoice — friction-free decision wheel. App Store. Premium ¥980 / $6.99 one-time.
  • DaysUntil — offline countdown widget. App Store. Premium ¥600 / $3.99 one-time.
  • PromptVault — 113 AI prompts offline (ChatGPT / Claude / Midjourney / ComfyUI). App Store. Free + Pro ¥980 / $6.99.
  • AltitudeNow — barometer + altimeter via Core Motion (no GPS battery drain). Day 16 in App Review queue.
  • FocusFlow — focus timer with iOS 16 Focus Filter and AppIntents. Entering TestFlight today.

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

GitHub: github.com/jiejuefuyouautoapp-hello, autoapp-days-until, autoapp-prompt-vault, autoapp-altitude-now, autoapp-focusblock.

The Stack — Five Layers

Layer 1: xcodegen for the Xcode project

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

# project.yml (excerpt from AutoChoice)
name: AutoChoice
options:
  bundleIdPrefix: com.jiejuefuyou
  deploymentTarget:
    iOS: "17.0"
targets:
  AutoChoice:
    type: application
    platform: iOS
    sources:
      - AutoChoice
    settings:
      INFOPLIST_KEY_CFBundleLocalizations: "en,ja,zh-Hans,zh-Hant,ko,es,fr,de"
      INFOPLIST_KEY_NSHealthShareUsageDescription: "Required to interact with HealthKit."
      INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Required to log workouts."
      DEVELOPMENT_TEAM: "<TEAM_ID>"
    info:
      path: AutoChoice/Info.plist
      properties:
        CFBundleShortVersionString: "1.0.6"
        CFBundleVersion: "100"
Enter fullscreen mode Exit fullscreen mode

Result: a developer on a fresh machine runs xcodegen and gets the identical .xcodeproj. No .xcodeproj is checked into the repo.

Layer 2: Fastlane + match for code signing

Fastlane's match 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."

# Fastfile (excerpt — the TestFlight lane)
default_platform(:ios)

platform :ios do
  desc "Build, sign, upload to TestFlight"
  lane :beta do
    setup_ci if ENV["CI"]
    match(
      type: "appstore",
      readonly: ENV["CI"] ? true : false,
      git_url: ENV["MATCH_GIT_URL"],
      app_identifier: ["com.jiejuefuyou.autochoice"]
    )
    increment_build_number(
      build_number: ENV["GITHUB_RUN_NUMBER"] || "1"
    )
    build_app(
      scheme: "AutoChoice",
      export_method: "app-store",
      clean: true
    )
    upload_to_testflight(
      api_key_path: "fastlane/asc_api_key.json",
      skip_waiting_for_build_processing: true,
      changelog: ENV["CHANGELOG"] || "Bug fixes and improvements."
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

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

Layer 3: App Store Connect API for everything ASC

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.

# Generate the JWT for ASC API V1
import jwt, time, json, urllib.request

def asc_jwt(key_id: str, issuer_id: str, private_key_path: str) -> str:
    with open(private_key_path, "r") as f:
        private_key = f.read()
    header = {"alg": "ES256", "kid": key_id, "typ": "JWT"}
    payload = {
        "iss": issuer_id,
        "iat": int(time.time()),
        "exp": int(time.time()) + 1200,   # 20 min, ASC max
        "aud": "appstoreconnect-v1"
    }
    return jwt.encode(payload, private_key, algorithm="ES256", headers=header)

token = asc_jwt(key_id="ABC123XYZ", issuer_id="...", private_key_path="AuthKey_ABC123XYZ.p8")

# Example: list all apps
req = urllib.request.Request(
    "https://api.appstoreconnect.apple.com/v1/apps",
    headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req) as resp:
    apps = json.load(resp)
    print(f"Found {len(apps['data'])} apps")
Enter fullscreen mode Exit fullscreen mode

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

Layer 4: GitHub Actions macos-15 for CI

The TestFlight upload runs on a macOS runner. I never touch a Mac. The workflow:

# .github/workflows/testflight.yml
name: TestFlight Build
on:
  push:
    tags:
      - 'v*'
jobs:
  build:
    runs-on: macos-15
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true
      - uses: actions/cache@v4
        with:
          path: ~/Library/Caches/org.swift.swiftpm
          key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
      - name: Decode ASC API key
        run: |
          mkdir -p fastlane
          echo "${{ secrets.ASC_API_KEY_JSON_B64 }}" | base64 --decode > fastlane/asc_api_key.json
      - name: Setup match SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.MATCH_DEPLOY_KEY }}
      - name: Install xcodegen
        run: brew install xcodegen
      - name: Generate project
        run: xcodegen
      - name: Fastlane beta
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          CHANGELOG: ${{ github.event.head_commit.message }}
        run: bundle exec fastlane beta
Enter fullscreen mode Exit fullscreen mode

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

Layer 5: The ASC submission flow (the part nobody talks about)

Submitting a version for App Review used to require clicking through the ASC web UI. The 2026 ASC API V1 added reviewSubmissions and reviewSubmissionItems — four API calls and you're submitted:

# 4-step ASC submission flow (replaces the old CDP web automation)
import json, urllib.request

def post(url, token, body):
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        },
        data=json.dumps(body).encode()
    )
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)

def patch(url, token, body):
    req = urllib.request.Request(
        url,
        method="PATCH",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        },
        data=json.dumps(body).encode()
    )
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)

# 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
sub = post(
    "https://api.appstoreconnect.apple.com/v1/reviewSubmissions",
    token,
    {
        "data": {
            "type": "reviewSubmissions",
            "attributes": {"platform": "IOS"},
            "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}
        }
    }
)
sub_id = sub["data"]["id"]

# Step 3: attach each item (appStoreVersion, IAP, etc.) to the submission
post(
    "https://api.appstoreconnect.apple.com/v1/reviewSubmissionItems",
    token,
    {
        "data": {
            "type": "reviewSubmissionItems",
            "relationships": {
                "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": sub_id}},
                "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}}
            }
        }
    }
)

# Step 4: flip the submission to SUBMITTED
patch(
    f"https://api.appstoreconnect.apple.com/v1/reviewSubmissions/{sub_id}",
    token,
    {
        "data": {
            "type": "reviewSubmissions",
            "id": sub_id,
            "attributes": {"submitted": True}
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

That replaced ~120 lines of CDP browser automation that used to flake on App Store Connect's SPA re-hydration.

What Actually Hurt (Five Production Lessons)

Lesson 1: truncatingRemainder on negatives keeps the sign

Swift returns -150 for (-2310).truncatingRemainder(dividingBy: 360). Mathematical mod would return 210. 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:

// Wrong (drifts across spins on negative accumulator)
currentRotation = currentRotation.truncatingRemainder(dividingBy: 360)
                  - rounds * 360 + targetAngle

// Right (resets to integer multiple of 360 every spin)
let anchor = (currentRotation / 360).rounded(.down) * 360
currentRotation = anchor + targetAngle
Enter fullscreen mode Exit fullscreen mode

Lesson 2: @testable import lies about Bundle(for:)

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

Lesson 3: Widget extensions need their own Matchfile entry

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.

Lesson 4: Apple's reviewer has no IAP entitlement

The reviewer account behaves like a fresh sandbox user. Product.products(for:) 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.

Lesson 5: github.com/<user>/<repo>/issues is not a Support URL

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.

The Honest State

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.

If you build iOS, fork the public repos and steal whatever's useful. If you don't, but this was interesting, the Substack and Gumroad templates are where the rest lives.

— Hao Sun, 2026-05-17, Tokyo

Top comments (0)