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/jiejuefuyou — autoapp-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"
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
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")
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
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}
}
}
)
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
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)