Android CI/CD Pipeline: Fastlane, GitHub Actions, and Play Store Deployment

Android CI/CD Pipeline: Fastlane, GitHub Actions, and Play Store Deployment

Status: In Progress

Setting up a CI/CD pipeline is critical for scaling your development and release process, for this article will be describe as short and simple note for referencing Compose Multiplatform project integrating Fastlane, GitHub Actions, and Play Store Deployment for Android module.


Table of Contents


Step 1. Install fastlane and set up your Fastfile

Install using one of the recommended installation methods

brew install fastlane

Project structure

project_root
	/ composeApp <-- code source for Android and kotlin code
	/ iosApp <-- code source for iOS
	/ ... other files

Initializing Fastlane in Your Project

For Android:

cd project_root/composeApp
fastlane init

For iOS:

cd project_root/iosApp
fastlane init

During the initialization, Fastlane will ask you a series of questions.

Package Name (com.krausefx.app)
Path to the json secret file: 
Download existing metadata and setup metadata management? (y/n)
Continue by pressing Enter ⏎

After initialization, you should see a new fastlane directory

composeApp/fastlane

.
├── Appfile ⭐️
├── Fastfile⭐️
├── Pluginfile
├── README.md
└── report.xml

iosApp/fastlane

.
├── Appfile
├── Fastfile
├── Pluginfile
├── README.md
├── report.xml
└── yaml
    └── app_distribution
        ├── dev.yaml
        ├── release.yaml
        └── staging.yaml

Appfile and Fastfile are the most important files to deploy fastlane.

Appfile defines the metadata to deploy your project’s application.

Fastfile: This is where we define our lanes (automation tasks).

Next, we’ll install a plugin that we’ll use to automate version code increments for android. Open sourced Plugins : It will also create a Pluginfile file in your fastlane directory that you can use to manage plugins:

fastlane add_plugin fastlane-plugin-increment_version_code

Pluginfile

# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!

gem 'fastlane-plugin-firebase_app_distribution'
gem 'fastlane-plugin-increment_version_code'

Now everything installed let edit Fastfile and create work flow below:

Now that everything is installed, let’s proceed with editing the Fastfile to define the necessary lanes and then create the corresponding workflow below. The Fastfile is where you define your Fastlane lanes, such as building the app, running tests, and uploading to the Play Store. Fastfile


default_platform(:android)

platform :android do
  desc "Runs all the tests except iOS"
  lane :test do
    gradle(
     task: "test",
     gradle_path: "../gradlew"
    )
  end


  desc "Submit a new Beta Build to Crashlytics Beta"
  lane :beta do
    gradle(
      task: "clean assembleRelease",
      gradle_path: "../gradlew"
    )
    crashlytics

    # if you want to upload to Firebase distribution beta build
    # sh "your_script.sh"
    # You can also use other beta testing services here
  end

  def safe_track_codes(package_name:, track:, json_key:)
    begin
      arr = google_play_track_version_codes(
        package_name: package_name,
        track: track,               # "internal", "production", etc.
        json_key: json_key
      )
      Array(arr).compact.sort      # ensure array + ascending order
    rescue => e
      UI.important("track_version_codes failed for track=#{track.inspect}: #{e.class} #{e.message}")
      []
    end
  end

  desc "Fetches the latest version code from the Play Console and increments it by 1"
  lane :fetch_and_increment_build_number do
    app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier)

    internal_codes   = safe_track_codes(package_name: app_identifier, track: "internal",   json_key: ENV["ANDROID_JSON_KEY_FILE"])
    production_codes = safe_track_codes(package_name: app_identifier, track: "production", json_key: ENV["ANDROID_JSON_KEY_FILE"])

    UI.message("Internal codes: #{internal_codes.inspect}")
    UI.message("Production codes: #{production_codes.inspect}")

    latest_code = [internal_codes.last, production_codes.last].compact.max || 0
    updated_version_code = latest_code + 1

    version_name = Time.now.strftime("%Y.%m.%d.%H%M")

    ENV["VERSION_CODE"] = updated_version_code.to_s
    ENV["VERSION_NAME"] = version_name

    UI.success("Next versionCode: #{updated_version_code} | versionName: #{version_name}")
  end

  desc "Deploy a new version to the Google Play (Internal Testing Track)"
  lane :deploy_internal do
      fetch_and_increment_build_number

      gradle(
        task: "bundleRelease",
        gradle_path: "../gradlew",
        properties: {
          "VERSION_CODE" => ENV["VERSION_CODE"],
          "VERSION_NAME" => ENV["VERSION_NAME"],
          "android.injected.signing.store.file" => ENV["KEYSTORE_FILE"],
          "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
          "android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
          "android.injected.signing.key.password" => ENV["KEY_PASSWORD"],
        }
      )

      upload_to_play_store(
        track: "internal",
        json_key: ENV["ANDROID_JSON_KEY_FILE"],
        release_status: "draft",
        skip_upload_changelogs: true
      )
  end

  desc "Deploy to Production"
  lane :deploy_production do
    fetch_and_increment_build_number

    gradle(
      task: "bundleRelease",
      gradle_path: "../gradlew",
      properties: {
        "VERSION_CODE" => ENV["VERSION_CODE"],
        "VERSION_NAME" => ENV["VERSION_NAME"],
        "android.injected.signing.store.file" => ENV["KEYSTORE_FILE"],
        "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
        "android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
      }
    )

    upload_to_play_store(
      track: "production",
      json_key: ENV["ANDROID_JSON_KEY_FILE"],
      release_status: "completed",
      skip_upload_changelogs: true
    )
  end

end

Step 2. Configure your secrets in GitHub’s encrypted secrets

To authenticate with the Google Play Developer API, we need a service account key, which allows the pipeline to securely deploy builds to the Play Store without manual interaction.

Create a Service Account in Google Cloud

  • Go to the Google Cloud Console.
  • Select your project or create a new one if needed.
  • Navigate to IAM & Admin > Service Accounts.
  • Click on Create Service Account.
  • Give it a name and description (e.g., “Fastlane Play Store Account”).
  • Assign the “Service Account User” or “Owner” role or specific roles related to Google Play if necessary.
  • After creating the service account, you’ll be prompted to Create Key.
  • Choose JSON format for the key and download the JSON file. This is the file that Fastlane will need.

Grant your service account access to the Play Console

Before your service account can be used to make requests to the Google Play Developer API, it must be granted access to your apps in the Google Play Console.

  1. Go to the Google Play Console.
  2. In the Google Play Console, navigate to Users and permissions.
  3. In the Users and permissions page, click Invite users under the action menu on the far right.
  4. In the Invite user form, paste the email address of your service account. Leave the Access expiry checkbox unchecked.

Scroll down to App permissions and click Add app.

Your service account must be granted the following permissions:

  • View app information (readonly)
  • Manage store presence
  • Release to production, exclude devices, and use Play App Signing
  • Release apps to testing tracks
  • Manage testing tracks and edit tester lists

Add new secret to GitHub’s encrypted secrets,

  1. Go to GitHub Setting
  2. Go to Secret and variables
  3. Actions secrets, click “New repository secret”

GitHub secrets only accept string values, so you’ll first need to convert the file (.jks or .json files) to a base64-encoded string before adding it to GitHub secrets.

base64 -i service_account_key.json | pbcopy

Let's save GPLAY_SERVICE_ACCOUNT_KEY_JSON name for the base64-encoded service account key JSON

Storing your Android signing key

  • ANDROID_KEYSTORE_FILE: The base64-encoded .jks or .keystore file used for signing Android builds, either for Play App Signing(upload key) or your app signing key.
  • ANDROID_KEYSTORE_PASSWORD: the password associated with the keystore file
  • ANDROID_KEY_ALIAS: the key store alias
  • ANDROID_KEY_PASSWORD: the private key password
base64 -i service_account_key.json | pbcopy

Step 3. Set up GitHub Actions workflow .yml file

To set up your Android GitHub Actions workflow, first create the .github and workflows folders, then create a .yml file. From your project’s root directory, run the following command:

mkdir .github && cd .github && mkdir workflows && cd workflows && touch build-upload-android.yml

Next, paste the following code into the newly created build-upload-android.yml file:

name: Android Build & Deploy Production

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      BUNDLE_GEMFILE: ${{ github.workspace }}/composeApp/Gemfile
      BUNDLE_PATH: vendor/bundle

    steps:
      - uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - name: Set up Ruby (installs gems from composeApp/Gemfile via BUNDLE_GEMFILE)
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.2
          bundler-cache: true

      - name: Cache Gradle
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            gradle-${{ runner.os }}-

      - name: Run tests (Fastlane)
        working-directory: composeApp
        run: bundle exec fastlane android test

      - name: Decode Service Account Key JSON File
        uses: timheuer/base64-to-file@v1
        id: service_account_json_file
        with:
          fileName: "serviceAccount.json"
          encodedString: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY_JSON }}

      - name: Decode Keystore File
        uses: timheuer/base64-to-file@v1
        id: android_keystore
        with:
          fileName: "android_keystore.keystore"
          encodedString: ${{ secrets.ANDROID_KEYSTORE_FILE }}

      - name: Build & deploy Android release (Fastlane)
        working-directory: composeApp
        env:
          KEYSTORE_FILE: ${{ steps.android_keystore.outputs.filePath }}
          KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          ANDROID_JSON_KEY_FILE: ${{ steps.service_account_json_file.outputs.filePath }}
        run: bundle exec fastlane android deploy_production

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: assets
          path: |
            composeApp/**/build/outputs/bundle/release/**/*.aab
            composeApp/**/build/outputs/apk/release/**/*.apk

Next, we’ll edit build.gradle.kts to automate the increment of the Android version name and code.

android {
	..
 defaultConfig {
        applicationId = "com.example.app"
        minSdk = libs.versions.android.minSdk.get().toInt()
        targetSdk = libs.versions.android.targetSdk.get().toInt()

        versionCode = if (project.hasProperty("VERSION_CODE")) {
            (project.property("VERSION_CODE") as String).toInt()
        } else {
            1
        }

        versionName = if (project.hasProperty("VERSION_NAME")) {
            project.property("VERSION_NAME") as String
        } else {
            "1.0.0"
        }
    }
	..
}

Step 4. Run your build!

Now, you can commit and push up your newly created GitHub workflow file, which will appear under the “Actions” tab in your repo and can be triggered on pushes to the main branch as well as on all pull requests targeting main.

If you want to create a GitHub Actions workflow, you can configure it in the build-upload-android.yml file as shown in the example below. Once set up, it will appear in your repository under the Actions tab.

name: Android Build & Deploy
 
on:
 workflow_dispatch:
 
 ..

And that’s it — we’ll continue with iOS module next. 😁✌️

#ComposeMultiplatform #Android #CI/CD #GooglePlayDeveloper