Building Android apps with Jenkins: release management
Summary
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…
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:
-
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 usingapksigner
: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.