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,
[quote]
____
There's a plugin for that
____
but unfortunately, I couldn't find one that meets my needs. While there
is a plugin called link:https://plugins.jenkins.io/git-changelog/[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
link:https://docs.github.com/en/rest?apiVersion=2022-11-28[GitHub REST API]
or the link:https://cli.github.com/[`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.
---- {.dockerfile}
== 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 link:https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app[GitHub App
authentication]
to enable `gh` to use our credentials. To do this, we have to install
the link:https://plugins.jenkins.io/github-branch-source/[GitHub Branch Source
plugin] and then
create a link:https://www.jenkins.io/blog/2020/04/16/github-app-authentication/[GitHub
Application].
No need to replicate here the link:https://github.com/jenkinsci/github-branch-source-plugin/blob/master/docs/github-app.adoc[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
link:https://github.com/gounthar/MyFirstAndroidAppBuiltByJenkins/blob/main/jenkins/create-release.sh[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"`.
---- {.bash}
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...
image::/assets/images/2023/05/03/2023-05-03-android-and-jenkins-releases/too-many-releases.png[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
link:https://github.com/Triple-T/gradle-play-publisher[`com.github.triplet.play`].
This time, it's a link:https://plugins.gradle.org/plugin/com.github.triplet.play[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 link:https://termly.io/products/eula-generator/[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.
---- {.bash}
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:
---- {.bash}
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`:
---- {.bash}
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.
---- {.bash}
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:
---- {.groovy}
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
<!-- blank line -->
<figure class="video_container">
video::Vdw1LgBcy3o[youtube] </iframe>
</figure>
<!-- blank line -->
You have to link:https://developers.google.com/android-publisher/getting_started#enable[enable the Android Publisher
API]
for that project.
<!-- blank line -->
<figure class="video_container">
video::eXJBIkHNB48[youtube] </iframe>
</figure>
<!-- blank line -->
Then you have to
link:https://developers.google.com/android-publisher/getting_started#existing[link]
your Google Play developer account to the GCP project.
<!-- blank line -->
<figure class="video_container">
video::XaokL2ku4JA[youtube] </iframe>
</figure>
<!-- blank line -->
Then you will have to link:https://cloud.google.com/iam/docs/service-accounts-create[create a service
account].
<!-- blank line -->
<figure class="video_container">
video::hAHvZe1XklU[youtube] </iframe>
</figure>
<!-- blank line -->
Then you have to create a
link:https://cloud.google.com/iam/docs/keys-create-delete[key].
<!-- blank line -->
<figure class="video_container">
video::LdMSK1d63Sw[youtube] </iframe>
</figure>
<!-- blank line -->
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 link:https://plugins.jenkins.io/envinject/[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.
<!-- blank line -->
<figure class="video_container">
video::LXVydeeMnSU[youtube] </iframe>
</figure>
<!-- blank line -->
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.
---- {.bash}
./gradlew tasks --group publishing
[quote]
____
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 asinternal/alpha/beta/productionor any custom track.Defaults to internal.
releaseStatus: The type of release, such asReleaseStatus.COMPLETED,ReleaseStatus.DRAFT,ReleaseStatus.HALTED, orReleaseStatus.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 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.