Skip to content

Flutter CI/CD with Fastlane & GitHub Actions — Part 3: Wiring it into GitHub Actions

Oussama Aniba
6 min read
Flutter CI/CD with Fastlane & GitHub Actions — Part 3: Wiring it into GitHub Actions
Series recap: Part 1 got iOS shipping to TestFlight locally. Part 2 added Android and solved the iOS signing puzzle with match. This one — the payoff — hands the whole thing over to GitHub Actions so you never touch a build machine again 🎉

You made it. This is the fun part.

By now, fastlane beta works from your Mac for both iOS and Android. All that's left is teaching a GitHub runner to run those same commands. In principle it's simple: it's the same lanes, just executed in a different keychain. In practice there's a bit of secret-management dance to do first. Let's dig in.

The plan

We're going to:

  1. Base64-encode every sensitive file (keystore, .p8, service account JSON) and drop them into GitHub Secrets
  2. Write TWO workflow YAMLs — one for iOS (runs on macos-latest), one for Android (runs on ubuntu-latest)
  3. Trigger on git tag pushes matching v*.*.* so shipping is intentional, not accidental
  4. Add a Slack ping at the end so your team gets notified

If you'd rather do a manual "run this workflow" button instead of tag-based triggers, I'll show that too.

The full list of GitHub Secrets you need

Go to your repo → Settings → Secrets and variables → Actions → New repository secret. You'll need:

For iOS:

Secret name What it is
APP_STORE_CONNECT_API_KEY_ID The short Key ID from App Store Connect
APP_STORE_CONNECT_API_ISSUER_ID The Issuer ID UUID
APP_STORE_CONNECT_API_KEY_BASE64 Base64-encoded contents of your .p8 file
MATCH_GIT_URL HTTPS URL of your private certs repo
MATCH_PASSWORD The passphrase you set when you ran fastlane match init in Part 2
MATCH_GIT_TOKEN A GitHub Personal Access Token with repo scope, so the runner can clone your private certs repo

For Android:

Secret name What it is
ANDROID_KEYSTORE_BASE64 Base64-encoded contents of your upload-keystore.jks
ANDROID_KEYSTORE_PASSWORD Keystore password
ANDROID_KEY_PASSWORD Key password
ANDROID_KEY_ALIAS Usually upload
PLAY_STORE_CREDENTIALS_JSON The FULL contents of your play-store-credentials.json (just paste the JSON text — no base64 needed since it's already text)

For notifications (optional):

Secret name What it is
SLACK_WEBHOOK_URL Incoming webhook URL from your Slack workspace

Base64-encode the binary files

On macOS/Linux:

# The App Store Connect API key
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
# → paste into APP_STORE_CONNECT_API_KEY_BASE64

# The Android keystore
base64 -i ~/upload-keystore.jks | pbcopy
# → paste into ANDROID_KEYSTORE_BASE64

The pbcopy at the end puts it directly on your clipboard. On Linux you can pipe to xclip -selection clipboard or just redirect to a file and open it.

IMPORTANT: -i flag matters on macOS. Without it, base64 wraps lines at 76 chars and while GitHub is smart enough to accept that, some later decoding steps aren't. -i gives you a clean single-line encoding.

Actually let me correct myself — modern macOS base64 handles this fine either way. But if you're on an older machine and see weird decode errors down the line, that's the first thing to check 🥲

The iOS workflow

Create .github/workflows/ios-beta.yml:

name: iOS — TestFlight beta

on:
  push:
    tags:
      - 'v*.*.*'
  workflow_dispatch:  # allows manual runs from the Actions tab

jobs:
  deploy-ios:
    runs-on: macos-latest
    timeout-minutes: 45

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          cache: true

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.4'
          bundler-cache: true
          working-directory: ios

      - name: Decode App Store Connect API key
        env:
          API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
          API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
        run: |
          echo -n "$API_KEY_BASE64" | base64 --decode > ios/fastlane/AuthKey_${API_KEY_ID}.p8

      - name: Flutter pub get
        run: flutter pub get

      - name: Fastlane beta
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
        run: bundle exec fastlane beta

      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2.1.1
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            text: "iOS build ${{ github.ref_name }} — ${{ job.status }} 🚀"

A few things worth calling out:

  1. bundler-cache: true on the Ruby setup step means gems get cached between runs. Fastlane's dependency tree is big — this saves you 2-3 minutes per run.
  2. MATCH_GIT_BASIC_AUTHORIZATION is how you give match's git-clone access to your private certs repo. It expects base64("username:token") format if you want to be precise, but for public runners the token alone as a bearer works too. Test it locally by unsetting your git credentials first.
  3. if: always() on the Slack step means you get notified whether the build succeeds OR fails. Personally I find failure notifications MORE useful than success ones.

Oh, and you'll need a Gemfile inside ios/ for bundler-cache to work:

# ios/Gemfile
source "https://rubygems.org"

gem "fastlane", "~> 2.236"

Run bundle install locally once, commit both Gemfile and Gemfile.lock, done. Pinning to ~> 2.236 means you'll get patch and minor updates automatically (2.236.x, 2.237.x, ...) but never a surprise 3.0 breaking your CI overnight.

The Android workflow

Create .github/workflows/android-beta.yml:

name: Android — Play Store beta

on:
  push:
    tags:
      - 'v*.*.*'
  workflow_dispatch:

jobs:
  deploy-android:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Java
        uses: actions/setup-java@v5
        with:
          distribution: 'zulu'
          java-version: '21'

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
          cache: true

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.4'
          bundler-cache: true
          working-directory: android

      - name: Decode keystore
        env:
          KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: |
          echo -n "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks

      - name: Write key.properties
        env:
          KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
        run: |
          cat > android/key.properties <<EOF
          storePassword=${KEYSTORE_PASSWORD}
          keyPassword=${KEY_PASSWORD}
          keyAlias=${KEY_ALIAS}
          storeFile=upload-keystore.jks
          EOF

      - name: Write Play Store credentials
        env:
          PLAY_CREDS: ${{ secrets.PLAY_STORE_CREDENTIALS_JSON }}
        run: |
          echo "$PLAY_CREDS" > android/fastlane/play-store-credentials.json

      - name: Flutter pub get
        run: flutter pub get

      - name: Fastlane beta
        working-directory: android
        run: bundle exec fastlane beta

      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2.1.1
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            text: "Android build ${{ github.ref_name }} — ${{ job.status }} 🚀"

You'll also need android/Gemfile:

# android/Gemfile
source "https://rubygems.org"

gem "fastlane", "~> 2.236"

Same drill — bundle install once, commit Gemfile and Gemfile.lock.

Trigger a release

Both workflows fire on tag push matching v*.*.*. So to ship:

git tag v1.2.3
git push origin v1.2.3

Head to your repo's Actions tab and watch both jobs run in parallel. iOS will take 15-20 minutes on the first run (fresh Xcode setup, match cloning, gym archiving). Android is faster — usually 8-12 minutes.

If you don't want to tag, you can also trigger manually: Actions tab → pick the workflow → "Run workflow" button (that's what workflow_dispatch: enables).

When things break (and they will)

A few gremlins I've hit and how to swat them:

"No matching provisioning profile found" — your match repo doesn't have a profile for the bundle ID you're building. Run fastlane match appstore locally with readonly: false (temporarily), it'll generate the missing profile and push it. Then flip readonly back to true.

"Bundle id mismatch" between Flutter and Xcode — your PRODUCT_BUNDLE_IDENTIFIER in the Xcode project doesn't match your Appfile. Open ios/Runner.xcodeproj in Xcode and check the Signing & Capabilities tab.

Play Console rejects with "This edit has already been committed" — you have a draft release stuck in Play Console from a previous failed run. Delete it manually in the Play Console UI, re-run the workflow.

Match can't clone the repo — your MATCH_GIT_TOKEN doesn't have repo scope, or it's a fine-grained token that doesn't include the certs repo. Regenerate with classic PAT + repo scope, that's the path of least resistance.

Slack notification never arrives — the webhook URL contains slashes and GitHub's YAML parser sometimes eats them. Wrap the entire secret in the Actions UI as a single value — do NOT re-encode it.

What we built

You now have:

  • A single command (git push origin v1.2.3) that ships BOTH platforms
  • Zero manual signing steps
  • Slack notifications on success or failure
  • Reproducible builds on any machine, because everything is in Git

PS: if you're going to do this on a team, add branch protection to your main branch AND require the workflows to succeed before merging. Otherwise someone WILL push a broken build and blame Fastlane. I know from experience 🥲

Full source code with everything from this series wired together lives in my GitHub. Clone it, replace the bundle IDs with yours, plug in the secrets, and you should have a shipping app within a couple of hours.

Hope this helped you retire your "release checklist" doc — mine has been gathering dust since I set this up, and honestly it's one of the most freeing things you can do as a mobile dev 🚀

Enjoy!

References: