Process · CI/CD

A release pipeline for a solo dev

When you ship alone, the release isn't the hard part — remembering all twelve steps of the release at 11pm, with one of them silently wrong, is. After a decade of doing this on teams, here's the pipeline I run now as a company of one, and the parts I deliberately left un-automated.

On a team, a botched release is annoying but survivable — someone notices, someone reverts, the on-call person sighs. Solo, there's no one to notice. If I push a build signed with the wrong profile, or upload a binary with last week's screenshots, the first feedback I get is a rejection email two days later or a one-star review. So my bar for automation isn't "is this clever" — it's "would I get this wrong if I were tired." That covers a surprising amount of the release.

What's actually worth automating

The trap with CI is automating the interesting bits and leaving the error-prone bits manual. The interesting bits — building, running tests — you'd notice if they broke. It's the boring, repetitive, easy-to-fumble steps that bite you. For an App Store release, those are:

  • Bumping the build number. Forget it and the upload bounces. Do it by hand and you'll eventually fat-finger it.
  • Code signing. The single most common way to lose an hour. Wrong cert, expired profile, a teammate (or past you) who regenerated something.
  • Screenshots. Twelve locales × six device sizes is hundreds of images. Nobody does that by hand twice.
  • The upload itself, plus pushing metadata so the listing matches the binary.

Everything there is mechanical, has a correct answer, and punishes mistakes after a delay. That's the exact profile of work to hand to a script. So the first thing I set up on any new product is fastlane.

Code signing without tears: match

Let me start with signing, because it's the thing that drove me to fastlane in the first place. Xcode's "automatically manage signing" is fine until it isn't — until you switch Macs, or a certificate expires mid-release, or you genuinely cannot remember which of three provisioning profiles is the live one. On a team it's worse: everyone regenerates certs and steps on each other.

match fixes this with one idea: store your certificates and profiles in a private, encrypted git repo, and have every machine pull from there instead of minting its own. The certs live in one place, encrypted with a passphrase, and your build machine just checks them out read-only. New laptop? match development and you're signed in a minute. Signing stops being a source of surprise and becomes a checkout step.

One caveat

Run match nuke only when you understand exactly what it revokes — it will invalidate every certificate of that type on your account, and any build or pipeline still relying on the old one breaks immediately. Solo, that's just you; on a team it's everyone. Treat it as a deliberate reset, never a quick fix.

The fastlane lanes I actually use

A lane is a named sequence of steps. I keep two that matter — beta for TestFlight and release for the App Store — plus a private helper they share. The whole point is that a release becomes one command I can run without thinking, which is exactly the state I want to be in at 11pm.

default_platform(:ios)

platform :ios do

  # Shared setup: pull signing assets and bump the build number.
  private_lane :prepare do
    setup_ci if ENV["CI"]                 # keychain handling on CI
    match(type: "appstore", readonly: true)
    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )
  end

  desc "Build and ship a TestFlight beta"
  lane :beta do
    prepare
    build_app(
      scheme: "iRunning",
      export_method: "app-store"
    )
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: File.read("../changelog.txt")
    )
    slack(message: "New iRunning beta is on TestFlight 🏃") if ENV["SLACK_URL"]
  end

  desc "Screenshots, metadata, and App Store submission"
  lane :release do
    capture_screenshots                   # drives the UI test scheme
    prepare
    build_app(scheme: "iRunning", export_method: "app-store")
    deliver(
      submit_for_review: true,
      automatic_release: false,           # I press the button myself
      force: true,                        # skip the HTML preview prompt
      precheck_include_in_app_purchases: false
    )
  end
end

A few things worth pointing out. The build number is derived from latest_testflight_build_number + 1, not from a counter in the repo — that way it's correct even if I cut a build from a different machine, and there's nothing to forget. Signing is readonly: true everywhere; the pipeline consumes certs, it never creates them. And capture_screenshots runs a dedicated UI test scheme that navigates the app and calls snapshot() at each screen, so the same Swift test produces every device size and locale. deliver then pushes those images and the text metadata up alongside the binary, so the listing can never drift out of sync with what I shipped.

Xcode Cloud, the lower-maintenance alternative

Self-hosted fastlane is powerful, but somebody maintains the host — and solo, that somebody is me. A Mac mini under the desk, or a CI runner, means Xcode updates, certificate renewals, the occasional "why did the keychain lock" evening. For a small app that overhead can outweigh the benefit.

Xcode Cloud is Apple's answer: you define a workflow in Xcode — trigger on push to main, build this scheme, run these tests, deliver to TestFlight — and Apple runs it on their machines. No host to babysit, signing handled for you, and a free tier of compute hours that comfortably covers one product. Push a tag, and a beta shows up in TestFlight without my laptop being involved at all.

The trade-off is control. Xcode Cloud is great at the common path and awkward the moment you step off it — bespoke screenshot pipelines, exotic dependencies, anything you'd express as a custom fastlane action becomes a ci_post_clone.sh shell script you debug through the build log. So my rule of thumb: Xcode Cloud for the build-test-TestFlight loop, fastlane for the App Store submission with its screenshots and metadata. They compose fine — Xcode Cloud can even invoke a lane from its post-build script — and you get low maintenance where it's routine and full control where it's fiddly.

The boring release checklist

Automation handles the mechanical steps; a checklist handles the judgement ones. This lives in a text file in the repo, and I genuinely read it top to bottom every release. It's deliberately boring — that's the feature. It's not glamorous, and it has saved me from shipping garbage more than once:

  • Version bumped and the changelog.txt written in human language, not git subjects.
  • Run on a real device, not just the simulator — at least the one flow I changed.
  • Screenshots regenerated if any UI moved (and a glance to confirm no text is clipped).
  • New permission strings or privacy keys present, if I touched anything sensitive.
  • The TestFlight build sat with me for a day before it became a release candidate.
  • App Store Connect: pricing, availability, and the "what's new" text are what I think they are.

None of this needs a tool. It needs me to not skip it, and a list is how I don't.

On not over-engineering

The genuine risk here isn't too little CI — it's too much. It is very easy to spend a fortnight building a beautiful pipeline for an app that ships once a month, complete with parallel test matrices, fancy changelog generation, and a deploy dashboard nobody but you will ever see. That's procrastination wearing a hard hat. Time spent gold-plating the pipeline is time not spent on the product, and the product is the only thing your users feel.

So I keep it proportionate. Two lanes and a checklist will carry a solo product a long way. I don't add a stage until I've felt its absence — until something has actually gone wrong, or a step has annoyed me three times. The pipeline earns its complexity by paying for itself, not by looking impressive in a config file.

That's the whole thing: match so signing never surprises me, a couple of fastlane lanes so a release is one command, Xcode Cloud where I'd rather not own a build machine, and a dull little checklist for the parts that need a human. None of it is sophisticated. The payoff isn't sophistication — it's that the boring, error-prone half of shipping stops eating my evenings, and I get to spend that time on the app, which is the only reason any of this exists.

← All writing