Flutter CI CD with GitHub Actions: A Step-by-Step Tutorial
Step-by-step Flutter CI CD with GitHub Actions: lint, test, build Android iOS, sign, cache, and release to stores. Ready-to-copy YAML included.
Image used for representation purposes only.
Overview
This tutorial shows how to build a production grade Flutter CI CD pipeline on GitHub Actions. You will get ready to copy YAML that runs static checks, unit tests, builds signed Android bundles and iOS IPAs, and can optionally ship to Google Play and TestFlight. The focus is speed, repeatability, and secure handling of signing keys.
What you will build
- Pull request CI that runs analyze, format check, and tests with coverage
- Caching for pub and Gradle to make runs fast
- Android release build and signing with a secure keystore
- iOS release build and signing on macOS runners
- Optional store delivery to Play and TestFlight
- Artifacts you can download from every run
Prerequisites
- Flutter stable set in your project
- GitHub repo with Actions enabled
- App configured for code signing
- Android keystore and alias
- iOS distribution certificate and provisioning profile
- Basic store accounts if you plan to publish
Repository layout
.
├─ lib/
├─ test/
├─ android/
├─ ios/
└─ .github/workflows/
Secrets you will need
Add these under Repo settings Secrets and variables Actions
Android
- ANDROID_KEYSTORE_BASE64 base64 of keystore file jks or keystore
- ANDROID_KEYSTORE_PASSWORD
- ANDROID_KEY_ALIAS
- ANDROID_KEY_PASSWORD
- PLAY_SERVICE_ACCOUNT_JSON optional for publishing via Gradle Play Publisher
iOS
- IOS_CERT_P12_BASE64 base64 of distribution p12
- IOS_CERT_PASSWORD
- IOS_PROVISION_PROFILE_BASE64 base64 of .mobileprovision for the app id
- APP_STORE_KEY_P8_BASE64 base64 of App Store Connect API key p8
- APP_STORE_KEY_ID
- APP_STORE_ISSUER_ID
Misc
- CODECOV_TOKEN optional if you use Codecov
1. Pull request CI lint, test, coverage
Create .github/workflows/flutter_ci.yml
name: Flutter CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze_test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Flutter pub get
run: flutter pub get
- name: Format check
run: flutter format --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: coverage/lcov.info
- name: Upload to Codecov
if: env.CODECOV_TOKEN != ''
uses: codecov/codecov-action@v4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
files: coverage/lcov.info
Notes
- subosito flutter action installs and caches the stable SDK
- Use Java 17 for modern Android Gradle Plugin versions
- concurrency avoids duplicate CI work on force pushes
2. Speed up builds with extra caches
For larger apps add Gradle and pub caches. Extend the job with
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Cache pub
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
pub-${{ runner.os }}-
3. Android release build and signing
Create .github/workflows/android_release.yml
name: Android Release
on:
workflow_dispatch:
inputs:
track:
description: Play track internal alpha beta production
required: false
default: internal
push:
tags:
- v*
permissions:
contents: read
jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Decode keystore
run: |
mkdir -p android
echo '${{ secrets.ANDROID_KEYSTORE_BASE64 }}' | base64 -d > android/keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties <<'EOF'
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=keystore.jks
EOF
- name: Build app bundle
run: flutter build appbundle --release
- name: Build apk
run: flutter build apk --release
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: android-release
path: |
build/app/outputs/bundle/release/*.aab
build/app/outputs/flutter-apk/app-release.apk
- name: Publish to Play with Gradle Play Publisher
if: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
env:
GPP_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
echo "$GPP_JSON" > android/play-service-account.json
cd android
./gradlew publishRelease -Pandroid.injected.signing.store.file=keystore.jks
Project config snippet for Gradle Play Publisher in android app build gradle
plugins {
id 'com.github.triplet.play' version '3.10.1'
}
play {
serviceAccountCredentials.set(file('play-service-account.json'))
defaultToAppBundles.set(true)
}
4. iOS release build and signing
Create .github/workflows/ios_release.yml
name: iOS Release
on:
workflow_dispatch:
push:
tags:
- v*
jobs:
ios:
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Set up Xcode
run: sudo xcode-select -switch /Applications/Xcode.app
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Set up keychain and signing
env:
P12_BASE64: ${{ secrets.IOS_CERT_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_CERT_PASSWORD }}
PROVISION_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
run: |
KEYCHAIN=build.keychain
security create-keychain -p '' $KEYCHAIN
security set-keychain-settings -lut 21600 $KEYCHAIN
security unlock-keychain -p '' $KEYCHAIN
echo "$P12_BASE64" | base64 -d > cert.p12
security import cert.p12 -k $KEYCHAIN -P "$P12_PASSWORD" -T /usr/bin/codesign
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROVISION_BASE64" | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/app.mobileprovision
security list-keychain -d user -s $KEYCHAIN login.keychain
- name: Pod install
working-directory: ios
run: |
sudo gem install cocoapods --no-document
pod repo update
pod install
- name: Build IPA
run: flutter build ipa --release
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ios-release
path: build/ios/ipa/*.ipa
- name: Ship to TestFlight with fastlane pilot
if: ${{ secrets.APP_STORE_KEY_P8_BASE64 != '' }}
env:
APP_STORE_KEY_P8_BASE64: ${{ secrets.APP_STORE_KEY_P8_BASE64 }}
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
run: |
brew install fastlane || true
mkdir -p fastlane
echo "$APP_STORE_KEY_P8_BASE64" | base64 -d > fastlane/AuthKey.p8
cat > fastlane/Fastfile <<'EOF'
default_platform(:ios)
platform :ios do
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV['APP_STORE_KEY_ID'],
issuer_id: ENV['APP_STORE_ISSUER_ID'],
key_filepath: 'fastlane/AuthKey.p8'
)
pilot(api_key: api_key, ipa: 'build/ios/ipa/*.ipa', skip_waiting_for_build_processing: true)
end
end
EOF
bundle exec fastlane ios beta || fastlane ios beta
Tips
- Make sure the iOS Runner target uses Automatic signing with your team id in Xcode project settings
- The macos runner name advances over time use the latest available in the docs if macos 14 is not present
5. Reusable workflows for mono repos or multiple apps
If you ship flavors or many apps, create a reusable workflow. Example
.github/workflows/flutter_reusable.yml
name: Flutter reusable
on:
workflow_call:
inputs:
flutter_channel:
required: false
type: string
default: stable
build_target:
required: true
type: string
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: ${{ inputs.flutter_channel }}
- run: flutter pub get
- run: flutter build ${{ inputs.build_target }} --release
Then call it from a thin wrapper
name: Ship flavor
on: workflow_dispatch
jobs:
aab:
uses: .github/workflows/flutter_reusable.yml
with:
build_target: appbundle
6. Promotion by tag or manual dispatch
- Push a tag like v1.2.3 to trigger both Android and iOS release workflows
- Or run workflows manually from the Actions tab using workflow dispatch inputs
7. Security and secret hygiene
- Keep secrets only in GitHub encrypted secrets
- Never echo secrets in plain logs redact with shell patterns or use masked output
- Rotate store and signing keys on a schedule
- Limit Actions permissions to the minimum and prefer fine grained repo access
- Avoid third party actions when a first party action exists
8. Common pitfalls and fixes
- Gradle build fails with different Java version pin actions setup java to 17
- iOS code sign no matching profiles team id or app id mismatch fix provisioning and ensure the profile uuid exists on the runner
- CocoaPods issues run pod repo update and clear cache if needed
- Large artifacts use aab for Play and keep ipa as the only iOS artifact
- Slow runs add caches for pub pods and Gradle and avoid running jobs you do not need with paths filters
9. Local parity
Use the same commands locally that CI runs so failures are predictable
flutter clean
flutter pub get
flutter analyze
flutter test --coverage
flutter build appbundle --release
flutter build ipa --release
10. Checklist for going live
- App ids package names and version codes match store metadata
- Release notes and changelog included if you publish automatically
- Screenshots and listing ready for first time submission
- Crash reporting and analytics keys configured via env or secrets
Wrap up
With these workflows you have a solid baseline CI CD for Flutter on GitHub Actions. Start simple with analyze and tests, then layer in signed builds, caching, and automated store delivery. Iterate by measuring run times, pruning steps, and promoting only from a green main branch tagged with a semantic version.
Related Posts
Flutter Integration Testing: A Modern, End‑to‑End Guide
A modern, practical guide to Flutter integration testing: setup, writing reliable tests, running on devices and web, CI examples, and troubleshooting.
Flutter background fetch periodic tasks: reliable patterns for Android and iOS
Learn how to run periodic background tasks in Flutter with WorkManager and iOS BackgroundTasks, plus reliable patterns, caveats, and code examples.
Flutter Impeller Rendering Engine: A Performance Deep Dive and Tuning Guide
A practical performance deep dive into Flutter’s Impeller renderer: how it works, how to measure it, and tuning patterns for smooth, jank‑free UIs.