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:
- Base64-encode every sensitive file (keystore,
.p8, service account JSON) and drop them into GitHub Secrets - Write TWO workflow YAMLs — one for iOS (runs on
macos-latest), one for Android (runs onubuntu-latest) - Trigger on git tag pushes matching
v*.*.*so shipping is intentional, not accidental - 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:
bundler-cache: trueon the Ruby setup step means gems get cached between runs. Fastlane's dependency tree is big — this saves you 2-3 minutes per run.MATCH_GIT_BASIC_AUTHORIZATIONis how you give match's git-clone access to your private certs repo. It expectsbase64("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.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:
Oussama Aniba Newsletter
Join the newsletter to receive the latest updates in your inbox.