CI/CD for Flutter: Auto-Deploy to Google Play with GitHub Actions
Every time you manually build an AAB, open the Play Console, upload the file, and click through the release wizard, you lose 20 minutes and introduce the risk of human error. Let's automate the entire process so pushing to master means deploying to production.
What We're Building
A GitHub Actions workflow that:
- Triggers on push to
master(or manual dispatch) - Decodes your upload keystore from a GitHub secret
- Builds a signed release AAB with flavor support
- Uploads the AAB directly to Google Play
No manual steps. Push code, grab coffee, app is live.
One-Time Setup
1. Google Play Service Account
You need a service account that GitHub Actions can use to upload to Play Console:
- Go to Google Cloud Console > IAM > Service Accounts
- Create a service account and download the JSON key
- In Play Console > Users and permissions, add the service account email with "Release Manager" role
2. Upload Keystore
Create your upload keystore if you haven't already, then base64-encode it for GitHub:
base64 -i /path/to/upload-keystore.jks | tr -d '\n' | pbcopy
This copies the encoded keystore to your clipboard. Paste it into a GitHub secret.
3. GitHub Secrets
Add these secrets to your repository (Settings > Secrets > Actions):
| Secret | Value |
|---|---|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON | Raw JSON from the service account key file |
ANDROID_KEYSTORE_JKS | Base64-encoded upload keystore |
ANDROID_KEYSTORE_PASSWORD | Keystore password |
ANDROID_KEY_ALIAS | Key alias (usually upload) |
ANDROID_KEY_PASSWORD | Key password |
4. The Workflow File
# .github/workflows/build-upload.yaml
name: Build & Upload to Google Play
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-android:
runs-on: self-hosted # or ubuntu-latest
environment: prod
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
# Decode keystore from secret
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_JKS }}" | base64 -D > android/android_keystore.jks
# Create key.properties
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storeFile=${{ github.workspace }}/android/android_keystore.jks
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- run: flutter pub get
- run: flutter build appbundle --release --flavor prod
# Upload to Google Play
- uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.yourcompany.yourapp
releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
track: production
Versioning: Don't Forget the Build Number
Google Play rejects uploads if the versionCode hasn't increased. Always bump the +N part in pubspec.yaml before pushing:
version: 1.0.1+7 # +7 is the versionCode
You can automate this with a script that reads the current version and increments it, or simply remember to bump it as part of your release process.
Self-Hosted Runner Tips
If you're using a self-hosted macOS runner (for faster builds or iOS support too):
- No spaces in the runner path.
/Users/you/actions-runnerworks./Users/you/My Folder/actions-runnerbreaks everything. - Start the runner with
./run.shand wait for "Listening for Jobs" - Don't run multiple runner sessions simultaneously
- Keep runner files out of git (add
actions-runner/to.gitignore)
Track Options
| Track | Use Case |
|---|---|
internal | Team testing, fastest review |
alpha | Closed testing with testers |
beta | Open testing before launch |
production | Live release to all users |
Start with internal to validate your pipeline works, then switch to production when you're confident.
Common Failures and Fixes
No logs / instant fail: The runner is offline, environment approval is pending, or the runner work folder is misconfigured.
"No such file or directory" with spaces: Your runner path contains spaces. Move it to a clean path.
"base64: invalid input": The ANDROID_KEYSTORE_JKS secret has wrong encoding. On macOS, the workflow needs base64 -D (not base64 -d).
Upload errors: The package name in the workflow doesn't match the Play Console app, or the track doesn't exist yet. Create the track in Play Console first.
Security Checklist
- Never commit keystore files to source control
- Never commit
key.properties - Never commit runner configuration files
- Use GitHub environment protection rules for production deployments
- Rotate the service account key periodically
What This Gets You
- Consistent builds: Same environment every time, no "works on my machine" issues
- Faster releases: Push to master, deployment happens automatically
- Audit trail: Every deployment is a GitHub Actions run you can inspect
- Team collaboration: Anyone with merge access can deploy
- Rollback: Git revert + push = automatic rollback deployment
Start Simple
Set up the workflow with track: internal first. Push to master, verify the AAB appears in Play Console's internal testing track. Once that works reliably, change to production.
The initial setup takes about an hour. After that, every release takes zero manual effort. The time you save compounds with every single deployment.



