Publishing a Flutter app manually is fine once.
Doing it every time by downloading an .aab, opening Play Console, uploading it, checking versions, and creating releases manually is trash. It is slow, error-prone, and sooner or later you will upload the wrong build.
In this article, I’ll document the complete end-to-end flow to publish a Flutter Android app to Google Play using GitHub Actions.
Final Release Flow
feature/*
│
▼
develop
│
▼
Flutter CI
────────────────────────────
main
│
▼
Flutter CI
│
▼
Internal Release
│
▼
Google Play Internal Testing
────────────────────────────
git tag v1.0.0
│
▼
Production Release
│
▼
Google Play Production
│
▼
GitHub Release
Part 1: Google Play Console Setup
Before GitHub Actions can upload builds to Google Play, Google Play must trust a service account.
The flow is:
Google Play Console
↓
Google Cloud Project
↓
Enable Google Play Android Developer API
↓
Create Service Account
↓
Create JSON Key
↓
Invite Service Account in Play Console
↓
Grant Release Permissions
↓
Store JSON in GitHub Environment Secret
Step 1: Create App in Google Play Console
Go to Google Play Console and create your app.
You need to configure:
- App name
- Default language
- App type
- Free or paid
- Declarations
- Privacy Policy
- App access
- Ads declaration
- Data safety
- Content rating
- Target audience
- Store listing
Do not automate this part first. Get your Play Console app created manually before setting up CI/CD.
Step 2: Link Google Play Console with Google Cloud Project
Go to:
Google Play Console
→ Setup
→ API access
Create or link a Google Cloud Project.
This project will be used to create the service account that GitHub Actions will use.
Step 3: Enable Google Play Android Developer API
In Google Cloud Console:
APIs & Services
→ Library
→ Google Play Android Developer API
→ Enable
Without this API, your GitHub workflow cannot upload Android App Bundles to Play Console.
Step 4: Create Service Account
In Google Cloud Console:
IAM & Admin
→ Service Accounts
→ Create Service Account
Example name:
github-actions-playstore-release
After creating it, copy the service account email.
It will look like this:
github-actions-playstore-release@your-project-id.iam.gserviceaccount.com
Step 5: Create JSON Key
Open the created service account.
Go to:
Keys
→ Add Key
→ Create New Key
→ JSON
Download the JSON file.
Important:
Never commit this JSON file.
If you commit this file, your release pipeline is compromised. Delete the key immediately and create a new one.
Step 6: Invite Service Account in Play Console
Go back to Play Console:
Users and permissions
→ Invite new users
Paste the service account email.
Grant app-level access to your app.
Recommended permissions:
View app information
Create and edit draft apps
Release to testing tracks
Release to production
Manage testing tracks
For internal release only, production permission is not required.
For production workflow, production release permission is required.
Part 2: GitHub Secrets Setup
Use GitHub Environment Secrets, not plain repository secrets.
Create environments:
internal
production
Go to:
GitHub Repository
→ Settings
→ Environments
→ internal
→ Environment secrets
Add:
PLAY_STORE_SERVICE_ACCOUNT_JSON
Paste the full JSON content from the service account key.
For production, repeat the same under:
production
Also add signing secrets:
ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD
Part 3: Flutter Android Signing Setup
Your Flutter app must generate a signed Android App Bundle.
Create:
android/key.properties
Do not commit the real file.
Example:
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=upload-keystore.jks
In CI, GitHub Actions will recreate this file from secrets.
Also make sure your android/app/build.gradle is configured for release signing.
Part 4: Flutter CI Workflow
This workflow runs for feature branches, develop, and main.
File:
.github/workflows/flutter-ci.yml
name: Flutter CI
on:
pull_request:
branches:
- develop
- main
push:
branches:
- develop
- main
- "feature/**"
permissions:
contents: read
jobs:
flutter-ci:
name: Flutter Analyze & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Flutter
uses: ./.github/actions/setup-flutter
- name: Install Dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run Tests
run: flutter test
This is your quality gate.
If this fails, no release should happen.
Part 5: Internal Release Workflow
This workflow runs after Flutter CI succeeds on main.
File:
.github/workflows/internal-release.yml
name: Internal Release
on:
workflow_run:
workflows:
- Flutter CI
types:
- completed
permissions:
contents: read
concurrency:
group: internal-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
build-and-deploy:
name: Build & Deploy Internal Release
runs-on: ubuntu-latest
timeout-minutes: 35
environment: internal
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_branch }}
fetch-depth: 0
- name: Setup Flutter
uses: ./.github/actions/setup-flutter
- name: Restore Android Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-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=upload-keystore.jks
EOF
- name: Create Play Store Service Account JSON
run: |
echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}' > play-store-service-account.json
- name: Install Dependencies
run: flutter pub get
- name: Build Android App Bundle
run: flutter build appbundle --release
- name: Upload to Play Store Internal Testing
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: play-store-service-account.json
packageName: com.yourcompany.yourapp
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: internal
status: completed
Replace:
com.yourcompany.yourapp
with your actual Android package name.
Part 6: Production Release Workflow
Production releases should not happen on every merge.
That is reckless.
Use Git tags.
Example:
git tag v1.0.0
git push origin v1.0.0
File:
.github/workflows/production-release.yml
name: Production Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
concurrency:
group: production-release
cancel-in-progress: false
jobs:
production-release:
name: Build & Deploy Production Release
runs-on: ubuntu-latest
timeout-minutes: 45
environment: production
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Flutter
uses: ./.github/actions/setup-flutter
- name: Restore Android Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-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=upload-keystore.jks
EOF
- name: Create Play Store Service Account JSON
run: |
echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}' > play-store-service-account.json
- name: Install Dependencies
run: flutter pub get
- name: Build Android App Bundle
run: flutter build appbundle --release
- name: Upload to Play Store Production
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: play-store-service-account.json
packageName: com.yourcompany.yourapp
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: production
status: completed
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: build/app/outputs/bundle/release/app-release.aab
Part 7: Versioning
Flutter uses version from pubspec.yaml.
Example:
version: 1.0.0+1
Format:
versionName+versionCode
Example:
1.0.0+1
Means:
versionName = 1.0.0
versionCode = 1
Every Play Store upload must have a higher versionCode.
If you upload the same version code again, Play Console will reject it.
For next release:
version: 1.0.1+2
Part 8: Recommended Branch Strategy
Use this:
feature/* → development work
develop → integration branch
main → release-ready branch
tag → production release
Flow:
feature/login-screen
↓
develop
↓
main
↓
internal testing
↓
tag v1.0.0
↓
production
Do not release production directly from a random feature branch.
That is not CI/CD. That is gambling.
Part 9: Required GitHub Environments
Create these environments:
internal
production
Recommended protection:
For internal:
No manual approval required
For production:
Required reviewers enabled
This means production release waits for approval before uploading to Google Play.
Part 10: Common Mistakes
Mistake 1: Service account created but not invited to Play Console
Creating a service account in Google Cloud is not enough.
You must invite the service account email in Play Console.
Mistake 2: Missing release permissions
If the service account does not have release permissions, upload will fail.
Grant only the permissions required for that environment.
Internal environment should not need production release access.
Mistake 3: Committing the JSON key
Never commit:
play-store-service-account.json
upload-keystore.jks
key.properties
Add them to .gitignore.
android/key.properties
android/app/upload-keystore.jks
play-store-service-account.json
Mistake 4: Reusing same versionCode
Play Console requires every uploaded app bundle to have a higher version code.
Bad:
version: 1.0.0+1
again and again.
Good:
version: 1.0.0+1
version: 1.0.1+2
version: 1.0.2+3
Mistake 5: Production release on every main merge
Main branch should trigger internal testing.
Production should trigger only from tags.
That gives you control.
Final Flow
Developer pushes feature branch
↓
Pull request to develop
↓
Flutter CI runs
↓
Merge develop to main
↓
Flutter CI runs
↓
Internal release workflow uploads AAB to Play Store Internal Testing
↓
Testers verify app
↓
Create git tag v1.0.0
↓
Production release workflow uploads AAB to Play Store Production
↓
GitHub Release is created
Conclusion
This setup gives a clean Flutter Android release pipeline:
- Feature branches only run CI
-
developvalidates integration -
mainpublishes to Internal Testing - Git tags publish to Production
- Service account handles Play Store upload
- GitHub Environment Secrets protect credentials
- Production can be protected with manual approval
Manual Play Store uploads are fine for the first release.
After that, automate it.
Otherwise, your release process will eventually break at the worst possible time.
Top comments (0)