How to publish a Kotlin Multiplatform Android app on Play Store with GitHub Actions

SERIES: Publishing a Kotlin Multiplatform Android, iOS, and macOS app with GitHub Actions.

It’s been almost a year since I started working on FeedFlow, an RSS Reader available on Android, iOS, and macOS, built with Jetpack Compose for the Android app, Compose Multiplatform for the desktop app, and SwiftUI for the iOS app.

To be faster and “machine-agnostic” with the deployments, I decided to have a CI (Continuous Integration) on GitHub Actions to quickly deploy my application to all the stores (Play Store, App Store for iOS and macOS, and on GitHub release for the macOS app).

Today, I’m starting a series of posts about the topic.

In this post, I will show how to deploy a Kotlin Multiplatform Android app to Google Play. To keep up to date, you can check out the other posts of the series in the index above or follow me on Mastodon or Twitter.

Triggers

A trigger is necessary to start the GitHub Action. I’ve decided to trigger a new release when I add a tag that ends with the platform name, in this case, -android. So, for example, a tag would be 0.0.1-android.

on:
  push:
    tags:
      - '*-android'

This way, I can be more flexible when making platform-independent releases.

Gradle and JDK setup

The first part of the pipeline involves cloning the repo and setting up the infrastructure.

Clone the repository

The actions/checkout action can be used to clone the repository:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

JDK Setup

The actions/setup-java action can be used to set up a desired JDK. In this case, I want the zulu distribution and version 18.

- name: set up JDK
  uses: actions/setup-java@v4
  with:
    distribution: 'zulu'
    java-version: 18

Gradle Setup

The gradle/actions/setup-gradle action can be used to set up Gradle with its cache.

In the action, I’m using two parameters: gradle-home-cache-cleanup and cache-encryption-key.

The gradle-home-cache-cleanup parameter will enable a feature that will try to delete any files in the Gradle User Home that were not used by Gradle during the GitHub Actions Workflow before saving the cache. In this way, some space can be saved. More info can be found in the documentation.

The cache-encryption-key parameter provides an encryption key from GitHub secrets to encrypt the configuration cache. The configuration cache might contain stored credentials and other secrets, so encrypting it before saving it on the GitHub cache is better. More info can be found in the documentation.

- uses: gradle/actions/setup-gradle@v3
  with:
    gradle-home-cache-cleanup: true
    cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

Kotlin Native setup

When compiling a Kotlin Multiplatform project that also targets Kotlin Native, some required components will be downloaded in the $USER_HOME/.konan directory. Kotlin Native will also create and use some cache in this directory.

├── .konan
│   ├── cache
│   ├── dependencies
│   └── kotlin-native-prebuilt-macos-aarch64-1.9.23

Caching that directory will avoid redownloading and unnecessary computation. The actions/cache action can be used to do so.

The action requires a key to identify the cache uniquely; in this case, the key will be a hash of the version catalog file since the Kotlin version number is stored there:

- name: Cache KMP tooling
  uses: actions/cache@v4
  with:
    path: |
            ~/.konan
    key: ${{ runner.os }}-v1-${{ hashFiles('*.versions.toml') }}

Setup Keystore

Every release version of an Android app needs to be signed with a developer key. For security reasons, the keystore that contains certificates and private keys can’t be publicly released, so storing it as a GitHub secret is a good approach

A keystore, to be saved inside the secrets, needs to be in a “shareable and encrypted” format (ASCII-armor encrypted). This format can be generated with the following command and by providing a passphrase:

gpg -c --armor your-keystore

The output of the previous command can now be uploaded on GitHub secrets, alongside the passphrase and decrypted with the following command:

echo '${{ secrets.KEYSTORE_FILE }}'> release.keystore.asc
gpg -d --passphrase '${{ secrets.KEYSTORE_PASSPHRASE }}' --batch release.keystore.asc > androidApp/release.keystore

In addition to the keystore file, some other info is required to successfully sign the app, like the key alias, the key password, and the keystore password. This info is provided in the signing configuration of the app in the app/build.gradle.kts file:

signingConfigs {
    create("release") {
        keyAlias = ..
        keyPassword = ..
        storeFile = ..
        storePassword = ..
    }
}

The keyAlias, keyPassword, and storePassword can be saved in the GitHub secrets and provided to Gradle through a properties file that the GitHub Action will create:

val local = Properties()
val localProperties: File = rootProject.file("keystore.properties")
if (localProperties.exists()) {
    localProperties.inputStream().use { local.load(it) }
}

signingConfigs {
    create("release") {
        keyAlias = local.getProperty("keyAlias")
        keyPassword = local.getProperty("keyPassword")
        storeFile = file(local.getProperty("storeFile") ?: "NOT_FOUND")
        storePassword = local.getProperty("storePassword")
    }
}

Here’s the complete step, with the keystore decrypting and the properties file creation:

- name: Configure Keystore
  run: |
    echo '${{ secrets.KEYSTORE_FILE }}'> release.keystore.asc
    gpg -d --passphrase '${{ secrets.KEYSTORE_PASSPHRASE }}' --batch release.keystore.asc > androidApp/release.keystore
    echo "storeFile=release.keystore" >> keystore.properties
    echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> keystore.properties
    echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> keystore.properties
    echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> keystore.properties    
  env:
    KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
    KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
    KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}

[Optional] Firebase configuration or other secrets

GitHub secrets can be leveraged to store any sensitive stuff or configuration that can’t be exposed to version control.

To do so, any file can be encoded with base64 and saved inside a GitHub secret.

base64 -i myfile.extension

Then, the GitHub action can decode the content and create the file. For example, here’s the step for the Firebase JSON configuration:

- name: Create Firebase json
  run: |
    echo "$FIREBASE_JSON" > androidApp/google-services.json.b64
    base64 -d -i androidApp/google-services.json.b64 > androidApp/google-services.json    
  env:
    FIREBASE_JSON: ${{ secrets.FIREBASE_JSON }}

Publish on Google Play Console

To publish the APK or AAB to the Play Console, I’m using the Gradle Play Publisher plugin.

To communicate and authenticate with the Play Console, the plugin requires a Service Account. The steps needed to create a Service Account for the Play Console are well described in the plugin documentation. All the information required for the authentication will be contained in a JSON file that needs to be provided to the Gradle plugin:

play {
    serviceAccountCredentials.set(file("../play_config.json"))
    track.set("alpha")
}

In the plugin configuration, I also specify that I want to upload the APK on the Alpha track of the Play Console. The plugin is very customizable, and all the possibilities can be found in the documentation.

N.B. The first version on the Play Console needs to be manually uploaded before using any automation.

To provide the JSON in the GitHub Action, the method described in the previous section can be used: the content of the JSON will be stored in the GitHub secrets encoded in base64.

- name: Create Google Play Config file
  run: |
    echo "$PLAY_CONFIG_JSON" > play_config.json.b64
    base64 -d -i play_config.json.b64 > play_config.json    
  env:
    PLAY_CONFIG_JSON: ${{ secrets.PLAY_CONFIG }}

The publishBundle Gradle command can be used to upload the app on the Play Console:

- name: Distribute app to Alpha track
  run: ./gradlew :androidApp:bundleRelease :androidApp:publishBundle

Conclusions

And that’s all the steps required to automatically publish a Kotlin Multiplatform Android app on the Play Console with a GitHub Action.

Here’s the entire GitHub Action for reference:

name: Android Alpha Release

on:
  push:
    tags:
      - '*-android'

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: set up JDK
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: 18

      - uses: gradle/actions/setup-gradle@v3
        with:
          gradle-home-cache-cleanup: true
          cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

      - name: Cache KMP tooling
        uses: actions/cache@v4
        with:
          path: |
                        ~/.konan
          key: ${{ runner.os }}-v1-${{ hashFiles('*.versions.toml') }}

      - name: Configure Keystore
        run: |
          echo '${{ secrets.KEYSTORE_FILE }}'> release.keystore.asc
          gpg -d --passphrase '${{ secrets.KEYSTORE_PASSPHRASE }}' --batch release.keystore.asc > androidApp/release.keystore
          echo "storeFile=release.keystore" >> keystore.properties
          echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> keystore.properties
          echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> keystore.properties
          echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> keystore.properties          
        env:
          KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
          KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
          KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}

      - name: Create Firebase json
        run: |
          echo "$FIREBASE_JSON" > androidApp/google-services.json.b64
          base64 -d -i androidApp/google-services.json.b64 > androidApp/google-services.json          
        env:
          FIREBASE_JSON: ${{ secrets.FIREBASE_JSON }}

      - name: Create Google Play Config file
        run: |
          echo "$PLAY_CONFIG_JSON" > play_config.json.b64
          base64 -d -i play_config.json.b64 > play_config.json          
        env:
          PLAY_CONFIG_JSON: ${{ secrets.PLAY_CONFIG }}

      - name: Distribute app to Alpha track
        run: ./gradlew :androidApp:bundleRelease :androidApp:publishBundle

You can check the action on GitHub