Question #426MediumAutomationTools & DevOps

How to automate fastlane in Android?

#fastlane#automation#android#ci-cd#deployment#play-store#google-play

Answer

Overview

Fastlane is an open-source automation tool designed to streamline mobile app delivery. For Flutter developers targeting Android, Fastlane can automate building, version validation, and publishing to the Play Store, reducing manual effort and preventing release mistakes.

If you're tired of manually uploading AAB files and managing version codes, this guide will help you fully automate your Android release process using Fastlane.

Key Benefits:

  • šŸ¤– Automation of repetitive tasks - Build, sign, and upload automatically
  • ā±ļø Saves time - Release in minutes instead of manually navigating Play Console
  • āœ… Improves consistency - Avoid human errors like duplicate version codes
  • šŸ”„ CI/CD friendly - Works smoothly with GitHub Actions, Jenkins, Bitrise
  • šŸ“ Centralized configuration - Keep entire release process version-controlled

Prerequisites

Before setting up Fastlane for Android, ensure you have:

Google Requirements:

  • āœ… Active Google Play Console account
  • āœ… App has completed initial review
  • āœ… App has been released at least once to Play Store

System Requirements:

  • macOS, Linux, or Windows
  • Ruby 2.5 or higher (check with
    text
    ruby --version
    )
  • Flutter SDK installed and configured
  • Android SDK and build tools

Project Requirements:

  • Flutter project with Android folder
  • App already registered in Play Console
  • Package name configured (e.g.,
    text
    com.example.app
    )

āš ļø Important: Fastlane requires your app to have completed the first manual release. You cannot use Fastlane for the very first Play Store submission.


Step 1: Install & Initialize Fastlane

1ļøāƒ£ Install Fastlane

macOS / Linux (Recommended):

bash
brew install fastlane

Windows:

bash
sudo gem install fastlane

2ļøāƒ£ Navigate to Your Flutter Project

bash
cd your_flutter_project
cd android

3ļøāƒ£ Initialize Fastlane

bash
fastlane init

When prompted:

  1. Enter your app package name (e.g.,
    text
    com.example.app
    )
  2. JSON path: Press Enter and confirm with Yes

After setup, you'll see:

text
android/
 ā”œā”€ā”€ Gemfile
 ā”œā”€ā”€ Gemfile.lock
 └── fastlane/
      ā”œā”€ā”€ Appfile
      └── Fastfile

4ļøāƒ£ Update the Gemfile

Open

text
android/Gemfile
and update with specific versions:

ruby
# android/Gemfile
source "https://rubygems.org"

ruby "3.3.7"

gem "fastlane", "2.232.0"
gem "abbrev"
gem "ostruct"

Install dependencies:

bash
bundle install

Why these versions?

  • text
    ruby "3.3.7"
    - Stable Ruby version
  • text
    fastlane "2.232.0"
    - Latest stable fastlane
  • text
    abbrev
    and
    text
    ostruct
    - Required dependencies

Step 2: Configure Google Play API (Supply Setup)

Fastlane uses the Google Play Developer API to upload builds automatically.

1ļøāƒ£ Create a Google Cloud Project

Go to: Google Cloud Console

  1. Click Select a project (top navigation bar)
  2. Click New Project
  3. Enter project name:
    text
    Fastlane Automation
  4. Click Create
  5. Wait for project creation to complete

2ļøāƒ£ Enable the Required API

Navigate to: APIs & Services → Enable APIs and Services

  1. Search for:
    text
    Google Play Android Developer API
  2. Click on the API
  3. Click Enable
  4. Wait for activation (may take a few seconds)

3ļøāƒ£ Create a Service Account

Navigate to: IAM & Admin → Service Accounts

  1. Click Create Service Account
  2. Enter details:
    • Service account name:
      text
      fastlane-uploader
    • Service account ID:
      text
      fastlane-uploader
      (auto-filled)
    • Description:
      text
      Fastlane automation for Play Store uploads
  3. Click Create and Continue
  4. Grant role (optional): Skip this step → Click Continue
  5. Click Done

Copy the service account email:

  • Format:
    text
    fastlane-uploader@your-project-id.iam.gserviceaccount.com
  • Save this email - you'll need it in Step 3

4ļøāƒ£ Generate JSON Key

  1. Click on the created service account (
    text
    fastlane-uploader
    )
  2. Go to Keys tab
  3. Click Add Key → Create new key
  4. Choose JSON format
  5. Click Create
  6. A JSON file will be downloaded (e.g.,
    text
    your-project-123456-abc123def456.json
    )

āš ļø Important:

  • This JSON file contains sensitive credentials
  • Store it securely
  • Never commit it to version control

Step 3: Grant Access in Play Console

Now connect the service account to your Play Console.

Steps:

  1. Go to: Google Play Console
  2. Click Users and Permissions (left sidebar)
  3. Click Invite new users
  4. Paste the service account email:
    text
    fastlane-uploader@your-project-id.iam.gserviceaccount.com
  5. Grant permissions:
    • Select Admin (all permissions) for full automation
    • Or select specific permissions:
      • āœ… View app information
      • āœ… Create and edit draft apps
      • āœ… Release apps to testing tracks
      • āœ… Release apps to production
      • āœ… Manage testing tracks
  6. Click Invite user
  7. No email will be sent (service accounts don't have email)
  8. The service account will appear in the user list

Step 4: Add JSON File to Project

Place the downloaded JSON file inside your project:

bash
# Move JSON file to fastlane directory
mv ~/Downloads/your-project-123456-abc123def456.json android/fastlane/play-store-credentials.json

Secure the file:

bash
# Set appropriate permissions
chmod 600 android/fastlane/play-store-credentials.json

Add to .gitignore:

bash
# android/fastlane/.gitignore
*.json
play-store-credentials.json
.env
.env.local

āš ļø Critical: Never commit JSON credentials to version control!


Step 5: Create Environment File

Create a

text
.env
file inside
text
android/fastlane/
:

bash
cd android/fastlane
touch .env

Add configuration:

bash
# android/fastlane/.env

# Android app package name
PACKAGE_NAME=com.example.app

# Path to Google Play service account JSON
PLAY_STORE_JSON_PATH=fastlane/play-store-credentials.json

Why use .env file?

  • āœ… Keeps sensitive data out of code
  • āœ… Easy to change between environments
  • āœ… Can be overridden in CI/CD

For CI/CD: Create

text
.env.local
for local development and use environment variables in CI/CD.


Step 6: Configure Fastlane Files

šŸ“„ Update Appfile

ruby
# android/fastlane/Appfile

json_key_file(ENV['PLAY_STORE_JSON_PATH'])
package_name(ENV['PACKAGE_NAME'])

This file:

  • Reads values from
    text
    .env
    file
  • Configures Play Store authentication
  • Sets default package name

šŸ“„ Update Fastfile

Production-ready Fastfile for Flutter Android:

ruby
# android/fastlane/Fastfile

default_platform(:android)

platform :android do
  # Prevent Fastlane from regenerating README.md on every run
  skip_docs

  # Load environment variables from .env (absolute path for reliability)
  env_file = File.expand_path('.env', __dir__)
  Dotenv.overload(env_file) if File.exist?(env_file)

  # Lane: Build & upload to Play Store Internal Testing
  desc "Build Flutter AAB & upload to Play Store Internal Testing"
  lane :beta do
    # 1. Read version from pubspec.yaml
    require 'yaml'
    pubspec_path = "../../pubspec.yaml"
    pubspec = YAML.load_file(pubspec_path)
    version_string = pubspec['version']

    UI.user_error!("Missing 'version' in pubspec.yaml") if version_string.nil? || version_string.empty?

    parts = version_string.split('+')
    version = parts[0]

    UI.user_error!("pubspec.yaml version '#{version_string}' is missing build number (expected format: x.y.z+n)") unless parts.length == 2

    build_number = parts[1].to_i
    UI.message("Using version from pubspec.yaml: #{version}+#{build_number}")

    # 2. Validate version (prevent duplicate uploads)
    existing_versions = begin
      google_play_track_version_codes(track: "internal")
    rescue => e
      UI.user_error!("āŒ Could not verify existing versions on Play Store: #{e.message}")
    end

    if existing_versions.include?(build_number)
      UI.user_error!("āŒ Version code #{build_number} already exists on Internal track. Please increment the build number in pubspec.yaml")
    end

    UI.success("āœ“ Version validation passed - #{version}+#{build_number}")

    # 3. Build AAB
    sh("cd ../.. && flutter build appbundle --release")

    # 4. Prepare Changelog
    changelog_path = "./metadata/android/en-US/release_notes.txt"
    changelog_text = File.exist?(changelog_path) ? File.read(changelog_path) : "Beta build v#{version} (#{build_number})"

    # Write changelog to the path 'supply' expects
    dest_changelog_dir = "./metadata/android/en-US/changelogs"
    FileUtils.mkdir_p(dest_changelog_dir)
    File.write("#{dest_changelog_dir}/#{build_number}.txt", changelog_text)

    # 5. Upload to Play Store Internal track
    upload_to_play_store(
      track: "internal",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )

    UI.success("āœ… Uploaded build #{build_number} (v#{version}) to Internal Track šŸš€")
  end

  # Lane: Promote beta build to Production
  desc "Promote existing Internal build to Production"
  lane :production do |options|
    # Read from pubspec.yaml if no explicit params provided
    if options[:version].nil? || options[:build].nil?
      require 'yaml'
      pubspec_path = "../../pubspec.yaml"
      pubspec = YAML.load_file(pubspec_path)
      version_string = pubspec['version']

      UI.user_error!("Missing 'version' in pubspec.yaml") if version_string.nil? || version_string.empty?

      parts = version_string.split('+')
      version = parts[0]

      UI.user_error!("pubspec.yaml version '#{version_string}' is missing build number (expected format: x.y.z+n)") unless parts.length == 2

      build_number = parts[1].to_i
    else
      version = options[:version]
      build_number = options[:build].to_i
    end

    UI.important("āš ļø  Ensure this matches the Internal build you want to promote!")
    UI.message("Promoting: v#{version} (#{build_number})")

    # Validate the build exists on Internal track
    existing_versions = begin
      google_play_track_version_codes(track: "internal")
    rescue => e
      UI.user_error!("āŒ Could not verify build on Internal track: #{e.message}")
    end

    if existing_versions
      unless existing_versions.include?(build_number)
        UI.user_error!("āŒ Build #{build_number} not found on Internal track.")
      end
      UI.success("āœ“ Build #{build_number} verified on Internal track")
    end

    # Promote to production
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      version_code: build_number,
      skip_upload_aab: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )

    UI.success("āœ… Successfully promoted build #{build_number} (v#{version}) to Production šŸŽ")
  end

  # Lane: Build only (no upload)
  desc "Build Flutter AAB without uploading"
  lane :build_only do
    # 1. Read version from pubspec.yaml
    require 'yaml'
    pubspec_path = "../../pubspec.yaml"
    pubspec = YAML.load_file(pubspec_path)
    version_string = pubspec['version']

    UI.user_error!("Missing 'version' in pubspec.yaml") if version_string.nil? || version_string.empty?

    parts = version_string.split('+')
    version = parts[0]

    UI.user_error!("pubspec.yaml version '#{version_string}' is missing build number (expected format: x.y.z+n)") unless parts.length == 2

    build_number = parts[1].to_i
    UI.message("Using version from pubspec.yaml: #{version}+#{build_number}")

    # 2. Validate version
    existing_versions = begin
      google_play_track_version_codes(track: "internal")
    rescue => e
      UI.user_error!("āŒ Could not verify existing versions on Play Store: #{e.message}")
    end

    if existing_versions.include?(build_number)
      UI.user_error!("āŒ Version code #{build_number} already exists on Internal track. Please increment the build number in pubspec.yaml")
    end

    UI.success("āœ“ Version validation passed - #{version}+#{build_number}")

    # 3. Build AAB
    sh("cd ../.. && flutter build appbundle --release")

    # 4. Prepare Changelog
    changelog_path = "./metadata/android/en-US/release_notes.txt"
    changelog_text = File.exist?(changelog_path) ? File.read(changelog_path) : "Beta build v#{version} (#{build_number})"

    dest_changelog_dir = "./metadata/android/en-US/changelogs"
    FileUtils.mkdir_p(dest_changelog_dir)
    File.write("#{dest_changelog_dir}/#{build_number}.txt", changelog_text)

    UI.success("āœ… Build #{build_number} (v#{version}) completed successfully")
  end

  # Error handling
  error do |lane, exception|
    UI.error("āŒ Error in lane #{lane}: #{exception.message}")
  end
end

Step 7: Available Commands

šŸš€ Upload to Internal Testing

bash
cd android
bundle exec fastlane beta

What happens:

  1. Reads version from
    text
    pubspec.yaml
  2. Validates version code against Play Store
  3. Builds Flutter AAB (
    text
    flutter build appbundle --release
    )
  4. Prepares changelog
  5. Uploads to Play Store Internal Testing track

šŸŽ Promote to Production

bash
cd android
bundle exec fastlane production

What happens:

  1. Reads version from
    text
    pubspec.yaml
  2. Verifies build exists on Internal track
  3. Promotes existing build to Production (no rebuild)

šŸ”Ø Build Only (No Upload)

bash
cd android
bundle exec fastlane build_only

What happens:

  1. Validates version
  2. Builds AAB file
  3. Does not upload to Play Store

Step 8: Important Notes

Before running Fastlane, ensure:

1. App is Buildable āœ…

Test your build process first:

bash
flutter build appbundle --release

Ensure it completes without errors.


2. Signing Configs Are Set āœ…

Create signing key:

bash
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload

Configure

text
android/key.properties
:

properties
storePassword=YourStorePassword
keyPassword=YourKeyPassword
keyAlias=upload
storeFile=/Users/yourname/upload-keystore.jks

Update

text
android/app/build.gradle
:

groovy
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

android {
    ...
    signingConfigs {
        release {
            keyAlias keystoreProperties['keyAlias']
            keyPassword keystoreProperties['keyPassword']
            storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
            storePassword keystoreProperties['storePassword']
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

3. Increment Version in pubspec.yaml āœ…

Before each release:

yaml
# pubspec.yaml

# WRONG - Same as previous release
version: 1.0.0+1

# CORRECT - Incremented build number
version: 1.0.0+2

# OR increment version
version: 1.1.0+1

Rules:

  • Increment version code (+1, +2) for internal builds
  • Increment version (1.0.0 → 1.1.0) for production releases

4. First Manual Release Completed āœ…

Fastlane requirements:

  • āŒ Cannot handle the very first Play Store submission
  • āœ… Can handle all updates after the first manual release

First release checklist:

  1. Build AAB manually
  2. Upload to Play Console manually
  3. Complete store listing (screenshots, description)
  4. Submit for review
  5. After approval, use Fastlane for all future releases

CI/CD Integration

GitHub Actions Example

Create

text
.github/workflows/android-release.yml
:

yaml
name: Android Release

on:
  push:
    tags:
      - 'v*'  # Trigger on version tags (e.g., v1.0.0)

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
          channel: 'stable'

      - name: Install dependencies
        run: flutter pub get

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.0'
          bundler-cache: true

      - name: Install fastlane
        run: |
          cd android
          bundle install

      - name: Create .env file
        run: |
          cd android/fastlane
          cat << EOF > .env
          PACKAGE_NAME=${{ secrets.PACKAGE_NAME }}
          PLAY_STORE_JSON_PATH=fastlane/play-store-credentials.json
          EOF

      - name: Decode Play Store credentials
        env:
          PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }}
        run: |
          cd android/fastlane
          echo "$PLAY_STORE_CREDENTIALS" | base64 --decode > play-store-credentials.json
          chmod 600 play-store-credentials.json

      - name: Decode signing key
        env:
          SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
        run: |
          echo "$SIGNING_KEY" | base64 --decode > android/app/upload-keystore.jks

      - name: Create key.properties
        env:
          KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
        run: |
          cd android
          cat << EOF > key.properties
          storePassword=$KEY_STORE_PASSWORD
          keyPassword=$KEY_PASSWORD
          keyAlias=$KEY_ALIAS
          storeFile=../app/upload-keystore.jks
          EOF

      - name: Build and deploy to Internal Testing
        run: |
          cd android
          bundle exec fastlane beta

Required GitHub Secrets:

Secret NameValue
text
PACKAGE_NAME
Your app's package name (e.g.,
text
com.example.app
)
text
PLAY_STORE_CREDENTIALS
Base64 encoded JSON credentials file
text
SIGNING_KEY
Base64 encoded upload-keystore.jks
text
KEY_STORE_PASSWORD
Keystore password
text
KEY_PASSWORD
Key password
text
KEY_ALIAS
Key alias (e.g.,
text
upload
)

To create base64 secrets:

bash
# Encode Play Store credentials
base64 -i play-store-credentials.json | pbcopy

# Encode signing key
base64 -i upload-keystore.jks | pbcopy

Troubleshooting

Issue: "Version code already exists on Internal track"

Error:

text
āŒ Version code 42 already exists on Internal track. Please increment the build number in pubspec.yaml

Solution:

Update

text
pubspec.yaml
:

yaml
# Before
version: 1.0.0+42

# After
version: 1.0.0+43

Issue: "Could not authenticate with Play Store"

Error:

text
Google Play API error: Invalid Credentials

Solution:

  1. Verify JSON file path in

    text
    .env
    :

    bash
    PLAY_STORE_JSON_PATH=fastlane/play-store-credentials.json
  2. Ensure JSON file exists:

    bash
    ls -la android/fastlane/play-store-credentials.json
  3. Verify service account has permissions in Play Console


Issue: "Signing configuration not found"

Error:

text
Execution failed for task ':app:validateSigningRelease'

Solution:

  1. Verify

    text
    key.properties
    exists:

    bash
    cat android/key.properties
  2. Ensure paths are correct in

    text
    key.properties

  3. Verify keystore file exists:

    bash
    ls -la ~/upload-keystore.jks

Issue: "AAB file not found"

Error:

text
Could not find AAB file at ../build/app/outputs/bundle/release/app-release.aab

Solution:

Build the AAB manually first:

bash
flutter build appbundle --release

Verify it exists:

bash
ls -la build/app/outputs/bundle/release/app-release.aab

Best Practices

1. Always Use Bundler āœ…

bash
# WRONG
fastlane beta

# CORRECT
bundle exec fastlane beta

2. Version Control Fastlane Files āœ…

Commit to Git:

  • āœ…
    text
    Gemfile
  • āœ…
    text
    Gemfile.lock
  • āœ…
    text
    Fastfile
  • āœ…
    text
    Appfile

Add to .gitignore:

  • āŒ
    text
    .env
  • āŒ
    text
    *.json
    (credentials)
  • āŒ
    text
    key.properties
  • āŒ
    text
    *.jks
    (keystore files)

3. Test Locally First āœ…

Before CI/CD:

  1. Test
    text
    bundle exec fastlane beta
    locally
  2. Verify upload to Play Console
  3. Test internal track installation
  4. Then configure GitHub Actions

4. Use Play Tracks Properly āœ…

Recommended workflow:

text
Development → Internal Testing (fastlane beta)
               ↓
            Closed Beta
               ↓
            Open Beta
               ↓
            Production (fastlane production)

5. Create Release Checklist āœ…

markdown
Before release:
- [ ] Update version in pubspec.yaml
- [ ] Test app thoroughly
- [ ] Run fastlane beta
- [ ] Test on Internal track
- [ ] Promote to production

Summary

TaskCommandDescription
Install Fastlane
text
brew install fastlane
Install via Homebrew
Initialize
text
fastlane init
Setup Fastlane in project
Install Dependencies
text
bundle install
Install Ruby gems
Upload to Internal
text
bundle exec fastlane beta
Build and upload
Promote to Production
text
bundle exec fastlane production
Promote existing build
Build Only
text
bundle exec fastlane build_only
Build AAB only

Final Thoughts

With Fastlane configured, your Android release process becomes:

bash
# Update version in pubspec.yaml
version: 1.2.0+43

# One command for Internal Testing
bundle exec fastlane beta

# One command for Production
bundle exec fastlane production

That's it. Clean. Automated. Reliable. šŸš€


What You've Automated

āœ… No more manual AAB uploads āœ… No duplicate version code mistakes āœ… Version validation from pubspec.yaml āœ… One command = Full Internal release āœ… One command = Production promotion āœ… CI/CD ready with GitHub Actions

Your Flutter Android deployment is now fully automated!

Official Documentation: