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

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
- Step 2. Configure your secrets in GitHub’s encrypted secrets
- Step 3. Set up GitHub Actions workflow .yml file
- Step 4. Run your build!
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.
- Go to the Google Play Console.
- In the Google Play Console, navigate to Users and permissions.
- In the Users and permissions page, click Invite users under the action menu on the far right.
- 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,
- Go to GitHub Setting
- Go to Secret and variables
- 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