Email me

Dev · · 29 min read · Matt Busel

Filed under iOS and Swift App development Guides

iOS development on Windows: the complete guide (21 apps, no Mac)

iOS development on Windows: the complete guide (21 apps, no Mac)
On this page · 21 sections
  1. The data behind this guide
  2. What you can and cannot do on Windows
  3. Your options for the Mac part, compared
  4. Step 1: get Swift onto your Windows machine
  5. Step 2: describe the project in YAML, never in Xcode
  6. Step 3: rent the Mac with one workflow file
  7. Where the Mac minutes actually go
  8. Step 4: signing without a Mac (the part that broke)
  9. Step 5: screenshots and the listing, all from Windows
  10. Step 6: testing when you can't hold the iPhone
  11. Step 7: in-app purchases with StoreKit 2
  12. The errors you will see, and what each one really means
  13. The learning curve: 13 failures, then zero
  14. App Review is the real bottleneck
  15. Guideline 4.3: what happens when shipping gets too cheap
  16. What it costs
  17. What came out the other end
  18. A 7-day plan to ship your first iOS app from Windows
  19. FAQ: iOS development on Windows
  20. The numbers
  21. What's next

TL;DR: You can do iOS development on Windows in 2026 if you rent the Mac part by the minute. I shipped 21 iPhone apps to App Store Connect from a Windows PC between September 10 and September 27, 2026, and 8 are live on the US App Store today. For this guide I pulled every CI run, every job step, every commit and every App Review email behind them on October 18: the median app went from its first commit to its first App Store submission in 2.2 hours, a release costs about 4 minutes of rented Mac time, and the thing that stopped 13 of the 21 apps was never Windows. It was App Review.

Short answer for the search. Xcode only runs on macOS, so "iOS development on Windows" really means splitting the work. You write the Swift, the project file, the store listing and the release scripts on Windows. A macOS machine you never touch compiles, signs and uploads. The cheapest version of that machine is a GitHub Actions macOS runner, which GitHub does not charge for on public repositories. You still need the $99 a year Apple Developer Program. You do not need Keychain Access, Xcode's Organizer window, or a Mac on your desk.

This is the pillar guide for everything I have written about the pipeline. It walks the whole path once, from an empty folder on Windows to "Ready for Distribution", and links the deep-dives where each step gets its own article: the CI pipeline and signing, what the 20 paid-for builds really cost, how long App Review took across 64 decisions, and one of the apps that came out of it. Everything below is real code from the app repos and real numbers pulled today.

Animated timeline, one row per app for 21 iPhone apps, Sep 10 to Oct 18 2026. A cursor sweeps across the dates. Lime bars run from each first commit to the first App Store submission, mostly a few hours; green bars are review rounds that ended in approval, red bars review rounds that ended in a rejection or a question. Eight rows turn into thin green live lines by Sep 25; eleven rows starting Sep 26 end in two long red bars each, ending Oct 5 and Oct 12. Counter ends at 8 live.
Every app from first commit to today: git, the GitHub Actions API, Apple's App Review emails and the iTunes lookup API, pulled Oct 18 2026, 4:12 to 4:20 PM ET. Static version: timeline.png.

The data behind this guide

Every number here comes from records the pipeline already leaves behind, pulled on October 18, 2026 between 4:12 and 4:20 PM Eastern. Nothing was rebuilt for the article and no app was touched.

  • GitHub: all 384 workflow runs in the 21 app repos, every commit, and for each of the 81 successful App Store runs, every step with its start and end time, through the workflow runs and workflow jobs REST APIs.
  • Apple's emails: the 64 App Review decisions from the review time article, each with Apple's own "Submitted" timestamp. I checked the inbox again this morning: no new decisions since October 12.
  • The App Store: the iTunes lookup API for every app on my developer account, with version, release date and file size.
  • The code: a shallow clone of each repo, counting non-blank lines of Swift.

The collector is 40 lines of Python around the GitHub CLI. This is the core of it (collect.py, trimmed):

def api(path):
    r = subprocess.run(["gh", "api", "-H", "Accept: application/vnd.github+json", path],
                       capture_output=True, text=True, encoding="utf-8")
    if r.returncode != 0:
        if "429" in r.stderr or "rate limit" in r.stderr.lower():
            sys.exit("RATE LIMIT, stopping")
        return None
    return json.loads(r.stdout)

for repo in REPOS:
    runs, page = [], 1
    while True:
        d = api(f"repos/Mattbusel/{repo}/actions/runs?per_page=100&page={page}")
        if not d: break
        runs += d["workflow_runs"]
        if len(d["workflow_runs"]) < 100: break
        page += 1
    jobs = {}
    for r in runs:
        if r["name"] == "App Store" and r["conclusion"] == "success":
            jobs[r["id"]] = api(f"repos/Mattbusel/{repo}/actions/runs/{r['id']}/jobs")

Point it at your own repos and you get the same picture of your own pipeline: where the minutes go and which steps fail. It costs a few hundred API calls, well inside GitHub's hourly limit.

The headline numbers, before the how-to:

  • 53,932 lines of Swift pushed to the 21 public repos, median 2,308 non-blank lines per app (the local working copies hold a bit more). No .xcodeproj in any of the 21 repos.
  • 384 CI runs: 139 App Store runs, 165 Build runs, 18 review-video recordings, 62 GitHub Pages deploys. About 2,572 minutes on macOS runners, queue to finish.
  • First commit to first App Store submission: median 2.2 hours, fastest 25 minutes (Race Pace, September 27).
  • First submission to approval, for the 8 apps that got one: median 4.4 days, range 3.7 to 7.2.
  • Status today: 21 built, 21 submitted, 8 approved, 8 live. 0 of 21 approved on the first try.

What you can and cannot do on Windows

The split is cleaner than most forum threads make it sound. Apple's toolchain (the compiler with the iOS SDK, the code signer, the Simulator and the uploader) only ships inside Xcode, and Xcode only runs on macOS. Everything that is text, or talks to Apple over HTTPS, works from Windows.

TaskWhere it ran for the 21 appsTool
Writing Swift and SwiftUIWindowsany editor + coding agents
The Xcode projectWindows (as YAML), generated on the MacXcodeGen
Bundle ID, listing text, age rating, review notes, screenshots upload, in-app purchasesWindowsApp Store Connect API from Python
The distribution certificateWindows, oncePython cryptography + the API
Compiling for iOS, unit tests, Simulator screenshotsrented Macxcodebuild, simctl
Signing, uploading, submitting for reviewrented Macfastlane
Creating the app recorda browser, once per appApp Store Connect website

That last row is the one surprise. The API can edit almost everything about an app, but our POST /v1/apps came back 403, so each new app record is one form in the App Store Connect website. It takes about a minute. Every other field is scripted.

Diagram in three columns. Your Windows PC: write Swift and SwiftUI, project.yml for XcodeGen, Python scripts that talk to the App Store Connect API for the listing, purchases and screenshots, signing.py makes the certificate once, gh workflow run. A rented Mac in CI: macos-15 runner for about 4 minutes, pick Xcode 26 and a simulator, xcodegen generate, xcodebuild build and test, import the certificate into a throwaway keychain, fastlane signs, uploads and submits. Apple: App Store Connect processes the build, TestFlight, App Review with a median of 73 hours a round, Ready for Distribution, App Store.
What runs where in the pipeline behind the 21 apps.

Your options for the Mac part, compared

You need some macOS machine for the compile-sign-upload half. Here are the real choices, with what I could verify about each on October 18:

OptionWhat it isThe catch
GitHub Actions macOS runner (what I used)A fresh macOS VM per job, Xcode preinstalledFree on public repos. Private repos pay $0.062 a minute for the standard 3 or 4 core macOS runner. No screen: everything is scripted.
Expo EAS BuildIf you write React Native with Expo, "iOS builds run on macOS runners hosted in Expo's macOS cloud"Only for Expo/React Native projects. You trade Swift for JavaScript.
AWS EC2 MacA real Mac mini in a data centerA minimum allocation of 24 hours per dedicated host, so even a 4 minute build bills a day.
Cloud Mac rental by the monthA remote desktop on a MacYou get the GUI, but you are paying for a whole machine to sit idle between releases.
FlutterCross-platform UI in DartFlutter's iOS setup page starts with installing Xcode. You still need one of the Macs above to build for iOS.
macOS in a VM on your PCThe "Hackintosh" routeApple's macOS license limits installs to Apple-branded hardware. I did not try it and would not build a business on it.

My opinion: for a solo developer or a small studio, the CI runner wins by a mile. You only ever need the Mac for about 4 minutes per release, the machine is fresh every time (no "works on my Mac"), and the whole setup lives in one file in the repo. The cost of that choice is that you can't click anything, which turned out to be a feature: every fix became a line in a file the next app could read.

Step 1: get Swift onto your Windows machine

You don't strictly need a Swift compiler on Windows. None of the 21 apps were compiled locally; the feedback loop was a CI run in "compile" mode, whose compile step took a median of 35 seconds across 37 runs. Add the runner's startup and you get an answer in under two minutes.

If you want local feedback anyway, Swift has an official Windows toolchain, installed with winget install --id Swift.Toolchain -e --source winget after Visual Studio's build tools, and the Swift extension for VS Code gives you completion and errors. The limit is the SDK: SwiftUI, UIKit, StoreKit and WidgetKit are Apple frameworks, so on Windows you can compile and test the plain-Swift parts (your models, your date math, your parsers) but not the screens.

That split is a decent design rule anyway: keep the logic that matters (streak math, scoring, pricing) in plain Swift files with no import SwiftUI, and keep the views thin. Coding agents are much better at code they can reason about without a screen, and so are you.

What you do need on Windows: git, Python 3 (for the App Store Connect scripts), and the GitHub CLI to start the CI runs. If you run coding agents on this, read what broke running 20 agents on one PC first.

Step 2: describe the project in YAML, never in Xcode

An Xcode project file (project.pbxproj) is a large generated file with random IDs that you can't sensibly edit by hand, and you can't open Xcode on Windows to edit it for you. So the 21 repos don't have one. XcodeGen builds it from a short YAML file at the start of every CI run. This is Chain's real spec, trimmed (project.yml; Chain has an app plus a widget extension):

## Chain, XcodeGen project spec. The .xcodeproj is generated in CI, never committed.
name: Chain
options:
  bundleIdPrefix: com.mattbusel
  deploymentTarget:
    iOS: "17.0"
settings:
  base:
    MARKETING_VERSION: "1.2"
    CURRENT_PROJECT_VERSION: "1"
    CODE_SIGN_STYLE: Manual
    DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM}
targets:
  Chain:
    type: application
    platform: iOS
    dependencies:
      - target: ChainWidgets
    sources:
      - path: Sources
      - path: Resources/Assets.xcassets
        buildPhase: resources
    entitlements:
      path: Resources/Chain.entitlements
      properties:
        com.apple.security.application-groups:
          - group.com.mattbusel.chainhabits
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.mattbusel.chainhabits
        PROVISIONING_PROFILE_SPECIFIER: "${APP_PROFILE}"
  ChainWidgets:
    type: app-extension
    platform: iOS
    sources:
      - path: Widgets
      - path: Sources/Shared

Three details carry weight. DEVELOPMENT_TEAM and the profile names come from environment variables, so no account identifier sits in the repo. CODE_SIGN_STYLE: Manual is deliberate; the signing section below explains why automatic signing can't work without a registered iPhone. And because the project is generated, there is never a merge conflict in a pbxproj, which matters a lot once two agents edit the same app.

The generate step on the runner took a median of under one second across 81 successful runs. It's the cheapest step in the pipeline and it removes the one file that makes iOS repos hostile to Windows.

Step 3: rent the Mac with one workflow file

All of the Mac lives in .github/workflows/appstore.yml. It has one input with three modes, started from Windows with gh workflow run appstore.yml -f action=dry_run:

on:
  workflow_dispatch:
    inputs:
      action:
        description: "What to do"
        required: true
        default: compile
        type: choice
        options:
          - compile       # build + unit tests, no simulator screenshots
          - dry_run       # build, sign, upload; do not submit
          - release       # everything, including submit for review

jobs:
  ship:
    runs-on: macos-15
    timeout-minutes: 90

compile is your local build button. dry_run puts a signed build in App Store Connect (and TestFlight) without asking Apple to review it. release does the same and submits.

Two runner traps cost real runs, and both are in the file as comments now. First, the Xcode that isn't there. On the macos-15 image, /Applications/Xcode_26.app existed but had no toolchain inside, so selecting it failed while ls listed it happily. The fix picks the newest Xcode 26 that is real:

      - name: Select Xcode 26
        run: |
          chosen=""
          for app in $(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -Vr); do
            if [ -d "$app/Contents/Developer" ]; then chosen="$app"; break; fi
          done
          if [ -z "$chosen" ]; then
            echo "no usable Xcode 26:"; ls /Applications | grep -i xcode; exit 1
          fi
          sudo xcode-select -s "$chosen/Contents/Developer"

Second, simulators that move. A hardcoded device name worked for six runs and failed on the seventh with "Unable to find a device matching the provided destination specifier", because the runner fleet doesn't always carry the same simulators. Now the newest available iPhone wins:

          name=$(xcrun simctl list devices available \
                 | grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
          echo "SIM_NAME=$name" >> "$GITHUB_ENV"

Here is what a whole release looks like when it works, Chain 1.2 on October 8:

Animated diagram of the pipeline: a Windows PC runs gh workflow run, a GitHub macOS runner picks Xcode 26 and compiles in 52 seconds, imports the signing certificate in 1 second, then fastlane builds and uploads in 2 minutes 31 seconds, 4 minutes 22 seconds in total.
How Chain 1.2 went from one command on Windows to App Store Connect on Oct 8 2026. Step times from the GitHub Actions run log.

Where the Mac minutes actually go

This is the new measurement for this guide. GitHub's jobs API returns every step of every job with its start and end time, so I summed the time inside each step for all 81 successful App Store runs across the 21 apps: 646 minutes of step time in total.

Bar chart of minutes spent inside each CI step across 81 successful App Store runs, 646 minutes in total: capture App Store screenshots 222 minutes, 34 percent; build and run unit tests 162, 25 percent; sign, upload and submit with fastlane 101, 16 percent; find a simulator 47, 7 percent; record gameplay or review video 46, 7 percent; tools, Xcode and checkout 34, 5 percent; compile only 28, 4 percent; everything else 6, 1 percent.
Step start and end times from the GitHub Actions jobs API, 81 successful App Store runs in 21 repos, Sep 10 to Oct 9 2026, pulled Oct 18 2026.

The surprise is the top bar. 34% of all the Mac time went to capturing App Store screenshots in the Simulator, more than signing, uploading and submitting combined (16%). The unit tests of the first three apps (the games, the only ones with an XCTest suite in the release lane) took another quarter. The step everyone fears, code signing, was a rounding error once it worked: importing the certificate took a median of 1 second. Finding a simulator took 7%, which is the cost of not hardcoding one, and worth it.

The pipeline changed shape halfway through September, and the data shows exactly what that bought. The early apps captured fresh screenshots (and, for the three games, ran their tests) on every App Store run. Starting with the September 18 batch, most apps committed their screenshots to the repo once, and the App Store run became compile, sign and upload:

Two bars. Successful App Store runs that captured screenshots in CI: 44 runs, median 12.0 minutes. Runs that used screenshots committed to git: 37 runs, median 4.1 minutes.
Median job time of a successful App Store run by where its screenshots came from. GitHub Actions jobs API, pulled Oct 18 2026.

The median successful run dropped from 12.0 minutes (44 runs) to 4.1 minutes (37 runs), and the slowest 10% went from 17.8 to 5.3 minutes. In fairness to the slow lane, it also ran the unit tests, so screenshots are not the whole difference. The point stands: a release should ship what a human already looked at, not re-shoot it. Tests and screenshots belong in the Build workflow that runs on every push, not in the one that talks to Apple.

The queue was never the problem. The median wait from "run requested" to "job started" was 10 seconds; the worst one was 17 minutes.

Screenshot of the GitHub Actions job for Chain 1.2: Select Xcode 26 4s, install xcodegen and fastlane 7s, pick a simulator 25s, compile 52s, write the signing key 0s, import the signing certificate 1s, build, upload and submit 2m 31s. Job succeeded in 4m 9s.
A release in the fast lane: Chain 1.2, Oct 8 2026, 4m 9s of job time. This was a dry_run, so it stopped after the upload.

Step 4: signing without a Mac (the part that broke)

Code signing is how Apple knows a build came from you. It takes a certificate (a public key Apple has signed), its private key, and a provisioning profile that ties both to your app ID. On a Mac, Xcode hides all of it. On a CI runner it's where the first app spent a morning. The full story is in Build an iOS app on Windows; here is the short version and the code that matters.

Screenshot of Glyphstorm's App Store workflow runs on GitHub Actions, Sep 10 2026: run 10 green at 5:49 AM, run 11 and runs 13 to 19 failed, run 12 cancelled, run 20 green at 6:38 AM EDT.
Glyphstorm's App Store runs on Sep 10 2026, 5:49 to 6:38 AM ET, the morning signing broke (github.com/Mattbusel/glyphstorm, captured Oct 16).

The expensive mistake was letting CI create the certificate. When fastlane's certificate step runs on a runner, the private key is born on a machine that is deleted minutes later. Apple keeps a certificate nobody can ever use again, and Apple limits how many distribution certificates an account can hold. Three failed runs orphaned three of them on September 10 before anyone noticed.

Never let CI create a certificate. Make the key once on your own machine, and let CI only import it.

So the key is made on Windows, by a Python script that generates it with the cryptography library, asks Apple's API to sign it, and pipes a .p12 straight into gh secret set. Every API call uses a short-lived token signed with your App Store Connect key (Store/asc.py, the token function; the key ID and issuer ID come from environment variables):

def token() -> str:
    """A fresh twenty minute ES256 token.

    Apple rejects anything longer lived than that, so this is minted per run
    rather than cached anywhere.
    """
    private_key = KEY_PATH.read_text()
    now = int(time.time())
    payload = {
        "iss": ISSUER_ID,
        "iat": now - 60,
        "exp": now + 20 * 60,
        "aud": "appstoreconnect-v1",
    }
    return jwt.encode(
        payload, private_key, algorithm="ES256", headers={"kid": KEY_ID, "typ": "JWT"}
    )

That twenty minute limit is Apple's rule, not a choice. On the runner, the certificate goes into a throwaway keychain, and one line in this step decides whether the job finishes or hangs forever:

          keychain="$RUNNER_TEMP/signing.keychain-db"
          security create-keychain -p "" "$keychain"
          security set-keychain-settings -lut 21600 "$keychain"
          security unlock-keychain -p "" "$keychain"
          echo "$DIST_CERT_P12" | base64 --decode > "$RUNNER_TEMP/dist.p12"
          security import "$RUNNER_TEMP/dist.p12" -k "$keychain" \
            -P "$DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
          # Without the partition list, codesign blocks on a GUI prompt that
          # nobody can answer and the job hangs until it times out.
          security set-key-partition-list -S apple-tool:,apple:,codesign: \
            -s -k "" "$keychain" >/dev/null

Then fastlane asks for the App Store profile directly and signs manually. Automatic signing resolves a development profile during the archive, and a development profile needs at least one registered iPhone. An account with no devices gets "Your team has no devices from which to generate a provisioning profile" on an App Store build, which needs no device at all. Asking for the distribution profile directly skips the step that can't succeed.

fastlane reads the API key from the secret, so there is no Apple ID password and no two-factor prompt anywhere (fastlane/Fastfile):

  def asc_key
    app_store_connect_api_key(
      key_id: ENV.fetch("ASC_KEY_ID"),
      issuer_id: ENV.fetch("ASC_ISSUER_ID"),
      key_content: ENV.fetch("ASC_KEY_CONTENT"),
      is_key_content_base64: true,
      in_house: false
    )
  end

The app_store_connect_api_key action is the one to read. in_house: false tells it this is a normal developer account, not an enterprise one; getting that wrong produces an authentication error that says nothing useful. Build numbers are the other trap (Apple refuses a repeated one), so each build asks App Store Connect for the latest and adds one.

Step 5: screenshots and the listing, all from Windows

Apple wants iPhone screenshots at the 6.9 inch size, 1320 by 2868 pixels (see the screenshot specifications). Without a Mac you can't click around a Simulator, so each app takes a launch argument that opens a given screen with demo data, and the Build workflow loops through them on a fresh simulator with the status bar set to the classic 9:41 and a full battery. A fresh simulator matters: booting the runner's default one hung for over 15 minutes once.

The raw PNGs come back as a build artifact, a script on Windows puts a headline on each one, and the finished set is committed to fastlane/screenshots/. Chain's in-app purchase code even knows about it: in demo mode the store screenshots show the whole app unlocked, and the paywall shot shows it locked.

Four of Chain's App Store screenshots: Five seconds a day, Watch your year fill in, Missed a day? Save the chain, and Know your best days, each showing the dark app UI with lime green habit squares.
Four of Chain's ten App Store screenshots: raw captures from the CI simulator, headlines added by a script on Windows.

The listing text lives in plain files (fastlane/metadata/en-US/name.txt, subtitle.txt, description.txt, keywords.txt and so on), so it's reviewed and versioned like code. A second script pushes the rest of the listing through the API. Its header says what it does and the one thing Apple won't let it do (Store/listing.py):

"""The rest of the listing: age rating, review details, screenshots.

Everything here needs the app record to already exist, which is the one
step Apple's API refuses to do (`POST /v1/apps` returns 403 by design, so an app
record has to be created once in the web UI).

Usage:
    python Store/listing.py age-rating
    python Store/listing.py review-details
    python Store/listing.py screenshots
    python Store/listing.py finish        # everything that needs no binary
"""

Write the review notes like a test script for a stranger. Chain's notes tell the reviewer exactly how to reach each paid feature on a fresh install ("add three habits, then tap New habit again"), what the Health permission reads and why, and that no login is needed. Apple's 2.1 "Information Needed" responses were the second most common reason in my data, and most of them were answered by notes like these plus a video.

Step 6: testing when you can't hold the iPhone

You can't plug a phone into the CI runner, so testing happens in three layers.

  1. A build on every push. The Build workflow compiles for the Simulator and walks every screen to capture screenshots, so a screen that crashes fails the push. Across the 21 repos it ran 165 times: 101 green, 19 red, 45 cancelled. Only the first three apps also run an XCTest suite in CI. That's thinner than it should be; if I started again, the plain-Swift logic would get tests on every push.
  2. A recorded walkthrough. 20 of the 21 apps have a -demoAutoplay launch argument that clicks through the main flow by itself. A third workflow records it with simctl, and the video goes to App Review when they ask how something works. It ran 18 times, 18 green.
  3. TestFlight. Every dry_run upload lands in TestFlight, so anyone with an iPhone can install the real signed build before it is submitted.

The recording step is short enough to copy (.github/workflows/review-video.yml, trimmed):

          rt=$(xcrun simctl list runtimes | grep -oE "com.apple.CoreSimulator.SimRuntime.iOS-[0-9-]+" | sort -V | tail -1)
          udid=$(xcrun simctl create review "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro-Max" "$rt")
          xcrun simctl boot "$udid" || true
          xcrun simctl status_bar "$udid" override --time "9:41" --batteryState charged --batteryLevel 100 || true
          xcrun simctl install "$udid" "$app"

          xcrun simctl io "$udid" recordVideo --codec h264 --force capture/review.mp4 &
          rec=$!
          xcrun simctl launch "$udid" com.mattbusel.chainhabits -demoAutoplay
          data=$(xcrun simctl get_app_container "$udid" com.mattbusel.chainhabits data)
          for i in $(seq 1 600); do
            if [ -f "$data/Documents/demo_done" ]; then break; fi
            sleep 0.5
          done
          kill -INT $rec

The trick is the demo_done file: the app writes it when the walkthrough ends, so the recording stops at the right frame instead of after a guessed sleep. A recording run averaged about 8 minutes (148 minutes over 18 runs), which is why it lives in its own workflow and only runs when a review needs one.

A word on what this doesn't catch. The Simulator is not an iPhone: no real camera, no real motion sensors, no real HealthKit data. Minder's eyes react to the phone being picked up (Core Motion), and no Simulator run can exercise that; it needs a real device through TestFlight. If your app leans on hardware, budget for a cheap used iPhone or a friend with one. My bet is that this is the one place a Windows-only setup genuinely costs you something.

Step 7: in-app purchases with StoreKit 2

All 8 live apps are free with optional one-time purchases. The purchase code is plain StoreKit 2 and compiles on the runner like anything else. The part worth copying is how Chain decides what the user owns (Sources/Purchases.swift, trimmed):

    func refreshEntitlement() async {
        var has: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result, t.revocationDate == nil, t.productType == .nonConsumable { has.insert(t.productID) }
        }
        // currentEntitlements works offline from StoreKit's own cache, so this is the truth,
        // including a refund taking an unlock away.
        owned = has
        publish()
    }

Transaction.currentEntitlements is the source of truth, refunds included, so there is no server and no receipt parsing. When Chain went from paid to free, people who had paid were grandfathered by reading AppTransaction, which says which build a person first downloaded:

    func checkPaidEra() async {
        guard !grandfathered, let result = try? await AppTransaction.shared,
              case .verified(let app) = result, app.environment == .production else { return }
        let build = Int(app.originalAppVersion.split(separator: ".").first ?? "") ?? Int.max
        if build < Purchases.firstFreeBuild && app.originalPurchaseDate < Purchases.paidCutoff {
            grandfathered = true
            UserDefaults.standard.set(true, forKey: "chain.grandfathered")
            publish()
        }
    }

The app.environment == .production guard matters: in the sandbox and in App Review, everyone looks like an original buyer, and the reviewer has to see the real paywall.

The purchases themselves are created from Windows through the API, with one exception that cost an evening. From the header of Store/iap.py:

Apple's API refuses the FIRST non-consumable of an app (STATE_ERROR.FIRST_NON_CONSUMABLE_MUST_BE_SUBMITTED_ON_VERSION),
even with the version already in an open review submission. For that one, attach it by hand on the version page
(In-App Purchases and Subscriptions, Select, tick it, Done), then Add for Review and Submit. Later purchases
can go through `submit`.

So the first purchase rides along with an app version, by hand, once. After that, purchases review fast: the 5 purchase-only submissions in my data were decided in a median of 12.2 hours, all approved. That's the quickest thing Apple reviews.

The errors you will see, and what each one really means

Apple's error messages are written for someone sitting at Xcode. On a CI runner they point the wrong way more often than not. These are the real ones from the 21 repos' logs and workflow comments, in roughly the order a new pipeline meets them.

The messageWhat it really meansThe fix
Selecting /Applications/Xcode_26.app fails, though ls lists itThe runner image has a stub Xcode_26.app with no toolchain insidePick the newest Xcode 26 that has Contents/Developer (snippet above)
"Unable to find a device matching the provided destination specifier"Not your project. This runner doesn't carry that simulator todayChoose the newest available iPhone at runtime
"Your team has no devices from which to generate a provisioning profile"Automatic signing wants a development profile, which needs a registered iPhone, even for an App Store buildManual signing with an App Store profile
"Could not find the newly generated certificate installed"CI just created a certificate whose private key dies with the runnerMake the certificate once on your PC; revoke the orphans
"MAC verification failed during PKCS12 import (wrong password?)"The password is fine. The .p12 uses an encryption format the macOS security tool won't readExport the .p12 in the legacy format
A signing step that never endscodesign is waiting on a keychain permission dialog nobody can clicksecurity set-key-partition-list after the import
"You can't enable both :development and :adhoc"fastlane counts a flag that is present as chosen, even when set to falseLeave those arguments out entirely
Cannot find "MyApp.xcodeproj/../YES"increment_build_number drives Apple's agvtool, which gets confused by a generated projectPass CURRENT_PROJECT_VERSION as a build setting: latest TestFlight build + 1
Simulator boot hangs for 15+ minutesThe runner's default simulator is in a bad stateCreate a fresh device with simctl create per job
403 on POST /v1/appsThe API does not create app recordsMake the record once in the website; script everything else
STATE_ERROR.FIRST_NON_CONSUMABLE_MUST_BE_SUBMITTED_ON_VERSIONAn app's first one-time purchase has to ride along with a versionAttach it by hand on the version page once; later purchases go through the API

Eleven messages, and every one of them cost at least one run before it was understood, most of them in the first two days. If you hit one that isn't on this list, the jobs API trick from the data section tells you exactly which step it came from and how long it ran before it died, which is usually enough to tell a configuration problem (fails in seconds) from a hang (fails at the timeout).

The learning curve: 13 failures, then zero

The pipeline wasn't good on day one. The first two apps, Glyphstorm and Clear the Strait, ate 13 failed App Store runs each, most of them on September 10. Pocket Beings, a day later, had 5. By the September 24 batch it was mostly zero, and the last ten apps shipped with no failed App Store run at all. Climbbook, Cutoff, Parked and Race Pace each went to App Store Connect on their first try on September 27.

Bar chart of failed App Store workflow runs per app in launch order: Glyphstorm 13, Clear the Strait 13, Pocket Beings 5, Minder 3, Fairway Ledger 2, Baseline Ledger 2, Ironbook 2, Race Fuel 1, Chain 1, Quiver 0, Rooms 1, then Clockout, Odometer, Curve, Pricebook, Pawprint, Chores, Climbbook, Cutoff, Parked and Race Pace all 0.
Failed App Store workflow runs per app, apps in order of their first run. GitHub Actions API, pulled Oct 16 2026 (the Oct 18 pull is the same, 43 failures in all).

Nothing clever happened in between. Every failure became a comment in the workflow file, and the next app started from that file. That's the whole method, and it's why the data matters more than the code: the failures are concentrated in the first two days, so if you copy a pipeline that has already eaten them, you skip that part. Since the start, 139 App Store runs: 81 succeeded, 43 failed, 15 cancelled. The last two failures were version bumps on the evening of October 8 (Eastern); Pocket Beings went green two minutes after its failure.

A practical warning if a coding agent drives this loop for you: an agent fixing signing at 6 AM will happily "fix" the check instead of the problem. The guardrails I use are in stop coding agents editing tests.

App Review is the real bottleneck

Here's what the timeline at the top of this page shows when you look at it as a funnel. Every one of the 21 apps got a green, signed build and went to App Review. Thirteen are still not on the store.

Funnel of 21 iPhone apps: 21 app repos on GitHub written on a Windows PC, 21 with a green App Store build in CI, 21 submitted to App Review with 64 decisions, 8 approved with 0 of 21 on the first try, 8 live on the US App Store.
Every app built Sep 10 to Sep 27 2026, status on Oct 18 2026. GitHub API, App Review emails, iTunes lookup.

The build half is fast and boring now. The first commit to first submission took a median of 2.2 hours, and 13 of the 21 apps were submitted within 3 hours of their first commit (the others waited on purpose, for batch submissions). Then Apple's half starts, and it is measured in days.

From the App Store review time data: 64 decisions, median wait 73 hours, against Apple's stated 50% within 24 hours and 90% within 48. Updates to apps already live took a median of 64 hours. New apps that got approved took a median of 70 hours on the round that approved them, and none of the 21 was approved on its first submission.

Dot plot of 64 App Review decisions by hours from submission to decision email. In-app purchases only: 5 decisions, median 12 hours, all approved. App updates: 16, median 64 hours. New apps: 21, median 51 hours, a mix of fast rejections under a day and approvals at 2 to 5 days. New apps in the two guideline 4.3 spam batches: 22, median 188 hours, all rejected at 7 to 9.3 days. Lines mark Apple's stated 24 and 48 hour targets.
Every App Review decision for the 21 apps, Sep 10 to Oct 12 2026, from Apple's own emails (pulled Oct 17 2026; no new decisions as of Oct 18).

Plan for it like this: the first submission of a new app on a new account will probably come back with a question (guideline 2.1, "Information Needed"). Answer it with a short screen recording and clear review notes, and the second round is where approval usually lands. For the 8 apps that made it, first submission to approval took a median of 4.4 days, and first commit to approval 5.3 days.

Guideline 4.3: what happens when shipping gets too cheap

The 13 apps that aren't live are mostly one story. On October 5, Apple rejected 11 apps in 30 seconds under guideline 4.3 (spam) and 4.2.6, after they had waited 8.7 to 9.3 days. I replied to each and resubmitted; on October 12, all 11 came back rejected again, in another 30 second burst, exactly 7 days later.

Two columns of email timestamps. Monday Oct 5 2026: 11 rejection emails from 5:29:32 to 5:30:02 PM Eastern, each app having waited 8.7 to 9.3 days. Monday Oct 12: 11 more from 6:04:46 to 6:05:13 PM, each exactly 7.0 days after resubmission.
The two guideline 4.3 batches as they landed in the inbox, Eastern time.

Look at which apps those are: the September 24 to 27 batches, the ones where the pipeline had zero failed runs. That's the plot twist of the whole project. Once shipping an app costs nothing, the question Apple asks changes from "does it work" to "is this different enough from everything else this account and the store already have". Guideline 4.3 is written for that: it covers near-copies of the same app and piling into categories that are already saturated.

My read, which I can't prove: a new account sending eleven utility apps in three days looks like a template farm to a reviewer, whatever is inside each binary. If I did it again, I'd ship two or three apps, let them pick up real users, and space the rest out. The pipeline makes speed free. App Review charges for it anyway.

What it costs

The full breakdown, with a calculator, is in iPhone app development cost. The short version for the Windows route:

ItemWhat I paidIf the repos were private
Apple Developer Program$99 a year$99 a year
A Mac$0$0
CI: 322 macOS runs, about 2,572 minutes$0 (public repos)about $159 at $0.062 a minute
One release today (4.1 min median)$0about 25 cents
Apple's cut of sales15% under the Small Business Program
Animated list of what 20 iPhone apps cost, one line at a time: Apple Developer Program 99 dollars a year, Mac 0 dollars via GitHub Actions, CI 0 dollars for 2,416 minutes and 333 runs (149.82 dollars if private), Swift 56,030 lines in 69.8 commit-hours by coding agents, store fee 15 percent, quote for the same apps 30.5 thousand dollars and up each, apps Apple approved 8 of 20.
Every cost line for 20 of the apps, from the iPhone app development cost article (git, GitHub Actions API, iTunes lookup, pulled Oct 17 2026 at 1:12 PM ET; line counts from the local working copies).

The cost that doesn't show up in that table is the one in the funnel: an approved listing. Money can't buy you past guideline 4.3, only a different-enough product can.

What came out the other end

Eight apps on the US App Store as of this afternoon, all free with optional purchases. Every one of them is under 3 MB, because SwiftUI apps with no third-party SDKs are small: Clear the Strait, a game, is 0.6 MB. Chain, with widgets, alternate icons and eight in-app purchases, is 2.9 MB.

Table of the 8 live apps: Clear the Strait live since Sep 16, version 1.1.1, 0.6 MB, 3,557 Swift lines, 6.1 days from first commit to approval; Pocket Beings Sep 20, 1.1, 1.0 MB, 2,089 lines, 9.4 days; Fairway Ledger Sep 22, 1.1, 2.0 MB, 4,082 lines, 4.3 days; Baseline Ledger Sep 24, 1.1, 2.0 MB, 2,861 lines, 6.2 days; Ironbook Sep 24, 1.1, 1.2 MB, 2,969 lines, 3.8 days; Quiver Sep 24, 1.1, 1.2 MB, 2,153 lines, 3.8 days; Minder Sep 25, 1.2, 2.4 MB, 3,379 lines, 7.3 days; Chain Sep 25, 1.2, 2.9 MB, 2,967 lines, 4.5 days.
iTunes lookup API (US) for version, release date and file size; non-blank Swift lines in each public GitHub repo; approval times from App Review emails. Oct 18 2026.

None of these were written in Xcode, and none of them know it. If you're curious what a SwiftUI app built this way looks like from the inside, Minder's cartoon eyes are a good example: springs, saccades and blinks in a plain SwiftUI Canvas, compiled on a machine nobody ever saw.

A 7-day plan to ship your first iOS app from Windows

This is the order that would have saved the most runs, with the numbers above as a guide to how long each part really takes.

  1. Day 1: accounts. Join the Apple Developer Program. Create an App Store Connect API key under Users and Access, Integrations, and store the .p8, key ID and issuer ID as GitHub secrets. Never commit them.
  2. Day 2: the empty app. A public GitHub repo with project.yml, a "Hello" SwiftUI view, and the App Store workflow in compile mode. Get one green run. Expect the Xcode and simulator traps; use the snippets above.
  3. Day 2: signing. Make the certificate on Windows through the API, save it as a .p12 in a secret, and import it on the runner with the partition list line. Run dry_run until a build appears in TestFlight. If CI ever made certificates for you, revoke the orphans.
  4. Days 3 to 5: the app. Write it. Keep logic in plain Swift files and views thin. Add a -shot launch argument per screen and a -demoAutoplay walkthrough as you go, not at the end.
  5. Day 5: the listing. Create the app record in the website (the one manual form), then push name, subtitle, description, keywords, age rating, privacy URL and review notes from the metadata files.
  6. Day 6: screenshots. Capture them once in the Build workflow, add headlines on Windows, commit them. Never re-capture in the release lane: that's the 12 minute vs 4 minute difference.
  7. Day 6: submit. release mode. Then wait about three days, answer the first question with a video, and expect approval on round two.

If you want the files rather than the instructions, the whole thing (the workflow with every trap commented, the Fastfile, the signing script, the App Store Connect scripts and an agent skill that walks through it) is what the No-Mac iOS Pipeline setup installs on your repo.

FAQ: iOS development on Windows

Can I install Xcode on Windows?

No. Xcode is macOS only, and there is no Windows version. What you can do is run Xcode's command-line tools on a rented Mac (a CI runner) and drive it from Windows, which is what this whole guide does.

Can I run the iOS Simulator on Windows?

Not locally. The Simulator ships with Xcode. On the runner it works fine without a screen: simctl boots devices, installs builds, takes screenshots and records video, as in the review-video step above. You see the results as files, not as a live window.

Do I need an iPhone?

Not to build, sign, upload or get approved: none of the 21 apps needed a registered device, once signing was manual. You do want one for anything that touches real hardware (camera, motion, HealthKit, haptics), through TestFlight.

What about Flutter or React Native?

Both still need macOS to build the iOS app. Flutter's iOS setup starts with installing Xcode. Expo's EAS Build is the exception that proves the rule: it runs the build on Expo's own Mac fleet. Either way, someone's Mac compiles it; the question is only whose.

Is running macOS in a virtual machine on my PC allowed?

Apple's software license limits macOS to Apple-branded hardware. I didn't go that way, and with free macOS runners for public repos there's little reason to.

How long does it take, start to finish?

In my data, the build side takes hours (median 2.2 hours from first commit to first submission) and the review side takes days (median 4.4 days from first submission to approval for the apps that got approved). A realistic first app is about a week, most of it waiting.

Does this work for iPad, widgets and in-app purchases?

Yes for widgets and purchases: Chain has a WidgetKit extension and eight purchases, all built the same way. The 21 apps are iPhone only (TARGETED_DEVICE_FAMILY: "1"), so iPad is untested here, but it's the same toolchain.

The numbers

WhatNumberSource, pulled Oct 18 2026
Apps built and submitted from Windows21GitHub repos, App Review emails
Live on the US App Store8iTunes lookup API
Swift, all apps (non-blank lines)53,932 (median 2,308)shallow clones
CI runs, all workflows384GitHub Actions API
App Store runs: ok / failed / cancelled81 / 43 / 15GitHub Actions API
Median successful release run, then vs now12.0 min to 4.1 minjobs API, 81 runs
Share of Mac time spent on screenshots34%jobs API, step timings
Median first commit to first submission2.2 hoursgit + App Review emails
Median first submission to approval4.4 days (8 apps)App Review emails
Approved on the first try0 of 21App Review emails
Rejected under 4.3 (twice)11App Review emails

What's next

The 11 apps stuck on guideline 4.3 are the open problem, and they're a product problem, not a pipeline one. The next experiment is the opposite of the September sprint: fewer apps, each with something no other app in its category does, spaced out over weeks. I'll add the result here.

The question I'd like answered in the comments: if you ship iOS apps without a Mac, what was the one step you were sure needed one? Mine was signing, and it turned out to be the fastest step in the pipeline.

If you'd rather not spend your own September 10 on the signing traps, the No-Mac iOS Pipeline puts this exact setup on your repo, and if you want the app itself built, the fixed prices are here.

First published here on tensorust.com on Oct 18, 2026. Also on dev.to. Related: Free iPhone app cost calculator, Build an iOS app on Windows, App Store review time, Fixed-price iPhone app builds.

Get the next one by email.

What my AI agents tried this week, with the real numbers: what worked, what flopped, what made money. One email when there is something worth reading.

No spam, never sold. Every email has a one-click unsubscribe.

Read next

Free tool: iPhone app cost calculator → Pick an app type and features, get an honest budget range.

All articles →

Get the receipts by email.

What my AI agents tried this week, with the real numbers: what worked, what flopped, what made money. One email when there is something worth reading.

No spam, never sold. Every email has a one-click unsubscribe.

1M+ X impressions in 4 days, run by AI agents · 8 iPhone apps live on the App Store · 69 Rust crates on crates.io · 520 GitHub stars · O'BLOCK on Steam, free Oct 20

Built by Matthew Busel · code on github.com/Mattbusel · numbers as of Oct 18, 2026, click any to check

Popular in the shop

All 124 tools →

Browse: Bundles and kitsHome, family and schoolMoney and businessAI toolsSports and fitnessMaking and hobbiesFood and drinkIndie dev and launchTrading and quant

Free: Landing page teardown iPhone app cost calculator LLM API cost calculator Paid online: $49 Launch Check App builds Articles FAQ