Automating Flutter Android Releases to Google Play using GitHub Actions
DEV Community

Automating Flutter Android Releases to Google Play using GitHub Actions

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

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.

Comments

No comments yet. Start the discussion.