Release Management with GitHub Actions and Stable Branches
Most iOS release pipelines lean on Fastlane. It works, but it adds a Ruby dependency chain, a Gemfile, Bundler version conflicts, and a DSL you have to learn separately from everything else. For a Swift project, that's a lot of incidental complexity.
This post builds a release pipeline entirely with GitHub Actions and shell scripts. The strategy uses stable branches for release cuts, automated merge-back PRs to keep main up to date, and GitHub prereleases for tracking builds. It's the same pattern used by large-scale mobile teams and it works just as well for a single developer.
The companion repo has the complete working code: View Code
The Branching Strategy
The idea is simple:
mainis always the latest development codestable/{app}/{version}branches are cut when you're ready to release (e.g.,stable/landmarks/1.0)- Hotfixes go to the stable branch and merge back to
mainautomatically - Tags mark actual App Store submissions
This gives you a clean separation between "what's being developed" and "what's being shipped." You can keep developing on main while stabilizing a release on the stable branch.
Cutting a Release
When you're ready to release version 1.0:
git checkout main
git pull origin main
git checkout -b stable/landmarks/1.0
git push -u origin stable/landmarks/1.0
That's it. The branch name encodes the app name and version, which the automation will parse.
Hotfixes
If you find a bug during QA, fix it on the stable branch:
git checkout stable/landmarks/1.0
# fix the bug
git commit -m "Fix reservation date parsing for timezone edge case"
git push
The push to the stable branch triggers two automated workflows.
Automated Merge-Back
When you push to a stable branch, you want those changes to flow back to main automatically. Otherwise you end up with fixes that exist in the release but not in development - a recipe for regressions.
name: Release Mergeback
permissions:
contents: write
pull-requests: write
on:
push:
branches:
- stable/**
jobs:
create_pr:
runs-on: ubuntu-latest
steps:
- name: Parse branch name
env:
BRANCH: ${{ github.ref_name }}
id: version
run: |
APP_NAME=$(echo "${BRANCH}" | cut -d'/' -f2)
VERSION=$(echo "${BRANCH}" | cut -d'/' -f3)
echo "app_name=${APP_NAME}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "fragment=${APP_NAME}/${VERSION}" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
- name: Merge stable branch into main
run: |
git fetch origin ${{ github.ref_name }}
git merge origin/${{ github.ref_name }} --no-edit \
-X theirs \
|| {
echo "Auto-merge with -X theirs failed, resolving remaining conflicts..."
git checkout --theirs -- '*.xcconfig'
git add '*.xcconfig'
git -c core.editor=true merge --continue
}
- name: Create pull request
uses: peter-evans/create-pull-request@v7
with:
branch: stable_to_main/${{ steps.version.outputs.fragment }}
title: "Merge ${{ steps.version.outputs.app_name }} ${{ steps.version.outputs.version }} release changes into main"
labels: |
Automated
Ready to merge
body: |
Automated pull request to merge release branch changes back into main.
These changes have already been reviewed on the stable branch.
**Merge using "Create a merge commit" - do not squash.**
A few things to note:
-X theirs for xcconfig conflicts. When you bump the version number on the stable branch, it will conflict with main (which hasn't been bumped). The -X theirs strategy takes the stable branch version. This is safe because main will get its own version bump eventually.
The fallback block. If -X theirs can't auto-resolve everything (maybe a non-xcconfig file also conflicts), we explicitly checkout theirs for xcconfig files and continue. This handles edge cases without human intervention.
PR, not direct push. We create a PR rather than pushing directly to main. This lets CI run on the merge result and gives you a record of what went back.
Automated Prereleases
Every push to a stable branch also creates (or updates) a GitHub prerelease. This gives you a changelog and a reference point for each build:
create_prerelease:
runs-on: ubuntu-latest
steps:
- name: Parse branch name
env:
BRANCH: ${{ github.ref_name }}
id: version
run: |
APP_NAME=$(echo "${BRANCH}" | cut -d'/' -f2)
VERSION=$(echo "${BRANCH}" | cut -d'/' -f3)
echo "app_name=${APP_NAME}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
fetch-tags: true
- name: Create or update prerelease
uses: softprops/action-gh-release@v2
with:
tag_name: pre-release/${{ steps.version.outputs.app_name }}/${{ steps.version.outputs.version }}
name: "${{ steps.version.outputs.app_name }} pre-release ${{ steps.version.outputs.version }}"
body: |
Automated pre-release for **${{ steps.version.outputs.app_name }}** version **${{ steps.version.outputs.version }}**.
**Last updated:** ${{ github.event.head_commit.timestamp }}
**Commit:** ${{ github.sha }}
generate_release_notes: true
draft: false
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
target_commitish: ${{ github.ref_name }}
The generate_release_notes: true flag tells GitHub to auto-generate a changelog from commit messages and merged PRs. This gives QA and stakeholders a human-readable summary of what changed.
Building and Uploading to TestFlight
For the actual build and upload, you have two options:
Option 1: Xcode Cloud. If you're already using Xcode Cloud, trigger a build when the stable branch is pushed. Configure it in Xcode under Product > Xcode Cloud. This is the lowest-friction option because Apple handles code signing.
Option 2: GitHub Actions with a macOS runner. If you want everything in one place:
name: Build and Upload
on:
push:
branches:
- stable/**
paths-ignore:
- '*.md'
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Install signing certificate
env:
CERTIFICATE_BASE64: ${{ secrets.SIGNING_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Install provisioning profile
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
PP_PATH=$RUNNER_TEMP/profile.mobileprovision
echo -n "$PROVISIONING_PROFILE_BASE64" | base64 --decode -o $PP_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
- name: Build archive
run: |
xcodebuild archive \
-project Landmarks.xcodeproj \
-scheme Landmarks \
-archivePath $RUNNER_TEMP/Landmarks.xcarchive \
-destination "generic/platform=iOS" \
CODE_SIGN_STYLE=Manual
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: |
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/Landmarks.xcarchive \
-exportPath $RUNNER_TEMP/export \
-exportOptionsPlist ExportOptions.plist
xcrun altool --upload-app \
-f $RUNNER_TEMP/export/Landmarks.ipa \
-t ios \
--apiKey $APP_STORE_CONNECT_API_KEY
This is more setup than Xcode Cloud, but it gives you full control and keeps everything in GitHub.
The Release Lifecycle
Here's how a typical release flows:
- Cut the branch:
git checkout -b stable/landmarks/1.0frommain - GitHub Actions fires: Creates a prerelease, triggers a build
- QA finds a bug: Fix it on the stable branch, push
- Merge-back PR appears: Automated PR brings the fix to
main - Prerelease updates: The changelog now includes the fix
- Ready to ship: Tag the commit:
git tag v1.0.0 && git push --tags - Create a GitHub Release: Convert the prerelease to a full release
# When ready to ship
git checkout stable/landmarks/1.0
git tag v1.0.0
git push origin v1.0.0
You can add another workflow that triggers on tag pushes to submit to App Store review automatically, but many teams prefer that last step to be manual.
Why Not Fastlane?
Fastlane is mature and feature-rich. But for a Swift project:
- It's a Ruby dependency in a Swift codebase
- Gemfile/Bundler version management adds friction
matchrequires a separate Git repo for certificates- The DSL is another language to learn and debug
- When it breaks, you're debugging Ruby, not Swift
GitHub Actions with xcodebuild directly is more verbose but completely transparent. When something breaks, you're reading shell commands, not Ruby stack traces. And the stable branch pattern gives you release management that Fastlane doesn't handle at all.
What's Next
The app can be built, tested, and shipped automatically. In the final post, we'll add feature flags backed by the Vapor server so you can roll out new features gradually and kill them remotely if something goes wrong.