How I Ship iOS Releases

This is the release flow I use for Swift native projects like DoubleMemory. It decouples main from builds so rapid, small PR merges don’t trigger Xcode Cloud, while giving me maximum control over when to build, when to bump versions, and when to cut releases—without merge conflicts and with automatic artifacts and changelogs.

Release flow overview

Goals and constraints

Branch + trigger model

Branch flow

Annotated workflow (full quote with comments)

# .github/workflows/release_sync.yml
name: Release Sync and Version Bump

on:
  release:
    types: [published] # Triggered by publishing a GitHub Release.
  workflow_dispatch: # Manual trigger for maintenance.

permissions:
  contents: write

jobs:
  promote:
    name: Promote main to production and bump version
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Full history needed for fast-forward checks.

      - name: Configure Git user
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Fast-forward production from main
        env:
          MAIN_BRANCH: main
          PRODUCTION_BRANCH: production
        run: |
          set -eo pipefail
          git fetch --prune origin
          if git ls-remote --exit-code origin "$PRODUCTION_BRANCH" >/dev/null 2>&1; then
            git checkout -B "$PRODUCTION_BRANCH" "origin/$PRODUCTION_BRANCH"
          else
            git checkout -B "$PRODUCTION_BRANCH" "origin/$MAIN_BRANCH"
          fi
          git merge --ff-only "origin/$MAIN_BRANCH"
          git push origin "$PRODUCTION_BRANCH:$PRODUCTION_BRANCH"

      - name: Bump MARKETING_VERSION on main
        env:
          MAIN_BRANCH: main
        run: |
          set -eo pipefail
          git checkout -B "$MAIN_BRANCH" "origin/$MAIN_BRANCH"
          swift Scripts/bump_version.swift
          if git status --porcelain | grep .; then
            new_version=$(grep -m1 "MARKETING_VERSION =" DoubleMemory.xcodeproj/project.pbxproj | sed -E 's/.*MARKETING_VERSION = ([0-9.]+);/\1/' | tr -d '[:space:]')
            git commit -am "Bump version to $new_version"
            git push origin "$MAIN_BRANCH:$MAIN_BRANCH"
          else
            echo "No version changes detected; skipping commit."
          fi

The GitHub Release path (preferred)

  1. Create a GitHub Release (auto-tag on release creation).
  2. The release triggers release_sync.yml:
    • Fast-forward production from main.
    • Bump the version on main and push.
  3. Xcode Cloud builds from production.
  4. Artifacts are generated and a clean changelog is attached to the release automatically by GitHub.

Release changelog

This keeps versioning centralized and avoids long-running GitHub Actions builds for every release. If you prefer to avoid clicking in GitHub, this step can be scripted (for example, via a CLI release command) and still uses the same workflow.

The direct production push path (manual build)

Sometimes you need to ship a build to TestFlight without cutting a full GitHub Release (e.g., for internal QA). We use a local script to mimic the action: it pushes the current state to production directly.

Scripts/build_app_store.sh:

#!/usr/bin/env bash

set -euo pipefail

# ... (setup variables)

echo "Fast-forwarding ${PRODUCTION_BRANCH} from ${MAIN_BRANCH}..."
git merge --ff-only "origin/$MAIN_BRANCH"

echo "Pushing ${PRODUCTION_BRANCH} to origin..."
git push origin "$PRODUCTION_BRANCH:$PRODUCTION_BRANCH"

# ...

This triggers Xcode Cloud immediately because of the push to production.

Why this works well for me

Skill definition (Codex format)

If you want this flow packaged as a Codex skill, the definition lives in a SKILL.md with YAML front matter (this is the actual format used by Codex). For the canonical spec and installer usage, see ~/.codex/skills/.system/skill-installer/SKILL.md.

---
name: ios-release-pipeline
description: Sets up a GitHub Release → fast-forward production → version bump flow for Xcode Cloud.
metadata:
  short-description: Release sync + version bump for iOS
---

# iOS Release Pipeline Setup

Create a `.github/workflows/release_sync.yml` workflow that:
- Triggers on `release` (published) and `workflow_dispatch`.
- Fast-forwards `production` from `main` and pushes.
- Bumps `MARKETING_VERSION` on `main` and pushes.
- Then generate `Scripts/build_app_store.sh` to fast-forward `production` from `main` and push for manual builds.

Post-installation steps

On GitHub:

  1. Enable Actions read/write permissions.
  2. Publish a Release to trigger the sync.

On Xcode Cloud:

  1. Start Condition = Branch Changes.
  2. Branch = production.
  3. Optional: add TestFlight Internal Testing.

Please check Thomas Ricouard’s flow for TestFlight via Codex Web for detailed instructions.

Script reference

The full script lives at Scripts/build_app_store.sh. It fast-forwards production from main and pushes to origin, and it should be generated by the skill rather than hidden. For version bumping, your LLM should be able to generate a small script that matches your project layout; if you use Tuist, this becomes much simpler than my original setup because I started DoubleMemory before adopting Tuist.

Final Words

If this post was helpful, I can share the full Swift native project setup in a separate write-up, including topics like how to setup a Swift project by using Tuist, and how to save debug tokens with xcbeautify. You can follow me on X if you want that next: @randomor.

References

This post is inspired by the ongoing discussion about Codex-to-TestFlight flows in those two recent X posts, and special thanks to Paul for the encouragement to write this post as well as adding the skills definition so everyone can easily integrate this flow into their projects.