← alst.es

I Ship an iOS App From My Phone

A phone-only development loop using Claude Code, GitHub Actions, and TestFlight.

I’ve been building an iOS game — The AI Snake, a Curve Fever–style snake game where an on-device AI gives your snake a personality and it reacts to everything you do with spoken quips. Twenty-five personas. Procedurally synthesized music, no audio files. Apple’s FoundationModels framework running entirely on-device. It’s been on the App Store since August.

Here’s the part I find more interesting than the app itself: the entire development loop runs on my phone. No Xcode, no local toolchain, no laptop open — just an iPhone, a few apps, and a CI pipeline that does the heavy lifting.

One honest caveat before the good part: I needed a Mac exactly once, to export a signing certificate. It’s a one-time step, it’s unavoidable for native iOS distribution, and I come back to it below. Everything after it happens on the phone.

This is how the loop works.


The Four-Step Loop

Code → Review & Merge → Build → Test
  📱          📱           ☁️      📱

Every iteration is:

  1. Code — Chat with Claude Code in the Claude iOS app. It writes changes on a branch and opens the PR when done.
  2. Review & merge — Read the diff and merge from the GitHub iOS app.
  3. Build — Trigger the TestFlight GitHub Action from the GitHub app’s Actions tab. It runs on a macOS 26 runner with Xcode 26 and uploads to TestFlight.
  4. Test — Install the new build from TestFlight and play.

Nothing here is novel on its own. CI that builds iOS apps is a solved problem, and plenty of people trigger builds from their phone occasionally. What’s different is that every step is phone-native, so there’s no step that quietly requires a desk. A loop is only as portable as its least portable link.


Step 1: Coding from the Claude iOS App

I use Claude Code — Anthropic’s agentic coding tool — via the Claude iOS app. It has full access to the repository: it can read files, run searches, write code, and commit.

In practice I describe what I want — “add a Zen Snake persona that speaks in haiku, uses Sandy’s voice, and eats 🍵 matcha” — and Claude does it. It reads the existing personas in Persona.swift, writes the new one following the same pattern, and opens a pull request on a feature branch.

What makes this a primary workflow rather than a party trick:

  • Claude works on a branch. Changes are isolated, and main is never what’s being experimented on.
  • The PR is the review artifact. GitHub renders Swift diffs clearly enough to catch obvious mistakes on a phone screen.
  • The mechanical work is the bulk of the work. Wiring a new persona into PersonaStore.all, threading its colors through ArenaBackground.swift, checking a voice identifier is valid — that’s most of the labour of adding a feature like this, and none of it is the interesting part.

The judgement calls stay with me: how a system prompt should be worded, which voice fits a character, whether a food pool makes sense. That division is the actual reason this works. If the phone-sized half of the job were the design and the desk-sized half were the typing, none of this would help.


Step 2: Review and Merge on GitHub for iOS

The GitHub iOS app handles PR review well enough. I read the diff, comment on anything suspicious, and merge.

One discipline this enforces, not entirely by choice: small, focused PRs. You really don’t want to review an 800-line diff on a phone. Asking for one change per branch is something I’d nod along to on a laptop and ignore; here the screen enforces it.

I won’t pretend the review is as good as it would be at a desk. It’s good enough to catch the things that matter in a project this size, and I’ve made my peace with catching the rest in TestFlight.


Step 3: The CI Pipeline That Makes It All Possible

The GitHub Action does everything a Mac sitting next to me would do.

name: TestFlight

on:
  workflow_dispatch:
  push:
    tags:
      - "v*"

jobs:
  testflight:
    name: Build and upload to TestFlight
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v5

      - name: Select latest Xcode 26
        run: sudo xcode-select -s "$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)"

      - uses: ruby/setup-ruby@v1
        with:
          # The runners' default Ruby 3.1 is too old for a current fastlane
          # lockfile (public_suffix needs >= 3.2). Leave this out and the
          # build fails during bundle install, not during the Xcode step.
          ruby-version: "3.4"
          bundler: latest
          bundler-cache: true

      - name: Build and upload
        run: bundle exec fastlane ios beta
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
          IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
          IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
          IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
          IOS_PROFILE_NAME: ${{ secrets.IOS_PROFILE_NAME }}

It triggers on workflow_dispatch or a v* tag push. From the GitHub iOS app the manual trigger is about three taps: Actions tab → TestFlight → Run workflow.

What fastlane does: builds with xcodebuild, signs with the distribution certificate and provisioning profile decoded from secrets, asks the App Store Connect API what the last TestFlight build number was and adds one, then uploads the IPA.

That build-number detail is worth stealing on its own, and it’s four lines:

# Ask App Store Connect what the last build number was, rather than
# tracking it in the repo.
build_number = latest_testflight_build_number(
  api_key: api_key,
  app_identifier: "com.example.myapp",
  initial_build_number: 0
) + 1

build_app(
  project: "MyApp.xcodeproj",
  scheme: "MyApp",
  configuration: "Release",
  export_method: "app-store",
  # Injected at build time, so the number lands in the binary without
  # touching the project file.
  xcargs: "CURRENT_PROJECT_VERSION=#{build_number}"
)

Nothing in the repo tracks the build number, so there’s no file to bump, no merge conflict when two branches both bump it, and no way to accidentally upload a duplicate. The source of truth is the place that actually knows.

The secrets setup (one-time, from a Mac)

Here’s the Mac. You need Keychain Access to export the distribution certificate as a .p12, and there is no way around it on iOS. Once these seven secrets are in GitHub, that’s the end of it.

SecretWhat it is
ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENTApp Store Connect API key (Key ID, Issuer UUID, .p8 file base64-encoded)
IOS_DIST_CERT_BASE64Apple Distribution certificate exported as .p12, base64-encoded
IOS_DIST_CERT_PASSWORDPassword for that .p12
IOS_PROFILE_BASE64App Store provisioning profile, base64-encoded
IOS_PROFILE_NAMEProfile name exactly as shown in the developer portal

The base64 encoding is only so binary files survive as environment variables:

base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy   # → ASC_KEY_CONTENT
base64 -i dist.p12 | pbcopy               # → IOS_DIST_CERT_BASE64
base64 -i appstore.mobileprovision | pbcopy  # → IOS_PROFILE_BASE64

Two things I’d tell my past self. The .p8 key can be downloaded exactly once, so put it somewhere durable before you encode it. And the certificate expires after a year — which means this “one-time” Mac step is really an annual one, and it will come due on a day when you have no Mac nearby and no memory of having done it.


Step 4: Test on TestFlight

The build lands in TestFlight 15–20 minutes after I tap the trigger. I get a notification, tap Update, and play. When I find something wrong I open Claude in the same moment, because I’m already holding the phone — and describing a bug while it’s still on screen is a meaningfully better bug report than one reconstructed at a desk two hours later.


What This Loop Feels Like

End to end, a change takes roughly an hour when nothing goes wrong: describe it, review the PR, trigger the build, wait for processing, install, play. Almost none of that is compute — the build itself is under three minutes, and TestFlight processing is 15 to 20. The rest is me.

That is dramatically slower than editing locally and hitting Run, and I want to be straight about it — if you’re iterating on a physics tweak that needs twenty attempts, this workflow is miserable and you should use a Mac.

What it’s good at is a different shape of work: changes that are well-specified before they start. Adding a persona, wiring up a store product, fixing a bug I can describe precisely. For those, most of that hour is unattended — which turns out to matter more than the total.

The clearest example is my commute. I describe a change as the train pulls out, review the PR a few stops later, and trigger the build before I get off. By the time I’m walking out of the station, TestFlight has the build waiting. The loop’s latency, which is its worst property at a desk, is invisible here — it’s running during time I couldn’t have used anyway.

A laptop can’t really claim that time. On a rush-hour train there’s no table, sometimes no seat, and the machine takes a minute to wake and reconnect before it’s useful at all. The phone is already out and already signed in. Two commutes a day is something like an hour of development that otherwise wouldn’t exist, and the honest alternative to shipping those features on a train was never “ship them faster at a desk” — it was “don’t ship them.”


What It Costs

Less than I expected, and not where I expected.

I assumed macOS CI would be the expensive part, and the rate card backs that up: GitHub charges $0.062/min for a macOS runner against $0.006 for a Linux 2-core — about ten times as much per minute, for a job that is inherently slower than a Linux one. Minutes are also rounded up to the whole minute, which hurts short jobs disproportionately. That arithmetic is why everyone tells you iOS CI is costly.

Then I measured mine. The average build takes 2.7 minutes — thirteen successful runs, ranging 2.0 to 3.9. I run them on a third-party macOS runner at $0.08/min with 3,000 free macOS minutes a month, which works out at roughly 22 cents a build, and about 1,100 builds a month before I pay anything. I have never come close. For a project this size the cost warning is simply wrong, and the switch away from GitHub-hosted runners was a one-line change to runs-on.

The useful conclusion isn’t “CI is cheap,” it’s that CI was never the bottleneck. A 2.7-minute build sitting inside a one-hour loop means the hour is almost entirely Apple’s TestFlight processing — 15 to 20 minutes of it — plus my own reading and reviewing. Optimising the build would buy nothing. That’s worth knowing before you spend an evening tuning caching, as I nearly did.

The real cost is time, and it lands on debugging. Any bug that takes five builds to isolate has eaten most of a day. That pushes you toward reasoning carefully before triggering rather than probing quickly — occasionally a virtue, frequently just slower.


The App Itself

The AI Snake is a Curve Fever–style snake game: hold ◀ or ▶ to rotate continuously rather than turning on a grid. After every event — eating, a near miss, dying — the snake says something out loud. The quips are generated on-device by FoundationModels, with no network call.

Twenty-five personas ship today: Corporate, Classic, Zen, Robot, Boomer, Crypto Bro, Shakespearean, Surfer Dude, a pirate, a wizard, your grandma. Each has a system prompt shaping its voice, a matched AVSpeechSynthesizer voice, procedurally synthesized ambient music, themed colors, and its own food pool. About 7,700 lines of Swift across 19 files, no third-party game frameworks, no audio files, no analytics SDK.


The Honest Caveats

You still need a Mac for the initial setup, and again each year when the certificate expires. The Keychain Access export is the one step I couldn’t do on-device.

Long-form design work is harder on a phone. Sketching a new architecture or reviewing a genuinely complex diff is more comfortable at a desk. I plan at a computer when I have one; I build on the phone.

Build failures mean reading CI logs on a small screen. GitHub’s mobile log viewer is functional but not pleasant for a 400-line Xcode error. What helped most was naming workflow steps precisely, so the failing step name usually tells me what broke before I scroll into the raw log at all.

This scales to a certain project size. The AI Snake compiles fast and has one target. An app with several targets, a deep SPM graph, and a ten-minute build would turn the one-hour loop into a two-hour one, and the whole thing stops being worth it.

Some things genuinely can’t be done from a phone, and I stopped pretending otherwise: Instruments profiling, anything needing the simulator, and inspecting a view hierarchy. When I need those, I need a computer.


Why It’s Worth Trying

The constraint turned out to be a forcing function. Small PRs, tight scope, CI that actually works — all things I’d have skipped on a personal project with a laptop within reach, and all things that made the project better independently of where I was sitting.

The honest pitch isn’t that phone development is better. It’s that the setup cost is a single afternoon, and afterwards the barrier to shipping a small change drops to roughly zero. For a side project, which mostly dies of friction rather than of difficulty, that turns out to matter more than raw iteration speed.


The AI Snake is on the App Store: The AI Snake — a Snake game whose snake talks back, improvising every line on-device. The repo is private, but the part worth copying isn’t the game: the workflow above is the same file this app ships from.