Here East, London, September 2025
Introduction
This post looks at automating the deployment of an Android app to Google Play with GitHub Actions. The app itself is covered in this post here. An earlier post on GitHub Actions is here. The notes are based on the ongoing work on the app. To deploy the app to Google Play involved 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 steps 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 the following steps via a GitHub Action:
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 steps 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 push 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 the Kotlin scripts, signing it and publishing it to Google Play.
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 settings.gradle.kts file in the app code and is specified as 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. 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. These are then accessed by the action using the ${{secrets.xxxxxx}} format. Here the information is read into the environment of
the GitHub Action so that the API keys and signing key are available to Gradle. This 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) is:
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.
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.
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'
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 } 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 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