DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Git-based Android App Versioning with AGP 4.0
Yang
Yang

Posted on • Updated on

Git-based Android App Versioning with AGP 4.0

An Android app has 2 pieces of version information:

  1. versionCode - a positive integer used as an internal version number of the app that needs to monotonically increase with each subsequent release.
  2. versionName - a string as a version number shown to users.

To automate the process of generating the versionCode and versionName for an app release, a common approach is to compute these values from Git tags:

android {
  defaultConfig {
    versionCode buildVersionCode()
    versionName buildVersionName()
  }
}

The buildVersionCode() and buildVersionName() need to execute a command-line process to fetch the git information (e.g. latest tag), parse it and generate the versionCode and versionName required:

def buildVersionCode = {
  try {
    def stdout = new ByteArrayOutputStream()
      exec {
        commandLine 'git', 'describe', '--tags'
        standardOutput = code
      }
      // computeVersionCodeFromTag(tag) might use a formula such as MAJOR * 10000 + MINOR * 100 + PATCH for SemVer tags.
      return computeVersionCodeFromTag(stdout.toString().trim())
  } catch (ignored) {
    return -1
  }
}

def buildVersionName = {
  try {
      def stdout = new ByteArrayOutputStream()
      exec {
          commandLine 'git', 'describe', '--tags'
          standardOutput = stdout
      }
      return stdout.toString().trim()
  } catch (ignored) {
    return null
  }
}

The main issue here is that we are doing computation during configuration of the build which is extremely inefficient as buildVersionCode and buildVersionName are executed every time a project is configured (e.g. every Gradle sync from the IDE, or just running ./gradlew help), whereas in practice the versionCode and versionName are only required when assembling an APK or AAB.

While Gradle has been pushing for Lazy Task Configuration and Task Configuration Avoidance for a couple of years now, the Android Gradle Plugin (AGP) had not migrated some of the most important APIs to support Gradle properties and providers until very recently. Thus there wasn't an easy way to avoid computing versionCode and versionName during configuration (here's a workaround provided by the AGP team back at IO 19).

This issue was finally addressed in AGP 4.0 with the introduction of 2 new APIs for configuring the build variants early in the configuration phase: onVariants and onVariantsProperties. Here's the skeleton for generating versionCode and versionName in a Gradle task from the official Android Plugin for Gradle cookbook.

Android App Versioning Gradle Plugin

A few months ago while working on automating the release process at work, I decided to take a closer look at the new onVariantProperties API and move the version info computation logic to a proper Gradle task. The initial prototype was developed in streamlined and had received some interests from the community.

After adding support for customizing versionCode and versionName with a lambda and simplifying the plugin configurations, today I’m happy to share App Versioning - a Gradle Plugin for lazily generating Android app's versionCode & versionName from Git tags.

How it works

Let's start by applying the plugin to the app module:

plugins {
    id("com.android.application")
    id("io.github.reactivecircus.app-versioning") version "x.y.z"
}

Note that the samples in the post are written with Gradle Kotlin DSL but traditional Groovy DSL is also fully supported.

At this point if your repository has a Git tag that follows Semantic Versioning, app-versioning will start generating versionCode and versionName for your APK / AAB!

Let's imagine the latest tag in your current branch is 1.3.1. Assembling the APK with ./gradlew assembleRelease will trigger a generateAppVersionInfoForRelease task which generates both the versionCode and versionName for the assembled APK:

> Task :app:generateAppVersionInfoForRelease
Generated app version code: 10301.
Generated app version name: "1.3.1".

The generated versionCode and versionName are injected into the merged manifest at app/build/intermediates/merged_manifests/release/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="io.github.reactivecircus.streamlined"
    android:versionCode="10301"
    android:versionName="1.3.1" >
...
</manifest>

Default rules

Without providing any configurations, app-versioning will fetch the latest Git tag in the repository, attempt to parse it into a SemVer string, and compute the versionCode following positional notation:

versionCode = MAJOR * 10000 + MINOR * 100 + PATCH

So for the tag 1.3.1, the generated versionCode is 1 * 10000 + 3 * 100 + 1 = 10301.

The versionName generated will just be the name of the latest Git tag (1.3.1 in the example above).

If the default rules described above work for you, you are now ready to enjoy automated version info generation without further configurations! But chances are you might want to tweak the formula used for generating versionCode, or inject additional metadata into the versionCode and/or versionName to fit your existing process and workflow:

  • Instead of reserving 2 digits for each of the MAJOR, MINOR, and PATCH components, you might want to use a smaller or bigger range.
  • You might want to add an additional BUILD_NUMBER from CI to the versionCode or versionName.
  • You might need to include the commit hash in the versionName.

With that in mind, let's now take a look at how these custom rules can be specified with a couple of plugin configurations.

Custom rules

To maximize the supported use cases without introducing too many configurations, app-versioning lets you define how you want to compute the versionCode and versionName by implementing lambdas which are evaluated lazily during execution:

appVersioning {

  overrideVersionCode { gitTag, providers ->
    // TODO generate an Int from the given gitTag and/or providers
  }

  overrideVersionName { gitTag, providers ->
    // TODO generate a String from the given gitTag and/or providers
  }
}

GitTag is a type-safe representation of a tag encapsulating the rawTagName, commitsSinceLatestTag and commitHash, provided by the plugin.

providers is a ProviderFactory instance which is a Gradle API that can be useful for reading environment variables and system properties lazily.

SemVer-based version code

As mentioned earlier the plugin by default reserves 2 digits for each of the MAJOR, MINOR and PATCH components in a SemVer tag.

To allocate 3 digits per component instead (i.e. each version component can go up to 999):

import io.github.reactivecircus.appversioning.toSemVer
appVersioning {
  overrideVersionCode { gitTag, providers ->
    val semVer = gitTag.toSemVer()
    semVer.major * 1000000 + semVer.minor * 1000 + semVer.patch
  }
}

toSemVer() is an extension function (or SemVer.fromGitTag(gitTag) if you use Groovy) provided by the plugin to help create a type-safe SemVer object from the GitTag by parsing its rawTagName field.

If a Git tag is not fully SemVer compliant (e.g. 1.2), calling gitTag.toSemVer() will throw an exception. In that case we'll need to find another way to compute the versionCode.

Using timestamp for version code

Since the key characteristic for versionCode is that it must monotonically increase with each app release, a common approach is to use the Epoch / Unix timestamp for versionCode:

import java.time.Instant
appVersioning {
  overrideVersionCode { _, _ ->
    Instant.now().epochSecond.toInt()
  }
}

This will generate a monotonically increasing version code every time the generateAppVersionInfoFor<BuildVariant> task is run:

Generated app version code: 1599750437.

Using environment variable

Another common practice when computing version code is to add a BUILD_NUMBER environment variable provided by CI to the formula. To do this, we can use the providers lambda parameter to create a provider that's only queried during execution:

import io.github.reactivecircus.appversioning.toSemVer
appVersioning {
  overrideVersionCode { gitTag, providers ->
    val buildNumber = providers
        .environmentVariable("BUILD_NUMBER")
        .getOrElse("0").toInt()
    val semVer = gitTag.toSemVer()
    semVer.major * 10000 + semVer.minor * 100 + semVer.patch + buildNumber
  }
}

versionName can be customized with the same approach:

import io.github.reactivecircus.appversioning.toSemVer
appVersioning {
  overrideVersionName { gitTag, providers ->
    // a custom versionName combining the tag name, commitHash and an environment variable
    val buildNumber = providers
        .environmentVariable("BUILD_NUMBER")
        .getOrElse("0").toInt()
    "${gitTag.rawTagName} - #$buildNumber (${gitTag.commitHash})"
  }
}

Custom version name generated:

Generated app version name: "1.3.1 - #345 (f0b6056)".

App versioning on CI

Release APKs are usually built on CI, where the build configurations and host environment are likely to be different from a local dev environment. There are a couple of things worth noting when using the app-versioning plugin on CI.

Enable the plugin only when necessary

For performance reason many CI providers only fetch a single commit by default when checking out the repository. For app-versioning to work we need to make sure Git tags are also fetched. Here's an example for doing this with GutHub Actions:

- uses: actions/checkout@v2
  with:
    fetch-depth: 0

CI jobs such as unit tests and Lint usually do not require a versionCode to be present in the merged AndroidManifest to complete. If these CI jobs are sharded such that they run in separate VMs in parallel, we can disable the app-versioning plugin and not worry about telling the CI to fetch those additional Git tags when checking out.

To do this we can define an environment variable ENABLE_APP_VERSIONING and set it to false to indicate that no versionCode or versionName need to be generated by the app-versioning plugin for this job:

unit-tests:
  name: Unit tests
  runs-on: ubuntu-latest
  env:
    ENABLE_APP_VERSIONING: false

We can then enable / disable the plugin based on the value of the environment variable:

val enableAppVersioning = providers
  .environmentVariable("ENABLE_APP_VERSIONING")
  .forUseAtConfigurationTime()
  .getOrElse("true").toString().toBoolean()

appVersioning {
  enabled.set(enableAppVersioning)
  ...
}

Note that we need to use the experimental forUseAtConfigurationTime() API to create a configuration time provider.

Retrieving the generated version code and version name

There are cases where you may need to retrieve the versionCode and versionName of a generated APK / AAB:

  • including the version code and version name in a Slack message sent from CI after a successful build
  • communicating with a 3rd party service where version code and version name need to be included in the request (e.g. uploading R8 mapping file to a crashing reporting service)

Both the versionCode and versionName generated by app-versioning are in the build output directory:

app/build/outputs/app_versioning/<buildVariant>/version_code.txt
app/build/outputs/app_versioning/<buildVariant>/version_name.txt

We can cat the output of these files into variables:

VERSION_CODE=$(cat app/build/outputs/app_versioning/<buildVariant>/version_code.txt)
VERSION_NAME=$(cat app/build/outputs/app_versioning/<buildVariant>/version_name.txt)

Note that if you need to query these files in a different VM than where the APK (and its version info) was originally generated, you need to make sure these files are "carried over" from the original VM. Otherwise you'll need to run the generateAppVersionInfoFor<BuildVariant> task again to generate these files, but the generated version info might not be the same as what's actually used for the APK (e.g. if you use the Epoch timestamp for versionCode).

Here's an example with GitHub Actions that does the following:

  • in the Assemble job, build the App Bundle and archive / upload the build outputs directory which include the AAB and its R8 mapping file, along with the version_code.txt and version_name.txt files generated by app-versioning.
  • later in the Publish to Play Store job, download the previously archived build outputs directory, cat the content of version_code.txt and version_name.txt into variables, upload the R8 mapping file to Bugsnag API with curl and passing the retrieved $VERSION_CODE and $VERSION_NAME as parameters, and finally upload the AAB to Play Store (without building the AAB or generating the app version info again).

Lazy, incremental, cacheable

By moving version info generation to a Gradle task, we're finally able to avoid doing computation during Gradle's task configuration phase.

We can take this further by making the generateAppInfoFor<BuildVariant> task incremental and cacheable.

To do this the plugin tracks the .git/refs directory as a task input, so the task only becomes "dirty" and re-runs when there are changes to the Git references in the repository e.g. adding new commits or tags.

Task is executed with the first run:

./gradlew generateAppVersionInfoForProdRelease
...
BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed

Task is up-to-date with the subsequent run:

./gradlew generateAppVersionInfoForProdRelease
...
BUILD SUCCESSFUL in 1s
1 actionable task: 1 up-to-date

Task output is loaded from build cache (Gradle build cache needs to be enabled) after cleaning the project level build directory and running the task again:

./gradlew clean
./gradlew generateAppVersionInfoForProdRelease
...
BUILD SUCCESSFUL in 1s
1 actionable task: 1 from cache

What's next

Customizing version code and version name has become easier and faster thanks to the new APIs introduced in Android Gradle Plugin 4.x. The Android App Versioning Gradle Plugin builds on top of these APIs to specifically improve the experience for app versioning use cases based on Git.

Instructions for installing, configuring and using the plugin are available on GitHub.

Please give it a try and share your feedback as we work towards the stable release!


References:

Top comments (1)

Collapse
w3bshark profile image
Tyler McCraw

Great writeup!

🌚 Life is too short to browse without dark mode