Link copied

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-codesign

Two things I cared about here.

  • dart-checks runs on ubuntu.
    Flutter analyze and tests work fine on Linux, so this is faster and cheaper than a macOS runner.
  • ios-build uses needs: dart-checks, so the iOS build is skipped when format or tests fail.
  • concurrency with cancel-in-progress: true cancels 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/*.ipa

A 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 nameValue
APP_STORE_CONNECT_APPLE_IDApple ID email address
APP_STORE_CONNECT_TEAM_IDApple Developer team ID
APP_STORE_CONNECT_API_KEY_IDAPI key ID (the XXXXXXXXXX part)
APP_STORE_CONNECT_ISSUER_IDIssuer ID (UUID format)
APP_STORE_CONNECT_API_KEY_CONTENTBase64-encoded .p8 file contents
MATCH_GIT_URLCertificates repository URL
MATCH_PASSWORDmatch passphrase
MATCH_GIT_BASIC_AUTHORIZATIONBase64-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" | base64

How 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 deploy

Push a version tag

Tags with a v prefix also trigger the workflow.

git tag v1.0.1
git push origin v1.0.1

Manual 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 deploy is 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