Mobile app deployment is one of the last frontiers where many engineering teams still rely on manual processes. A developer builds a release locally, signs it on their machine, uploads it through the Play Console UI, and fills out a release form by hand. It works, until it doesn't. The signing keystore lives on one laptop. The Play Console credentials are shared over Slack. The versioning is managed in a spreadsheet.
This is not a deployment pipeline. It is a fragile ritual that couples your release process to a specific person, a specific machine, and a specific sequence of manual steps that nobody has fully documented.
GitHub Actions combined with Fastlane transforms Flutter's release process into a reproducible, auditable, automated pipeline, building, testing, signing, and deploying to the Google Play Store on every merge to your release branch, with no developer manual intervention required.
This guide covers the complete Flutter-to-Play Store pipeline, project configuration, automated testing, keystore management, build versioning, AAB generation, and staged Play Store deployment, all wired into a GitHub Actions workflow that runs without human input.
Pipeline Architecture
Before writing any YAML, understand what the complete pipeline does and in what order:
Push to release branch
│
▼
┌─────────────────────┐
│ 1. Setup │ Install Flutter SDK, Java, dependencies
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 2. Test │ Unit tests, widget tests, integration tests
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 3. Code Analysis │ flutter analyze, dart format check
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 4. Version Bump │ Auto-increment build number from Git
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 5. Build AAB │ flutter build appbundle --release
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 6. Sign │ Sign with keystore via Gradle
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 7. Deploy │ Upload to Play Store internal track via Fastlane
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 8. Notify │ Slack notification with release summary
└─────────────────────┘
Each stage gates the next, a failed test prevents a build, a failed build prevents a deployment. The pipeline is a sequence of quality gates, not just a delivery mechanism.
Step 1 - Flutter Project Configuration
Ensure your Flutter project is configured for automated builds before touching CI:
# pubspec.yaml - version format: major.minor.patch+buildNumber
name: your_app
version: 1.4.2+87 # +87 is the build number (versionCode in Play Store)
Configure Gradle to read signing credentials from environment variables rather than hardcoded file paths, critical for CI environments where keystores are injected at runtime:
// android/app/build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
// Load from file if it exists (local development)
// Otherwise read from environment variables (CI)
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias'] ?: System.getenv('KEY_ALIAS')
keyPassword keystoreProperties['keyPassword'] ?: System.getenv('KEY_PASSWORD')
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile'])
: file(System.getenv('KEY_STORE_PATH') ?: 'keystore.jks')
storePassword keystoreProperties['storePassword'] ?: System.getenv('KEY_STORE_PASSWORD')
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
This pattern works identically in local development (reads from key.properties) and CI (reads from environment variables injected by GitHub Actions).
Step 2 - Secrets Setup in GitHub
Before writing the workflow, store all sensitive values as GitHub Actions secrets:
Repository → Settings → Secrets and Variables → Actions
KEYSTORE_BASE64 ← Base64-encoded keystore file
KEY_ALIAS ← Key alias within the keystore
KEY_PASSWORD ← Key password
KEY_STORE_PASSWORD ← Keystore password
PLAY_STORE_JSON_KEY ← Google Play service account JSON
SLACK_WEBHOOK_URL ← Slack notification webhook (optional)
Encode your keystore for storage as a GitHub secret:
# On your local machine - encode keystore to base64
base64 -i your-release-keystore.jks | pbcopy # macOS
base64 -w 0 your-release-keystore.jks | xclip # Linux
# Paste the output as the KEYSTORE_BASE64 secret value
The base64 encoding is necessary because GitHub Secrets store string values, binary files must be encoded for storage and decoded at runtime.
Step 3 - Google Play Service Account
Fastlane's supply tool authenticates to the Play Store API using a Google service account, not your personal Google account. This is the secure, auditable approach, the service account has only the permissions it needs, and credentials can be rotated independently.
Set up the service account:
- Go to Google Play Console → Setup → API access
- Link to an existing Google Cloud project or create a new one
- Create a service account in Google Cloud IAM with the
Editorrole on the linked project - Download the service account JSON key file
- Grant the service account Release Manager permissions in Play Console
- Paste the JSON key contents as the
PLAY_STORE_JSON_KEYGitHub secret
Step 4 - Fastlane Configuration
Fastlane handles the Play Store upload. Initialize it in your Flutter project:
# From your Flutter project root
gem install fastlane
cd android
fastlane init
Configure the Fastfile with lanes for each deployment target:
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run all tests"
lane :test do
gradle(task: "test")
end
desc "Deploy to Play Store internal testing track"
lane :deploy_internal do
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["PLAY_STORE_JSON_KEY"],
release_status: "draft",
skip_upload_apk: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
end
desc "Promote internal build to beta"
lane :promote_to_beta do
upload_to_play_store(
track: "internal",
track_promote_to: "beta",
json_key_data: ENV["PLAY_STORE_JSON_KEY"],
skip_upload_apk: true,
skip_upload_aab: true,
)
end
desc "Promote beta to production with staged rollout"
lane :promote_to_production do
upload_to_play_store(
track: "beta",
track_promote_to: "production",
rollout: "0.1", # start with 10% rollout
json_key_data: ENV["PLAY_STORE_JSON_KEY"],
skip_upload_apk: true,
skip_upload_aab: true,
)
end
end
The three-lane structure mirrors the Play Store track progression, internal -> beta -> production. Each lane is independently callable from GitHub Actions, giving you granular control over the promotion workflow.
Step 5 - The Complete GitHub Actions Workflow
# .github/workflows/flutter-deploy-android.yml
name: Flutter Android Deploy
on:
push:
branches: [release]
# Manual trigger for promoting between tracks
workflow_dispatch:
inputs:
action:
description: "Deployment action"
required: true
type: choice
options:
- deploy-internal
- promote-beta
- promote-production
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Analyze code
run: flutter analyze --fatal-infos
- name: Run unit and widget tests
run: flutter test --coverage
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
files: coverage/lcov.info
build-and-deploy:
name: Build & Deploy
runs-on: ubuntu-latest
needs: test # only runs if tests pass
if: github.event_name == 'push' || github.event.inputs.action == 'deploy-internal'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for build number calculation
- 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:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Calculate build number from Git commit count
id: versioning
run: |
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_OUTPUT
echo "Build number: $BUILD_NUMBER"
- name: Update pubspec version
run: |
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
NEW_VERSION="${CURRENT_VERSION}+${{ steps.versioning.outputs.BUILD_NUMBER }}"
sed -i "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
echo "Version set to: ${NEW_VERSION}"
- name: Decode keystore from secret
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Build Android App Bundle
run: |
flutter build appbundle \
--release \
--build-number=${{ steps.versioning.outputs.BUILD_NUMBER }}
env:
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_STORE_PATH: keystore.jks
- name: Set up Ruby for Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Deploy to Play Store internal track
working-directory: android
run: bundle exec fastlane deploy_internal
env:
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
- name: Clean up keystore
if: always() # clean up even if build fails
run: rm -f android/app/keystore.jks
- name: Notify Slack on success
if: success()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '{
"text": "✅ *Flutter Android Deploy* — Build ${{ steps.versioning.outputs.BUILD_NUMBER }} deployed to internal track.\nCommit: `${{ github.sha }}`\nAuthor: ${{ github.actor }}"
}'
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '{
"text": "❌ *Flutter Android Deploy Failed* — Build ${{ steps.versioning.outputs.BUILD_NUMBER }}\nCommit: `${{ github.sha }}`\nAuthor: ${{ github.actor }}\nCheck: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}'
promote-beta:
name: Promote to Beta
runs-on: ubuntu-latest
if: github.event.inputs.action == 'promote-beta'
environment: beta-promotion # requires manual approval gate in GitHub environments
steps:
- uses: actions/checkout@v4
- name: Set up Ruby for Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Promote internal build to beta
working-directory: android
run: bundle exec fastlane promote_to_beta
env:
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
promote-production:
name: Promote to Production
runs-on: ubuntu-latest
if: github.event.inputs.action == 'promote-production'
environment: production-promotion # requires mandatory reviewer approval
steps:
- uses: actions/checkout@v4
- name: Set up Ruby for Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Promote beta to production (10% rollout)
working-directory: android
run: bundle exec fastlane promote_to_production
env:
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
Step 6 - Build Number Strategy
The git rev-list --count HEAD approach ties the build number to the Git commit count, it's monotonically increasing, reproducible from any clone, and doesn't require external state management:
# Build number = total number of commits on the branch
git rev-list --count HEAD
# Output: 847
# Reproducible - the same commit always produces the same build number
# Monotonically increasing - merges to main always increase the count
# No external state - no counter stored in a database or file
This eliminates the classic CI problem of "where is the build number stored?" the answer is always "in Git history."
Step 7 - Environment Protection Rules for Promotion
GitHub Environments add a required human approval gate before the promote-production job runs:
Repository → Settings → Environments → New Environment
Name: production-promotion
Required Reviewers: [release-managers team]
Wait Timer: 0 minutes
Deployment Branches: release
With this configuration, the promote-production workflow job pauses and sends a notification to the release-managers team requesting approval. The 10% staged rollout in Fastlane gives you a further safety buffer, a bad build affects 10% of users before you manually expand the rollout in Play Console.
Step 8 - Caching for Pipeline Speed
Flutter and Gradle dependencies are large, caching them dramatically reduces pipeline runtime:
# Cache Flutter SDK and pub packages
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
cache: true # built-in Flutter cache
# Cache Gradle dependencies separately
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
Common Pitfalls to Avoid
Keystore on disk after workflow completes. The if: always() cleanup step is non-negotiable. A keystore file left in the workspace of a GitHub Actions runner even ephemerally, is a security risk. Always delete it, even on failure.
Using personal Google accounts for Play Store API. Personal account credentials tied to a developer are revoked when that person leaves the organization. Service accounts are credential artifacts owned by the organization, rotate them independently of any individual.
Hardcoding the Flutter version. Pin the Flutter version in your workflow (flutter-version: '3.19.0') and update it deliberately. Floating to latest means a Flutter SDK update can silently break your build.
Skipping the test job on the release branch. The test job is the most important gate in the pipeline, removing it "to speed up releases" eliminates the quality guarantee the pipeline is built to provide.
No staged rollout for production. Deploying directly to 100% of production users with no staged rollout turns every release into a high-stakes gamble. Start at 10%, monitor crash rates and ANR rates in the Play Console, and expand manually when metrics confirm stability.
Conclusion
A Flutter deployment pipeline built on GitHub Actions and Fastlane transforms the release process from a fragile, person-dependent ritual into a reproducible, auditable, automated workflow. Tests gate builds. Builds gate deployments. Deployments require approval before reaching production users. Staged rollouts limit the blast radius of regressions.
Every element of the manual process, the keystore on a developer's laptop, the shared Play Console login, the manual version bump, the upload through the UI, has a secure, automated equivalent in this pipeline. The result is a release process your team can run confidently, repeatedly, and without heroics.
Building for iOS alongside Android? The same GitHub Actions workflow structure applies for App Store Connect deployment via Fastlane's deliver action, with Xcode cloud signing replacing the Android keystore step.
Top comments (0)