Deploying your Android app to the Play Store (without Fastlane or Bitrise!)

Bryen Vieira
Go City Engineering
13 min readSep 28, 2023
An Android device showing the Google Play Store
Photo by Mika Baumeister on Unsplash

Recently, my team has been working to move away from Bitrise and bring our CI pipeline in line with our other projects, which meant moving the building, testing, and releasing of our Android apps onto to CircleCI.

I’ve written this blog post to provide some guidance for anyone else who might want to be in control of their CI pipeline without using Fastlane or Bitrise. There there isn’t a lot of recently-written, well-compiled guidance out there for this scenario and - as is so often the case - the Google Developer documentation was lacking in a few places!

TL;DR: You could use a good Bitrise or Fastlane tutorial and get set up pretty quickly, but if you want a reusable Bash script which can build, sign, and deploy your app, allowing you the flexibility to use whatever CI/CD platform you like, then this guide is for you. We used this to reduce our costs significantly by getting rid of Bitrise and moving over to CircleCI, which was already in widespread use across our business.

The Bash Script Approach

First, you’ll need to create a service account for CircleCI to access the Play Console APIs. Follow this guide (under the heading ‘use a service account’) and make sure you save the JSON file created by this flow.

Dialog encountered whilst creating a new private key JSON file in Google Cloud Platform
Save the JSON file created as a result of this step — you’ll need to add some values from this file to a CircleCI context so that they can be used by the Bash script

You will then need to set up a CircleCI context (we’ve called it android-release), which should contain the following variables — some of these are obtained from the JSON you just downloaded after creating your service account, whilst others will come from your release keystore:

  • GCP_AUTH_ISS = The email for the service account, i.e. the value of client_email in the JSON.
  • GCP_AUTH_AUD = The intended recipient of the token, i.e. the value of token_uri in the JSON.
  • GCP_AUTH_TOKEN = The private key created by this flow, i.e. the value of private_key in the JSON.
  • ANDROID_KEYSTORE_ALIAS = Your keystore’s alias.
  • ANDROID_KEYSTORE_PASSWORD = The password to your keystore file.
  • ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD = Your keystore private key.
  • ANDROID_KEYSTORE = Your base64-encoded keystore, which can be obtained like so: openssl base64 -A -in your_app.keystore > encoded_keystore

Bash Script

This script we’re about to create will take the following variables and upload our AAB to the Play Console:

  • PACKAGE_NAME = Your app’s package name, e.g. com.mysite.myapp
  • VERSION_CODE = The version code (not version name) for your app (i.e. something like 3564, not 1.2.0)
  • AAB_PATH = The path to your app bundle file, e.g. /bundles/app-universal.aab
  • PLAY_CONSOLE_TRACK = The track you will be uploading to, e.g. internal
  • IS_DRAFT = Whether this is a draft upload or if it is final (this is optional and will default to true, as you typically will want to perform further edits from the Play Console after this upload is complete)

The final script below is an adapted version of this article from Brightec, modified to work with our setup:

#!/bin/bash

PACKAGE_NAME=$1
VERSION_CODE=$2
AAB_PATH=$3
PLAY_CONSOLE_TRACK=$4
IS_DRAFT=$5

# Safety checks
if [ -z "$PACKAGE_NAME" ]; then
echo "PACKAGE_NAME variable not supplied. Exiting."
exit 1
fi
if [ -z "$VERSION_CODE" ]; then
echo "VERSION_CODE variable not supplied. Exiting."
exit 1
fi
if [ -z "$AAB_PATH" ]; then
echo "AAB_PATH variable not supplied. Exiting."
exit 1
fi
if [ -z "$PLAY_CONSOLE_TRACK" ]; then
echo "PLAY_CONSOLE_TRACK variable not supplied. Exiting."
exit 1
fi
if [ -z "$IS_DRAFT" ]; then
echo "IS_DRAFT variable not supplied. Assuming draft (rather than completed) upload is desired."
IS_DRAFT=true
fi
if [ "$IS_DRAFT" = true ]; then
STATUS="draft"
else
STATUS="completed"
fi

JWT_HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64)
jwt_claims() {
cat <<EOF
{
"iss": "$GCP_AUTH_ISS",
"scope": "https://www.googleapis.com/auth/androidpublisher",
"aud": "$GCP_AUTH_AUD",
"exp": $(($(date +%s) + 300)),
"iat": $(date +%s)
}
EOF
}
JWT_CLAIMS=$(echo -n "$(jwt_claims)" | base64)
JWT_PART_1=$(echo -n "$JWT_HEADER.$JWT_CLAIMS" | tr -d '\n' | tr -d '=' | tr '/+' '_-')
JWT_SIGNING=$(echo -n "$JWT_PART_1" | openssl dgst -binary -sha256 -sign <(echo -e "$GCP_AUTH_TOKEN") | base64)
JWT_PART_2=$(echo -n "$JWT_SIGNING" | tr -d '\n' | tr -d '=' | tr '/+' '_-')

echo "Getting access token..."

HTTP_RESPONSE_TOKEN=$(curl --silent --write-out "HTTPSTATUS:%{http_code}" \
--header "Content-type: application/x-www-form-urlencoded" \
--request POST \
--data "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$JWT_PART_1.$JWT_PART_2" \
"$GCP_AUTH_AUD")
# shellcheck disable=SC2001
HTTP_BODY_TOKEN=$(echo "$HTTP_RESPONSE_TOKEN" | sed -e 's/HTTPSTATUS\:.*//g')
HTTP_STATUS_TOKEN=$(echo "$HTTP_RESPONSE_TOKEN" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

if [ "$HTTP_STATUS_TOKEN" != 200 ]; then
echo -e "Get access token failed.\nStatus: $HTTP_STATUS_TOKEN\nBody: $HTTP_BODY_TOKEN\nExiting."
exit 1
fi

ACCESS_TOKEN=$(echo "$HTTP_BODY_TOKEN" | jq -r '.access_token')

EXPIRY=$(($(date +%s) + 120))
post_data_create_edit() {
cat <<EOF
{
"id": "circleci-$CIRCLE_BUILD_NUM",
"expiryTimeSeconds": "$EXPIRY"
}
EOF
}

echo "Creating new upstream edit..."

HTTP_RESPONSE_CREATE_EDIT=$(curl --silent --write-out "HTTPSTATUS:%{http_code}" \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--request POST \
--data "$(post_data_create_edit)" \
https://www.googleapis.com/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits)
# shellcheck disable=SC2001
HTTP_BODY_CREATE_EDIT=$(echo "$HTTP_RESPONSE_CREATE_EDIT" | sed -e 's/HTTPSTATUS\:.*//g')
HTTP_STATUS_CREATE_EDIT=$(echo "$HTTP_RESPONSE_CREATE_EDIT" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

if [ "$HTTP_STATUS_CREATE_EDIT" != 200 ]; then
echo -e "Create edit failed.\nStatus: $HTTP_STATUS_CREATE_EDIT\nBody: $HTTP_BODY_CREATE_EDIT\nExiting."
exit 1
fi

EDIT_ID=$(echo "$HTTP_BODY_CREATE_EDIT" | jq -r '.id')
echo "Edit ID: $EDIT_ID"

echo "Uploading AAB..."

HTTP_RESPONSE_UPLOAD_AAB=$(curl --write-out "HTTPSTATUS:%{http_code}" \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/octet-stream" \
--progress-bar \
--request POST \
--upload-file "$AAB_PATH" \
https://www.googleapis.com/upload/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits/"$EDIT_ID"/bundles?uploadType=media)
# shellcheck disable=SC2001
HTTP_BODY_UPLOAD_AAB=$(echo "$HTTP_RESPONSE_UPLOAD_AAB" | sed -e 's/HTTPSTATUS\:.*//g')
HTTP_STATUS_UPLOAD_AAB=$(echo "$HTTP_RESPONSE_UPLOAD_AAB" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

if [ "$HTTP_STATUS_UPLOAD_AAB" != 200 ]; then
echo -e "Upload AAB failed\nStatus: $HTTP_STATUS_UPLOAD_AAB\nBody: $HTTP_BODY_UPLOAD_AAB\nExiting."
exit 1
fi

post_data_assign_track() {
cat <<EOF
{
"track": "$PLAY_CONSOLE_TRACK",
"releases": [
{
"versionCodes": [
"$VERSION_CODE"
],
"status": "$STATUS"
}
]
}
EOF
}

echo "Assigning edit to $PLAY_CONSOLE_TRACK track..."

HTTP_RESPONSE_ASSIGN_TRACK=$(curl --silent --write-out "HTTPSTATUS:%{http_code}" \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--request PUT \
--data "$(post_data_assign_track)" \
https://www.googleapis.com/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits/"$EDIT_ID"/tracks/"$PLAY_CONSOLE_TRACK")
# shellcheck disable=SC2001
HTTP_BODY_ASSIGN_TRACK=$(echo "$HTTP_RESPONSE_ASSIGN_TRACK" | sed -e 's/HTTPSTATUS\:.*//g')
HTTP_STATUS_ASSIGN_TRACK=$(echo "$HTTP_RESPONSE_ASSIGN_TRACK" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

if [ "$HTTP_STATUS_ASSIGN_TRACK" != 200 ]; then
echo -e "Assign track failed\nStatus: $HTTP_STATUS_ASSIGN_TRACK\nBody: $HTTP_BODY_ASSIGN_TRACK\nExiting."
exit 1
fi

echo "Committing edit..."

HTTP_RESPONSE_COMMIT=$(curl --silent --write-out "HTTPSTATUS:%{http_code}" \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--request POST \
https://www.googleapis.com/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits/"$EDIT_ID":commit)
# shellcheck disable=SC2001
HTTP_BODY_COMMIT=$(echo "$HTTP_RESPONSE_COMMIT" | sed -e 's/HTTPSTATUS\:.*//g')
HTTP_STATUS_COMMIT=$(echo "$HTTP_RESPONSE_COMMIT" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

if [ "$HTTP_STATUS_COMMIT" != 200 ]; then
echo -e "Commit edit failed\nStatus: $HTTP_STATUS_COMMIT\nBody: $HTTP_BODY_COMMIT\nExiting."
exit 1
fi

echo "Successfully added $AAB_PATH to $PLAY_CONSOLE_TRACK track, with edit ID $EDIT_ID. (Draft status: $STATUS)"

If you wish to upload an APK rather than an app bundle, you’ll just need to change the URL for the upload cURL request (HTTP_RESPONSE_UPLOAD_AAB) to make a request to https://www.googleapis.com/upload/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits/"$EDIT_ID"/apks?uploadType=media with a content type of application/vnd.android.package-archive.

This would mean the request would look something like this (allowing you to pass an APK_PATH to be uploaded, rather than an AAB_PATH):

HTTP_RESPONSE_UPLOAD_APK=$(curl --write-out "HTTPSTATUS:%{http_code}" \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/vnd.android.package-archive" \
--progress-bar \
--request POST \
--upload-file "$APK_PATH" \
https://www.googleapis.com/upload/androidpublisher/v3/applications/"$PACKAGE_NAME"/edits/"$EDIT_ID"/apks?uploadType=media)

CircleCI Pipeline

To be a true replacement for our Bitrise implementation, we needed the pipeline to checkout our project’s GitHub repo, build our app bundle, sign it, then deploy it to Google Play Console.

In my opinion, the CircleCI pipeline is slightly more complex to put together than the Bash script above, so I will explain each individual step first, before revealing the full config.yml for the pipeline.

First, we need a step to build the app bundle for the release build of our app:

run:
name: Build AAB (release)
command: ./gradlew ":app:bundleRelease"

Next, we’ll create a step which grabs the base64-encoded keystore from our android-release context, decodes it, and stores the result in a file called your-app.keystore:

run:
name: Get keystore from CircleCI context
command: |
mkdir keystore
echo ${ANDROID_KEYSTORE} > encrypted-keystore
base64 -d encrypted-keystore > keystore/your-app.keystore

To build release APK which can be distributed internally for testing (as well as populate environment variables to be used by the bash script later), we created this step:

- run:
name: Build universal APK (release)
command: |
curl -L "https://github.com/google/bundletool/releases/download/1.15.4/bundletool-all-1.15.4.jar" -o bundletool.jar
java -jar bundletool.jar build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=apk/app-universal-release.apks \
--mode=universal \
--ks=keystore/your-app.keystore \
--ks-pass=pass:${ANDROID_KEYSTORE_PASSWORD} \
--ks-key-alias=${ANDROID_KEYSTORE_ALIAS} \
--key-pass=pass:${ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD}
unzip -p apk/app-universal-release.apks universal.apk > apk/app-universal-release.apk
AAPT=$(find $ANDROID_HOME -name aapt | sort -r | head -1)
VERSION_NAME=$(${AAPT} dump badging apk/app-universal-release.apk | grep versionName | awk '{print $4}' | sed -e s/versionName=//g -e s/\'//g)
VERSION_CODE=$(${AAPT} dump badging apk/app-universal-release.apk | grep versionCode | awk '{print $3}' | sed -e s/versionCode=//g -e s/\'//g)
PACKAGE_NAME=$(${AAPT} dump badging apk/app-universal-release.apk | grep package | awk '{print $2}' | sed -e s/name=//g -e s/\'//g)
echo 'export PACKAGE_NAME="$PACKAGE_NAME"' >> "$BASH_ENV"
echo 'export VERSION_CODE="$VERSION_CODE"' >> "$BASH_ENV"
echo 'export VERSION_NAME="$VERSION_NAME"' >> "$BASH_ENV"

N.B. If you encounter issues when trying to retrieve the exported environment variables between steps (perhaps you’re adapting the solution from this article around an existing pipeline), then consider writing the variables to a file, persisting all or part of the workspace, then loading them in the next step/job. An example of this could be to use echo ‘export PACKAGE_NAME=”$PACKAGE_NAME”’ >> new_env_vars in one step, persisting that file, then running cat new_env_vars >> $BASH_ENV in the next step/job to restore those environment variables.

The following step can safely be run in parallel to the APK build step above. This step signs the release app bundle using jarsigner:

run:
name: Sign AAB (release)
command: |
jarsigner -verbose \
-sigalg SHA256withRSA \
-digestalg SHA-256 \
-keystore keystore/your-app.keystore \
-storepass ${ANDROID_KEYSTORE_PASSWORD} \
-keypass ${ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD} \
-signedjar app-release-signed.aab app/build/outputs/bundle/release/app-release.aab ${ANDROID_KEYSTORE_ALIAS}

Now that we have our signed bundle, we can deploy it with our Bash script. If you need to upload an APK instead of an app bundle, you can change the first line of the command in this step to: APK_PATH=$(find . -path “*universal-release*.apk” -print -quit), and pass APK_PATH instead of AAB_PATH in the final line.

Also, replace ./.circleci/scripts/upload-to-play-console.sh with the path to the shell script in your project if you chose to save it elsewhere.

run:
name: Upload to Play Console
shell: /bin/bash -eo pipefail
command: |
AAB_PATH=$(find . -path "*release-signed*.aab" -print -quit)
echo Deploying "${PACKAGE_NAME}" with version code ${VERSION_CODE} to Play Console, using "${AAB_PATH}"
./.circleci/scripts/upload-to-play-console.sh "${PACKAGE_NAME}" "${VERSION_CODE}" "${AAB_PATH}" "internal" "false"

Tying this all together, the final pipeline should look something like this:


version: 2.1

orbs:
android: circleci/android@2.3.0

config_android: &config_android
working_directory: ~/workspace
docker:
- image: cimg/android:2023.09
environment:
GRADLE_OPTS: '
-Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC"
-Dorg.gradle.daemon=false
-Dorg.gradle.workers.max=8
-Dkotlin.incremental=false'
resource_class: xlarge

jobs:
deploy_app:
<<: *config_android
steps:
- checkout
- run:
name: Build AAB (release)
command: ./gradlew ":app:bundleRelease"
- run:
name: Get keystore from CircleCI context
command: |
mkdir keystore
echo ${ANDROID_KEYSTORE} > encrypted-keystore
base64 -d encrypted-keystore > keystore/your-app.keystore
- run:
name: Build universal APK (release)
command: |
curl -L "https://github.com/google/bundletool/releases/download/1.15.4/bundletool-all-1.15.4.jar" -o bundletool.jar
java -jar bundletool.jar build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=apk/app-universal-release.apks \
--mode=universal \
--ks=keystore/your-app.keystore \
--ks-pass=pass:${ANDROID_KEYSTORE_PASSWORD} \
--ks-key-alias=${ANDROID_KEYSTORE_ALIAS} \
--key-pass=pass:${ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD}
unzip -p apk/app-universal-release.apks universal.apk > apk/app-universal-release.apk
AAPT=$(find $ANDROID_HOME -name aapt | sort -r | head -1)
VERSION_NAME=$(${AAPT} dump badging apk/app-universal-release.apk | grep versionName | awk '{print $4}' | sed -e s/versionName=//g -e s/\'//g)
VERSION_CODE=$(${AAPT} dump badging apk/app-universal-release.apk | grep versionCode | awk '{print $3}' | sed -e s/versionCode=//g -e s/\'//g)
PACKAGE_NAME=$(${AAPT} dump badging apk/app-universal-release.apk | grep package | awk '{print $2}' | sed -e s/name=//g -e s/\'//g)
echo 'export PACKAGE_NAME="$PACKAGE_NAME"' >> "$BASH_ENV"
echo 'export VERSION_CODE="$VERSION_CODE"' >> "$BASH_ENV"
echo 'export VERSION_NAME="$VERSION_NAME"' >> "$BASH_ENV"
- run:
name: Sign AAB (release)
command: |
jarsigner -verbose \
-sigalg SHA256withRSA \
-digestalg SHA-256 \
-keystore keystore/your-app.keystore \
-storepass ${ANDROID_KEYSTORE_PASSWORD} \
-keypass ${ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD} \
-signedjar app-release-signed.aab app/build/outputs/bundle/release/app-release.aab ${ANDROID_KEYSTORE_ALIAS}
- run:
name: Upload to Play Console
shell: /bin/bash -eo pipefail
command: |
AAB_PATH=$(find . -path "*release-signed*.aab" -print -quit)
echo Deploying "${PACKAGE_NAME}" with version code ${VERSION_CODE} to Play Console, using "${AAB_PATH}"
./.circleci/scripts/upload-to-play-console.sh "${PACKAGE_NAME}" "${VERSION_CODE}" "${AAB_PATH}" "internal" "false"

workflows:
release-workflow:
jobs:
# you may have existing jobs preceeding the examples from this article,
# in which case, you can remove the checkout step from our job and
# persist the workspace from your previous steps/jobs
- the-rest-of-your-pipeline
- deploy_app:
requires:
- the-rest-of-your-pipeline

N.B. If the steps above fail because you would like to use a different executor/machine image when integrating this with an existing pipeline, just ensure that you add an initial step which downloads the Alpine packages used by the other run steps:

run:
name: Fetch required packages
command: apk add bash openssl curl jq

The Kotlin Script Approach

Just a few days after implementing the Bash script approach described above, we encountered this article by Paweł Szymański. Naturally, every Android developer wants to operate everything in their life through Koncise Kotlin Kode™, so we got to work konverting our pipeline once more.

The first step was to create a new Kotlin module - we called it playstorepublish - and ensure it was included in settings.gradle.kts by appending include(“:playstorepublish”) to the file. (Often your IDE will do this for you, but check, just in case it doesn’t!)

Screenshot of the Android studio menu for creating a new module
Create a new Kotlin module in your project, replacing 'com.yourapp' with your app’s package name

Once you’ve created the module, ensure the module-level build.gradle.kts looks like this, replacing the package name used for mainClass with your app’s package name:

plugins {
id("kotlin")
id("application")
}

application {
@Suppress("UnstableApiUsage")
mainClass = "com.yourapp.playstorepublish.PlayStorePublish"

}

dependencies {
// Adapt these to use your preferred dependency management approach,
// and make sure to use the latest versions of the dependencies
implementation("com.google.auth:google-auth-library-oauth2-http:1.19.0")
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20230921-2.0.0")
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

N.B. You may also need to add the Kotlin JVM plugin to your project-level build.gradle.kts — try this if you are unable to get the following steps to work.

Now we can get to work on the Kotlin class which will mean we no longer need to maintain a Bash script, where the request URLs involved in uploading may be subject to change, and the code will probably be quite unreadable to new team members. Instead, this Kotlin file uses the Google Android publisher Java SDK to generate an executable script for deploying apps to Google Play Console. This allows us to more easily stay up to date with API changes, as the SDK will (usually) be quickly updated according to those changes. The code is also far more readable and maintainable for the average Android developer compared to our previous approach.

package com.yourapp.playstorepublish

import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
import com.google.api.client.http.FileContent
import com.google.api.client.http.HttpRequestInitializer
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.androidpublisher.AndroidPublisher
import com.google.api.services.androidpublisher.AndroidPublisherScopes
import com.google.api.services.androidpublisher.model.AppEdit
import com.google.api.services.androidpublisher.model.Track
import com.google.api.services.androidpublisher.model.TrackRelease
import com.google.auth.http.HttpCredentialsAdapter
import com.google.auth.oauth2.ServiceAccountCredentials
import java.io.File
import java.io.FileInputStream
import kotlin.system.exitProcess

object PlayStorePublish {

private const val SCRIPT_FAILED_CODE = -1
private const val APK_FILE_EXTENSION = "apk"
private const val APK_CONTENT_MIME_TYPE = "application/vnd.android.package-archive"
private const val AAB_FILE_EXTENSION = "aab"
private const val AAB_CONTENT_MIME_TYPE = "application/octet-stream"
private const val PLAY_STORE_INTERNAL_TRACK = "internal"

@JvmStatic
fun main(args: Array<String>) {
try {
println("Started app upload script")
val packageName = args[0]
val apkOrAabPath = args[1]
val credentialsPath = args[2]
val releaseName = args[3]

println("Received arguments:\npackageName: $packageName\napkOrAabPath: $apkOrAabPath\ncredentialsPath: $credentialsPath\nreleaseName: $releaseName")
uploadApp(packageName, apkOrAabPath, credentialsPath, releaseName)
} catch (e: Exception) {
println("${e::class.simpleName} ${e.message}")
exitProcess(SCRIPT_FAILED_CODE)
}
}

private fun setHttpTimeout(requestInitializer: HttpRequestInitializer) =
HttpRequestInitializer { request ->
requestInitializer.initialize(request)
request.connectTimeout = 3 * 60000
request.readTimeout = 3 * 60000
}

private fun uploadApp(
packageName: String,
apkOrAabPath: String,
credentialsPath: String,
releaseName: String
) {
val credentials = ServiceAccountCredentials
.fromStream(FileInputStream(credentialsPath))
.createScoped(AndroidPublisherScopes.all())

val publisher = AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory(),
setHttpTimeout(HttpCredentialsAdapter(credentials))
).setApplicationName("Google Play Console upload").build()

val edit: AppEdit = publisher.edits().insert(packageName, null).execute()

println("Created edit: ${edit.id} for $packageName")

val fileToUpload = File(apkOrAabPath)
val uploadedFileVersionCode = requireNotNull(
when (fileToUpload.extension) {
APK_FILE_EXTENSION -> uploadApk(publisher, packageName, edit, apkOrAabPath)
AAB_FILE_EXTENSION -> uploadAab(publisher, packageName, edit, apkOrAabPath)
else -> throw Exception("Incompatible app file extension: ${fileToUpload.extension}")
}
)

println("Uploaded ${fileToUpload.extension} with version code $uploadedFileVersionCode successfully")

val track = publisher
.edits()
.tracks()
.update(
packageName, edit.id, PLAY_STORE_INTERNAL_TRACK, Track().setReleases(
listOf(
TrackRelease()
.setName(releaseName)
.setVersionCodes(listOf(uploadedFileVersionCode.toLong()))
.setStatus("completed")
)
)
)
.execute().track

publisher.edits().commit(packageName, edit.id).execute()

println("Committed edit ${edit.id} for $packageName with version code $uploadedFileVersionCode on $track track")
}

private fun uploadAab(
publisher: AndroidPublisher,
appId: String,
edit: AppEdit,
pathToUpload: String
): Int? =
publisher.edits().bundles().upload(
appId,
edit.id,
FileContent(AAB_CONTENT_MIME_TYPE, File(pathToUpload))
).execute().versionCode

private fun uploadApk(
publisher: AndroidPublisher,
appId: String,
edit: AppEdit,
pathToUpload: String
): Int? = publisher.edits().apks().upload(
appId,
edit.id,
FileContent(APK_CONTENT_MIME_TYPE, File(pathToUpload))
).execute().versionCode
}

Just like the previous approach, this will work for either an AAB or an APK, and will upload to the internal track by default.

So what’s next? In order to have this code generate a Bash script for us, we need to run:

./gradlew playstorepublish:installDist

For those unfamiliar with running Gradle tasks, this will use your project’s Gradle wrapper (gradlew) to run a task (installDist) in the specified module (playstorepublish).

Try it locally before adding this to your pipeline!

Screenshot from Android studio ‘Run Anything’ dialog, showing the Gradle command to run our Bash script generation task
By default, if you double-tap the Ctrl key in Android Studio, you will open the `Run Anything` dialog, where you can test this task locally by typing the command in and hitting enter

If the command executed successfully, you should see some scripts in the build subfolder for your module. They will have the same name as your module. For our pipeline, we’ll be using the executable file generated at the following path:

./playstorepublish/build/install/playstorepublish/bin/playstorepublish

Screenshot from Android studio, showing the folder structure produced as a result of running the ‘installDist’ Gradle task
Folder structure after running the installDist Gradle task

N.B. If you are unable to run the executable locally, you may have to set execute permissions on the file with chmod +x <path_to_executable>

Finally, we can update the pipeline from our previous approach simply by editing the run step named ‘Upload to Play Console’ like so:

run:
name: Upload to Play Console
command: |
./gradlew playstorepublish:installDist
AAB_PATH=$(find . -path "*release-signed*.aab" -print -quit)
UPLOADER=./playstorepublish/build/install/playstorepublish/bin/playstorepublish
echo Deploying "${PACKAGE_NAME}" to Play Console, using "${AAB_PATH}"
echo "${GOOGLE_PLAY_PRIVATE_KEY_JSON}" > auth_token.json
$UPLOADER "${PACKAGE_NAME}" "${AAB_PATH}" auth_token.json "${VERSION_NAME}"

If you don’t plan to use Fastlane lanes or Bitrise’s native CD steps to deploy your app, hopefully this guide was a helpful resource for you. It’s also worth noting that if this is too much faff, product offerings such as Fastlane, GitLab, and Bitrise exist to serve these (and many more) use cases in a convenient way. There are a myriad of paid and free app CI/CD solutions out there, some of which are complete, paid cloud solutions, others which are free, open-source and can be integrated into other platforms and workflows.

Do your research and choose a CI/CD approach that best serves your cost, maintainability, and time restrictions!

--

--

Bryen Vieira
Go City Engineering

Professionally punching in the dark for nearly a decade