DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on • Originally published at actocodes.hashnode.dev

The Flutter-to-DevOps Pipeline: How to Automate Play Store Deployments with GitHub Actions

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

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

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

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

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

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:

  1. Go to Google Play Console → Setup → API access
  2. Link to an existing Google Cloud project or create a new one
  3. Create a service account in Google Cloud IAM with the Editor role on the linked project
  4. Download the service account JSON key file
  5. Grant the service account Release Manager permissions in Play Console
  6. Paste the JSON key contents as the PLAY_STORE_JSON_KEY GitHub 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)