Building Android apps with Jenkins: release management

Building Android apps with Jenkins: release management

The previous blog post of this series discusses what I think makes CI/CD for mobile app development a unique kind of animal, and my first steps in building Android apps with Jenkins. We were left with a working declarative pipeline per branch, one Docker image per branch too, and an application binary ready to be deployed. Ready, really?\

Release management

I was able to find the binaries in the workspace in a matter of seconds, but there is no release available, only binaries. This means there would be some manual steps required to create a versioned release that we can deliver to test users, for example.

We could manually create a release within GitHub and then copy-paste the binaries from Jenkins' artifact archives to the GitHub release page. We could also do the same for the Google Play Store… However, this approach is neither efficient nor error-proof.

Regarding having a release on the Github repository at the same time as on Google Play, it really depends on the app and its audience. For the purposes of this article, let’s assume it’s okay.

Prerequisites

To automate the release process, we need to determine the criteria for a version number, how to update the version number, and what constitutes a release. We can use the "Semantic Version" Gradle plugin, which has a strict set of rules to guide us. This plugin allows us to increment the patch, minor, or major version using Gradle commands. We can also use classifiers such as snapshot, beta, alpha, or any other version classifier to define a version name.

version = "1.1.11"

apply plugin: "com.dipien.android.semantic-version"

I then searched for a Jenkins plugin that would create a GitHub release. As the saying goes,

There’s a plugin for that

but unfortunately, I couldn’t find one that meets my needs. While there is a plugin called Git Changelog that can merge commit messages to produce a readable version of the changes, it doesn’t create the release.

GitHub release

If you want to stay on the Jenkins side, there isn’t a plugin this time. However, there are various ways to create a release. You could use the GitHub REST API or the gh command, which can handle all the heavy lifting for us. Therefore, let’s go back to the drawing board and add the command to our Docker image.

# Install GitHub command line tool
ENV GITHUB_TOKEN $GITHUB_TOKEN
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
    chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
    apt update && apt install -y --no-install-recommends gh

Once that’s done, we need to use GitHub App authentication to enable gh to use our credentials. To do this, we have to install the GitHub Branch Source plugin and then create a GitHub Application.

No need to replicate here the efficient documentation existing on GitHub. The only fields you need to prepare and fill out (currently) are:

  • Github App name - i.e. Jenkins-<team name>

  • Homepage URL - your company’s domain or a GitHub repository, anything would do.

  • Webhook URL - your jenkins instance, i.e. https://<jenkins-host>/github-webhook/

At that moment, I queried GitHub using gh to determine whether the release already existed, and created it if it did not.
My choice of how to create the release was entirely arbitrary: I decided to create a release when the version ended with "RELEASE", a draft release when there was no suffix, and a pre-release when the version ended with "ALPHA" or "BETA".

suffix=$(echo $versionName | sed 's/.*-//')
case $suffix in
    ALPHA|BETA)
        echo "Time to do a prerelease"
        GH_OPTS="$GH_OPTS-p"
        ;;
    SNAPSHOT)
        echo "This is a snapshot, we won't release anything"
        GH_OPTS="$GH_OPTS DO_NOT_RELEASE"
        ;;
    RELEASE)
        echo "This a real release, so no need to use -d or -p";;
    *)
        echo "Unknown suffix \"$suffix\", so we'll do a draft release"
        GH_OPTS="$GH_OPTS-d"
        ;;
esac

Good enough for my use case.

The gh command does a nice job of preparing a release change log, so I’m relying on it. If we’re not building on the main branch, the release is not finalized, so I can still tidy it up later.
It’s great to be able to create a release as soon as it’s required, even when it’s not necessary…

Too many
releases

It looks like I may have gone a little too far with the automatic release creation, don’t you think?

Now, what about using that workflow to create a release on the Play Store?

Google Play Store release

The version is already handled by the semantic plugin, and the release notes are almost ready to go. Now, we just need to find the right plugin to push our app to the Google Play Store.
Luckily, we have a plugin for that, called com.github.triplet.play. This time, it’s a Gradle plugin instead of a Jenkins plugin.

The first step to getting your app on the Play Store is to pay the $25 developer account fee. After that, you need to register your app, import the EULA (there are free websites to generate that), upload the required paperwork, and then upload the signed app. Since the app is not signed yet, we’ll need to do that first.

Signing the app from the command line

There are different ways to sign your app - from the command line using apksigner for APKs or jarsigner for app bundles or you can configure Gradle to sign it during the build. In either case, you need to generate a private key using keytool before signing the app.

 keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -validity 10000 -alias my-alias

Let’s see quickly how to sign an apk:

  1. Align the unsigned APK using zipalign:

    zipalign -v -p 4 my-app-unsigned.apk my-app-unsigned-aligned.apk
    

    zipalign ensures that all uncompressed data starts with a particular byte alignment relative to the start of the file, which may reduce the amount of RAM consumed by an app. 2. Sign your APK with your previously generated private key using apksigner:

    apksigner sign --ks my-release-key.jks --out my-app-release.apk my-app-unsigned-aligned.apk
    

    This example outputs the signed APK at my-app-release.apk after signing it with a private key and certificate that are stored in a single KeyStore file: my-release-key.jks.

Now, let’s see how to sign an application bundle (located in app/build/outputs/bundle/debug) thanks to Gradle.

jarsigner -verbose -sigalg SHA256withRSA -keystore ../../../../../my-release-key.jks app-debug.aab my-alias

Signing the app from Gradle

Open the module-level build.gradle file and add the signingConfigs {} block with entries for storeFile, storePassword, keyAlias and keyPassword, and then pass that object to the signingConfig property in your build type. For example:

 signingConfigs {
        release {
            // You need to specify either an absolute path or include the
            // keystore file in the same directory as the build.gradle file.
            storeFile file("my-release-key.jks")
            storePassword "password"
            keyAlias "my-alias"
            keyPassword "password"
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

From now on, when you create the bundle with Gradle, it will be signed, self-signed, which is not what we’re aiming for. We still need to upload the icon, a summary, screenshots, banners, and other boilerplate content… The next step is to create a GCP project.

Creating a GCP project

You have to enable the Android Publisher API for that project.

Then you have to link your Google Play developer account to the GCP project.

Then you will have to create a service account.

Then you have to create a key.

To set up the necessary credentials for publishing our app to the Play Store, we’ll need to create an environment variable in Jenkins. To do this, we first need to install the Environment Injector plugin. Once that’s done, we can grant the necessary permissions to our service account so that it can publish the app on our behalf.

And we’re finally ready to publish our app thanks to gradle on Jenkins.

Publishing the app

The gradlew tasks group publishing tells us we have a publishBundle task that uploads App Bundle for all variants.

./gradlew tasks --group publishing

> Task :tasks

Tasks runnable from root project My First Built by Jenkins Applications

Publishing tasks

publishBundle - Uploads App Bundle for all variants. See https://github.com/Triple-T/gradle-play-publisher#publishing-an-app-bundle

BUILD SUCCESSFUL in 1s 1 actionable task: 1 executed

As we did not store the generated `jks` file in the repo, we have to use a variable to hold the value.
On your machine, it would work with something like:

[source,bash]

export ANDROID_PUBLISHER_CREDENTIALS=cat *json

On Jenkins, we will have to create a secret.

video::XkORY9nbgak[youtube, width=839, height=473, role=center]

The secret is now available under the `android-publisher-credentials` key.

The triplet link:https://github.com/Triple-T/gradle-play-publisher#common-configuration[documentation] tells us that we can set up a configuration in the build.gradle file like:

[source,groovy]

play { // Overrides defaults track.set("internal") updatePriority.set(2) releaseStatus.set(ReleaseStatus.DRAFT) // … }

Gradle Play Publisher supports uploading both the App Bundle and APK, and can promote those artifacts to different tracks.
You can customize how your artifacts are published using several options:

 * `track`: The target stage for an artifact, such as `internal`/`alpha`/`beta`/`production` or any custom track.
 ** Defaults to internal.
 * `releaseStatus`: The type of release, such as `ReleaseStatus.COMPLETED`, `ReleaseStatus.DRAFT`, `ReleaseStatus.HALTED`, or `ReleaseStatus.IN_PROGRESS`.
 ** Defaults to `ReleaseStatus.COMPLETED`.
 * `userFraction`: The percentage of users who will receive a staged release.
 ** This is only applicable where `releaseStatus=[IN_PROGRESS/HALTED]`
 ** defaults to `0.1` (10%).
 * `updatePriority`: Sets the update priority for a new release. See link:https://developer.android.com/guide/playcore/in-app-updates[Google's documentation] for more information.
 ** Defaults to the API value.

Furthermore, according to the link:https://github.com/Triple-T/gradle-play-publisher#uploading-release-notes[documentation], you need to supply a release notes file.
To do so, you need to add a file under `src/[sourceSet]/play/release-notes/[language]/[track].txt`. +
Here, `sourceSet` is a full variant name, `language` is one of the Play Store supported codes, and `track` is the channel you want these release notes to apply to.
If no channel is specified, the default channel will be used.

As an example, let's assume you have these two different release notes:

[source,bash]

src/main/play/release-notes/en-US/default.txt …/beta.txt

When you publish to the beta channel, the `beta.txt` release notes will be uploaded. For any other channel, `default.txt` will be uploaded.

For our use case, we’ll link:https://github.com/gounthar/MyFirstAndroidAppBuiltByJenkins/blob/main/jenkins/create-gps-release.sh[use] the `internal` track, and start from the release notes generated via the `gh` tool to produce a shorter version limited to 500 characters as specified by Google.

[source,bash]

Have we completed all the necessary steps?
We now have an Android application that builds, has undergone static analysis, and is automatically pushed to both GitHub and the Google Play Store.
However, there is still much left to cover, which we will explore in upcoming episodes.