← All writing

git push is the release button

I have nine store listings spread across four repos. There’s Dink+, three flavors of Coach+ (pickleball, tennis, and the IPTPA edition), and Pickleball Journal on iOS and Android, spread across three Apple developer accounts and two Play accounts.

Until recently, releasing any of them meant the same ritual. Bump the version in three files, archive in Xcode, upload, wait for App Store Connect to process the build, create the version, attach the build, paste in the notes, and submit. Then open Play Console and do the Android version of the same thing. It’s about an hour if nothing goes wrong, and something usually goes wrong.

It’s now one command and a git push.

The goal

From the design doc:

git push --follow-tags is the only release action. Everything after (build, sign, upload, create the store version, attach the build, submit, auto-release on approval) happens without a human, for all nine listings, from one shared implementation.

That last clause mattered the most. Four repos with four vendored copies of a pipeline would drift within a month. So there’s a single private repo called mobile-release that holds a reusable GitHub Actions workflow, a config-driven Fastfile, and a small CLI. Each app repo carries a caller workflow of about 15 lines and a release.config.json file that says where its Xcode project and Android directory live. That’s the entire integration.

Decisions

I went with fastlane as the engine. Every hosted release product is either a wrapper around fastlane or a wrapper around the same App Store Connect and Play APIs it uses, so it made sense to go straight to the source.

The builds run on a self-hosted Mac runner for both platforms. iOS can’t be built anywhere but macOS, hosted macOS minutes cost about ten times what Linux minutes cost, and the laptop already has Xcode, Java, the Android SDK, and warm caches. So it runs on my MacBook Pro for now and will move to a Mac mini. iOS and Android build in parallel.

A tag push is the trigger, and it’s fully hands-off after that. The pipeline submits for review automatically, auto-releases on approval, and pushes Play to the production track at 100%. There’s no “click to promote” step, because a step I have to click is a step I’ll forget about for three days.

Release notes are a file in the repo, written before the tag exists. The CLI drafts them and opens them in your editor so you can cut anything that’s behind a feature flag, and the pipeline refuses to run if the file is missing. The draft comes from claude -p reading the merged PRs’ titles and bodies and writing in the voice of the previous release’s notes. It produces a detailed changelog for the repo, then short marketing-voice sections for the App Store and Google Play, with Play’s kept under 500 characters. The whole thing takes about 40 seconds, and if claude isn’t on the path you get a plain list of PR titles to rewrite by hand.

The multi-app repo uses per-app tags, so pickleball-v3.5.3, tennis-v3.5.3, and iptpa-v1.0.0 each get independent versions, notes, and submissions.

The guard

The part I’m most pleased with is also the smallest. Before any build starts, a preflight script checks the tag against the config, confirms the notes file exists and has a body, verifies that the version files agree with each other, and checks that everything the build needs is actually present on the runner. It fails in seconds.

Every one of those checks catches a mistake that would otherwise fail ten minutes in, after a full pod install and Xcode archive. A pipeline you can trust is mostly a pipeline that fails early and tells you why.

The rule in bold

The README has one line in bold:

A tag push is a production release, including force-pushing an existing tag.

Once a repo’s trigger is on, you never move a v* tag to fix something. You delete it and cut a new build number. The stores won’t accept the same build number twice, which means a dry run uses up the number it uploads and the real release afterwards needs --build N+1. That’s worth it for a repo’s first trip through the pipeline. After that, the pipeline is the test.

What the field corrected

The design was reviewed before I built it, and it still lost three small arguments with reality in the first 24 hours.

Passing export_xcargs separately broke the export, because fastlane’s gym already applies your xcargs to the export step. Deleting one line fixed it.

The dirty-tree check was refusing to release because of untracked files, so it now ignores them.

The runner needed its own deploy key for the tooling clone, so that the app repos don’t need a token just to fetch a private workflow’s helper scripts.

None of these were in the spec, and all of them are in the docs now under a section about deviations from the design. That’s how infrastructure usually goes. You design it, you get it about 90% right, and the last 10% is a list of things Apple didn’t mention.

First releases

The first releases through the pipeline were Dink+ and Coach+ pickleball. The other apps are adopted and will go through it as their next releases come up.

End to end it’s about 15 minutes from git push to submitted, and most of that is App Store Connect processing the build.

I don’t think the automation is the interesting part. fastlane has been around for a decade. The interesting part was getting one implementation to serve nine listings across three accounts without any of them becoming a special case. The config file is the whole abstraction, and everything else is a lane.