In the previous post, I set up local deploys with fastlane ios beta to TestFlight.
This time I wired up the same Fastfile so GitHub Actions can deploy automatically.
CI workflow (ios-ci.yml)
On PRs and pushes to main, this runs Dart/Flutter checks and an unsigned iOS build.
name: iOS CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ios-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
dart-checks:
name: Format, Analyze, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: dart format --output=none --set-exit-if-changed lib test
- run: flutter analyze lib test
- run: flutter test
ios-build:
name: iOS Build (unsigned)
runs-on: macos-latest
needs: dart-checks
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Build iOS (no codesign)
run: |
flutter pub get
flutter build ios --release --no-codesignTwo things I cared about here.
dart-checksruns on ubuntu.
Flutter analyze and tests work fine on Linux, so this is faster and cheaper than a macOS runner.ios-buildusesneeds: dart-checks, so the iOS build is skipped when format or tests fail.concurrencywithcancel-in-progress: truecancels stale runs when you push to the same branch repeatedly.
Deploy workflow (ios-testflight.yml)
Pushes to the deploy branch or v* tags trigger a TestFlight deploy.
name: iOS TestFlight
on:
workflow_dispatch:
push:
branches:
- deploy
tags:
- "v*"
permissions:
contents: read
concurrency:
group: ios-testflight
cancel-in-progress: false
jobs:
testflight:
name: Build and upload to TestFlight
runs-on: macos-26
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: 🦋 Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: 📥 Get dependencies
run: flutter pub get
- name: ✅ Run tests
run: flutter test --coverage
- name: 🔨 Pre-build iOS
run: flutter build ios --release --no-codesign
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
working-directory: ios
- name: Deploy to TestFlight
working-directory: ios
env:
CI: "true"
APP_STORE_CONNECT_APPLE_ID: ${{ secrets.APP_STORE_CONNECT_APPLE_ID }}
APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: bundle exec fastlane ios beta
- name: Upload gym logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: gym-logs
path: ~/Library/Logs/gym/
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: app-ipa
path: ios/build/*.ipaA few details worth calling out.
Test locally with env files before GHA
This sounds obvious, but jumping straight into GHA secrets without a working local setup gets messy fast.
I recommend confirming everything works locally with .env files first.
See the local testing guide.
Save logs and IPA as artifacts
On failure, gym logs are uploaded as artifacts; on success, the IPA is saved too. Handy for debugging.
Secrets setup
Register these under Settings → Secrets and variables → Actions in the repo.
| Secret name | Value |
|---|---|
APP_STORE_CONNECT_APPLE_ID | Apple ID email address |
APP_STORE_CONNECT_TEAM_ID | Apple Developer team ID |
APP_STORE_CONNECT_API_KEY_ID | API key ID (the XXXXXXXXXX part) |
APP_STORE_CONNECT_ISSUER_ID | Issuer ID (UUID format) |
APP_STORE_CONNECT_API_KEY_CONTENT | Base64-encoded .p8 file contents |
MATCH_GIT_URL | Certificates repository URL |
MATCH_PASSWORD | match passphrase |
MATCH_GIT_BASIC_AUTHORIZATION | Base64-encoded user:PAT string |
APP_STORE_CONNECT_API_KEY_CONTENT is the .p8 file converted to Base64.
base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'MATCH_GIT_BASIC_AUTHORIZATION lets match access the certificates repo.
echo -n "yourname:ghp_xxxxxxxxxxxx" | base64How to trigger a deploy
Push to the deploy branch
Merge (or push) main into deploy to start a deploy.
git checkout deploy
git merge main
git push origin deployPush a version tag
Tags with a v prefix also trigger the workflow.
git tag v1.0.1
git push origin v1.0.1Manual trigger
With workflow_dispatch enabled, you can run the workflow any time from the Actions tab via Run workflow.
Summary
- Split CI and deploy to keep PR reviews fast while keeping production deploys safe
- The Fastfile is shared between local runs and GHA — if it works locally, it usually works in CI
- Once Secrets are set up, a push to
deployis all you need
Next up: a setup with Xcode Cloud. Unlike GHA, you do not pay for macOS runner minutes — a nice perk for solo projects.
→ Building Flutter with Xcode Cloud