Here East, London, September 2025
Introduction
This post describes how to automate the deployment of an Android app to Google Play from its code on GitHub using GitHub Actions. The app itself is covered in this post here. An earlier post on GitHub Actions is here. The notes are based on ongoing work on the app. To deploy the app to Google Play involves the following steps:
1. Building an Android App Bundle (An .aab file) from the app’s Kotlin scripts on GitHub
2. As the app is being built inject the API keys it uses rather than have them in the code on GitHub
3. Sign the app with my private key to authenticate it
4. Log into Google Play and upload the app
These can be done using Android Studio, but this involves manually signing and uploading the app to Google Play whenever a change is made to it. Automating this process with a GitHub Action makes deploying updates quicker and integrates them more directly with version control.
This post covers implementing a GitHub Action that automates this process by:
1. Specifying the event that triggers the Action
2. Specifying where the Action is run and the secrets that are used to build and sign the app
3. Setting up the actions that are needed to clone the repo and build the app
4. Publishing the app on Google Play
The script of the GitHub Action which does this is below. It is saved in the app repo as a yaml file at the location /.github/workflows/app_launch_to_play.yml.
This post covers the parts of the action in turn.
name: Deploy to Google Play
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
env:
MODULE: app
MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }}
LISTED_API_KEY: ${{ secrets.LISTED_API_KEY }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Give permissions to the gradle wrapper (gradlew)
run: chmod +x ./gradlew
- name: Restore keystore
run: |
ANDROID_KEYSTORE="$RUNNER_TEMP/upload.jks"
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$ANDROID_KEYSTORE"
echo "ANDROID_KEYSTORE=$ANDROID_KEYSTORE" >> "$GITHUB_ENV"
- name: Create placeholder local.properties (optional)
run: '[ -f local.properties ] || echo "# generated by CI" > local.properties'
- name: Set version code
run: echo "VERSION_CODE=${{ github.run_number }}" >> $GITHUB_ENV
- name: Build a Release Bundle and publish to Internal testing
env:
ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} # raw JSON
run: |
./gradlew :${{ env.MODULE }}:publishReleaseBundle \
-PversionCode=${{ env.VERSION_CODE }} \
-Pplay.track=internal \
-Pplay.releaseStatus=draft \
-Pplay.changesNotSentForReview=true \
--info --no-daemon --rerun-tasks --no-configuration-cache
1. Specifying the event that triggers the Action
In this case the action is triggered by a commit to the main branch of the app repo on GitHub. For example if we push a branch to the app’s GitHub repo and then merge it into main then this starts the action to build the app from its Kotlin scripts.
name: Deploy to Google Play
on:
push:
branches:
- main
2. Specifying where the Action is run and the secrets that are used to build and sign the app
The action is run on an ubuntu-latest instance with read permissions. The name of the module that will be built and published is specified by the MODULE environment variable. The default module
name for most Kotlin apps is ‘app’. The module name can be found from the include statement in the settings.gradle.kts e.g. include(":app").
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
env:
# App module path (change if your module isn’t named `app`)
MODULE: app
MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }}
LISTED_API_KEY: ${{ secrets.LISTED_API_KEY }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
The app requires two API keys and the private key information to sign the app (The Android KEYSTORE_PASSWORD to get access to the keystore, KEY_ALIAS to identify the key and the KEY_PASSWORD). We
don’t want to add these in the repo code so they are added as GitHub secrets. These can be added by going to the Settings tab followed by Secrets and variables and Actions as shown in the example
below.

This information is then accessed by the action using ${{secrets.xxxxxx}}. Here it is read into the environment of the GitHub Action so that the API keys and signing key are available
to Gradle. The information is then extracted from the environment when the Kotlin app is built by the app’s Build.gradle.kts script to insert the API keys into the app and then sign it with the private
key. The Kotlin code in the app used to extract the action’s environment variables (shown here for the example of ANDROID_KEYSTORE) has the form:
val ksPath: String? = System.getenv("ANDROID_KEYSTORE")
3. Setting up the actions that are needed to clone the repo and build the app
The action uses a number of other actions. The action checkout@v4 clones the GitHub repo. The two subsequent actions setup-java@v4 and setup-gradle@v4 sets up Java and Gradle to
allow the app to be build. Gradle is a tool that compiles projects in Java-related languages like Kotlin. The chmod +x ./gradlew allows the Gradle wrapper (which
enables Gradle to be run from the command line) to be run by the action to build the app bundle.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Give permissions to the Gradle wrapper (gradlew)
run: chmod +x ./gradlew
- name: Restore keystore from base64
run: |
ANDROID_KEYSTORE="$RUNNER_TEMP/upload.jks"
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$ANDROID_KEYSTORE"
echo "ANDROID_KEYSTORE=$ANDROID_KEYSTORE" >> "$GITHUB_ENV"
- name: Create placeholder local.properties
run: '[ -f local.properties ] || echo "# generated by CI" > local.properties'
To give access to the signing key in the Android keystore in ANDROID_KEYSTORE="$RUNNER_TEMP/upload.jks" the variable ANDROID_KEYSTORE is defined which will be saved in the temporary location
upload.jks by the action. The command echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$ANDROID_KEYSTORE" decodes the keystore secret value from base64 to the variable. The reason
for this is that GitHub Actions can’t store binary information in a secret. We therefore store it in base64 (an encoding from binary to 64 characters) and get the action to convert it to a binary
and then write it back to the GitHub environment GITHUB_ENV). The GitHub environment is used as, unlike the action environment env where the values are static, it allows us to create new
variables while the action runs (here the decoded keystore value).
When testing the app locally we have the API keys in a local properties file that is not committed to Git. The local.properties file is therefore not in the remote and if available the app uses the API and signing key information from the action’s environments in the first instance. However, the local properties file is used in the build process so we get the GitHub Action to create it.
4. Publishing the app on Google Play
To update the version number each time the app is rebuilt we write the action’s github.run_number to the GITHUB_ENV as the VERSION_CODE.
The action then uses the credentials ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON } which has also been added to the secrets to login to Google Play.
There are different release tracks on Google Play: Production (The app is fully available), Open testing (The app is publicly available for testing on Google Play) and Closed and Internal testing (the app is released to a larger and smaller group of private testers respectively). There is also a release status.
Having logged into Google Play the action then uses gradlew command publishReleaseBundle to publish a release bundle with the specified version_code. Here the
track is the internal one with the draft status. As the app is still being worked on in the internal track the changes are not sent for review.
run: echo "VERSION_CODE=${{ github.run_number }}" >> $GITHUB_ENV
- name: Build a Release Bundle and publish to Internal testing
env:
ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} # raw JSON
run: |
./gradlew :${{ env.MODULE }}:publishReleaseBundle \
-PversionCode=${{ env.VERSION_CODE }} \
-Pplay.track=internal \
-Pplay.releaseStatus=draft \
-Pplay.changesNotSentForReview=true \
--info --no-daemon --rerun-tasks --no-configuration-cache
When a change is made to the main branch the action runs through the specified stages. Here a previous run is shown in the app repo’s GitHub Actions tab:
The latest version of the app is then visible on Google Play on the internal testing track.

Previous cloud posts:
Cloud 1: Introduction to launching a Virtual Machine in the Cloud
Cloud 2: Getting started with using a Virtual Machine in the Cloud
Cloud 3: Docker and Jupyter notebooks in the Cloud
Cloud 4: Using Serverless
Cloud 5: Introduction to deploying an app with simple CI/CD
Cloud 6: Introduction to Infrastructure as Code using CloudFormation
Cloud 7: Building an API with Lambda, Docker and CloudFormation
References:
Using secrets in GitHub Actions
The Gradle command line guide
Android developer publishing guidance
Google Play console guidance