Lab 10.3 — Full Release Pipeline
Goal: stitch GitHub Actions + Fastlane into one pipeline that runs tests on every PR, ships to TestFlight on every main push, and submits to App Store on every v*.*.* tag.
Time: 120–180 minutes.
Prereqs: Lab 10.1 complete (working Fastfile + match repo), a GitHub repo, App Store Connect API key, paid Apple Developer subscription.
Setup
- Push your
FastlaneLabrepo from Lab 10.1 to GitHub. - Convert the API key to base64 for GitHub secrets:
base64 -i fastlane/AuthKey.p8 -o /tmp/asc_key.b64 pbcopy < /tmp/asc_key.b64 - Generate a fine-grained PAT scoped to your certs repo with Contents: Read.
- Build the basic auth token for match:
echo -n "x-access-token:$YOUR_PAT" | base64
Build (3 GitHub secrets, 3 Fastlane lanes, 1 workflow)
Step 1 — Add GitHub secrets
Repo → Settings → Secrets and variables → Actions → New secret:
| Name | Value |
|---|---|
ASC_KEY_BASE64 | contents of /tmp/asc_key.b64 |
ASC_KEY_ID | e.g. AAAA1111BB |
ASC_ISSUER_ID | e.g. 69a6de70-... |
MATCH_PASSWORD | the passphrase you set in Lab 10.1 |
MATCH_GIT_TOKEN_B64 | the base64 you just produced |
SLACK_WEBHOOK | a Slack incoming webhook URL (or use webhook.site for testing) |
Step 2 — Extend the Fastfile
Replace fastlane/Fastfile with:
fastlane_version "2.220.0"
default_platform :ios
APP_ID = "com.yourname.fastlanelab"
SCHEME = "FastlaneLab"
PROJECT = "FastlaneLab.xcodeproj"
def asc_api_key
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_BASE64"],
is_key_content_base64: true,
in_house: false
)
end
platform :ios do
before_all do
setup_ci if is_ci
end
desc "Run all tests"
lane :test do
run_tests(
project: PROJECT,
scheme: SCHEME,
devices: ["iPhone 16 Pro"],
clean: true
)
end
desc "Sync App Store certs"
lane :certs do
match(type: "appstore", app_identifier: APP_ID, readonly: is_ci)
end
desc "Build and upload to TestFlight (internal)"
lane :beta do
certs
increment_build_number(
xcodeproj: PROJECT,
build_number: ENV["GITHUB_RUN_NUMBER"] || latest_testflight_build_number(api_key: asc_api_key, app_identifier: APP_ID) + 1
)
build_app(
project: PROJECT,
scheme: SCHEME,
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
},
output_directory: "build",
output_name: "FastlaneLab.ipa"
)
upload_to_testflight(
api_key: asc_api_key,
ipa: "build/FastlaneLab.ipa",
skip_waiting_for_build_processing: true,
distribute_external: false
)
notify("📦 Beta #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight")
end
desc "Tag-triggered App Store release"
lane :release do
UI.user_error!("Not on a tag") unless ENV["GITHUB_REF"]&.start_with?("refs/tags/v")
version = ENV["GITHUB_REF"].sub("refs/tags/v", "")
test
certs
increment_version_number(version_number: version)
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])
build_app(
project: PROJECT,
scheme: SCHEME,
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
},
output_directory: "build",
output_name: "FastlaneLab.ipa"
)
upload_to_app_store(
api_key: asc_api_key,
ipa: "build/FastlaneLab.ipa",
skip_screenshots: true,
skip_metadata: false,
force: true,
submit_for_review: true,
automatic_release: true,
phased_release: true,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false
}
)
notify("🚀 v#{version} submitted to App Store")
end
def notify(text)
return unless ENV["SLACK_WEBHOOK"]
sh "curl -s -X POST -H 'Content-Type: application/json' --data '{\"text\":\"#{text}\"}' \"$SLACK_WEBHOOK\""
end
error do |lane, exception|
notify("❌ Lane #{lane} failed: #{exception.message}")
end
end
Step 3 — Write the GitHub Actions workflow
.github/workflows/ios.yml:
name: iOS
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
XCODE_VERSION: "16.0"
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData/**/SourcePackages
~/.swiftpm
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
- run: gem install fastlane -NV --no-document
- run: fastlane test
beta:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- run: bundle install || true
- name: Run beta lane
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane beta
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- name: Run release lane
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane release
Step 4 — Test it
# 1. PR test
git checkout -b test/pr
echo "// touch" >> FastlaneLab/ContentView.swift
git commit -am "test PR pipeline"
git push -u origin test/pr
gh pr create --title "Test PR" --body "Pipeline verification"
# → GitHub Actions runs `test` job only
# 2. Beta
gh pr merge --merge
# → `test` + `beta` jobs run; check Slack + TestFlight
# 3. Release
git tag v1.0.1 -m "First automated release"
git push origin v1.0.1
# → `test` + `release` jobs run; check App Store Connect for submission
Stretch
- Phased release with metadata — set up
fastlane/metadata/en-US/release_notes.txt, etc., removeskip_metadata: true, watch metadata sync alongside the binary. - Reject “main” merges if test fails — set branch protection in GitHub:
mainrequirestestjob to pass before merge. - Approval gate before release — wrap the
releasejob in a GitHub Environment with required reviewers; the workflow pauses until an admin approves. - Auto-generate changelog — replace
release_notes.txtcontent withgit log --pretty=format:'- %s' $(git describe --tags --abbrev=0 HEAD^)..HEAD. - Cost-cut PR runs — add a
paths:filter so thetestjob only runs when*.swiftfiles change.
Notes
- The first
releaselane on a brand new app will fail because App Store Connect requires manual setup of pricing + age rating. Configure those one time in the UI, then automation takes over. - If
matchcomplains about “cert not in keychain” on CI,setup_ciwasn’t called. Confirmbefore_allruns. latest_testflight_build_numbermakes builds idempotent — even if GitHub re-runs a job, the build number stays unique without collisions.- For real apps, add a
notarizestep for Mac apps and avalidate_appstep beforeupload_to_app_storeto catch issues earlier.