DEV Community

Cover image for Automating Flutter Android Releases to Google Play using GitHub Actions
Madhan Gannarapu
Madhan Gannarapu

Posted on

Automating Flutter Android Releases to Google Play using GitHub Actions

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example name:

github-actions-playstore-release
Enter fullscreen mode Exit fullscreen mode

After creating it, copy the service account email.

It will look like this:

github-actions-playstore-release@your-project-id.iam.gserviceaccount.com
Enter fullscreen mode Exit fullscreen mode

Step 5: Create JSON Key

Open the created service account.

Go to:

Keys
→ Add Key
→ Create New Key
→ JSON
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Go to:

GitHub Repository
→ Settings
→ Environments
→ internal
→ Environment secrets
Enter fullscreen mode Exit fullscreen mode

Add:

PLAY_STORE_SERVICE_ACCOUNT_JSON
Enter fullscreen mode Exit fullscreen mode

Paste the full JSON content from the service account key.

For production, repeat the same under:

production
Enter fullscreen mode Exit fullscreen mode

Also add signing secrets:

ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD
Enter fullscreen mode Exit fullscreen mode

Part 3: Flutter Android Signing Setup

Your Flutter app must generate a signed Android App Bundle.

Create:

android/key.properties
Enter fullscreen mode Exit fullscreen mode

Do not commit the real file.

Example:

storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=upload-keystore.jks
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Replace:

com.yourcompany.yourapp
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

File:

.github/workflows/production-release.yml
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Part 7: Versioning

Flutter uses version from pubspec.yaml.

Example:

version: 1.0.0+1
Enter fullscreen mode Exit fullscreen mode

Format:

versionName+versionCode
Enter fullscreen mode Exit fullscreen mode

Example:

1.0.0+1
Enter fullscreen mode Exit fullscreen mode

Means:

versionName = 1.0.0
versionCode = 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Part 8: Recommended Branch Strategy

Use this:

feature/*  → development work
develop    → integration branch
main       → release-ready branch
tag        → production release
Enter fullscreen mode Exit fullscreen mode

Flow:

feature/login-screen
      ↓
develop
      ↓
main
      ↓
internal testing
      ↓
tag v1.0.0
      ↓
production
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recommended protection:

For internal:

No manual approval required
Enter fullscreen mode Exit fullscreen mode

For production:

Required reviewers enabled
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add them to .gitignore.

android/key.properties
android/app/upload-keystore.jks
play-store-service-account.json
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Reusing same versionCode

Play Console requires every uploaded app bundle to have a higher version code.

Bad:

version: 1.0.0+1
Enter fullscreen mode Exit fullscreen mode

again and again.

Good:

version: 1.0.0+1
version: 1.0.1+2
version: 1.0.2+3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conclusion

This setup gives a clean Flutter Android release pipeline:

  • Feature branches only run CI
  • develop validates integration
  • main publishes 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)