How to automatically handle version updates using Gradle

Matthias Schenk
Towards Dev
Published in
9 min readJan 3, 2023

--

Taken from wikipedia.com

In this article I want to show how dependency and plugin versions in a project, using Gradle build tool, can be configured in order to be able to automatically update to newest version. Managing the versions used by the project belongs to the regular tasks, which I have to do as developer to keep 3rd party dependencies and used plugins up-to-date.

Why is this important?

The main reason for this:

  • I want to get the newest security fixes to reduce the risk of hacker attacks. There are permanentely new security issues reported (see https://nvd.nist.gov/vuln/search) for all kind of libraries.
  • I want to get the newest functionality e.g. for better performance, updated/new API, hints about deprecated functionality, and more…

To show how dependencies/plugins are handled in a standard project, I start with a project configuration, which I get when using the Ktor starter of IntelliJ. The source code does not matter in this article, therefore I don’t add any functionality to the src directory, just focus on the configuration of the project.

The relevant places are part of the following files with the default content when the project is created initially:

// settings.gradle.kts
rootProject.name = "gradle-dependency-update"
// gradle.properties
ktorVersion=2.2.1
kotlinVersion=1.8.0
logbackVersion=1.2.11
kotlin.code.style=official
// build.gradle.kts
val ktorVersion: String by project
val kotlinVersion: String by project
val logbackVersion: String by project

plugins {
kotlin("jvm") version "1.8.0"
id("io.ktor.plugin") version "2.2.1"
}

group = "com.poisonedyouth"
version = "0.0.1"
application {
mainClass.set("io.ktor.server.netty.EngineMain")

val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}

repositories {
mavenCentral()
}

dependencies {
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-auth-jvm:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version")
implementation("io.ktor:ktor-serialization-jackson-jvm:$ktor_version")
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}

The versions of the 3rd party dependencies are defined inside the gradle.properties file and then are referenced inside the build.gradle.kts file. The versions of the used plugins are defined inside the build.gradle.kts file directly.

To update versions I need to manually lookup for a new available version in maven repository / gradle plugins repository and replace the version in the corresponding place. This process is still fast for small projects with just a few versions (as the sample project), but leads to a lot of work for big projects (e.g. with multiple submodules).

So what to do?

Version Catalog

For declaring dependencies and plugins in a central place Gradle provides the possibility to create a version catalog. Instead of declaring the dependencies with the specified version in every module separately, they are all declared inside of settings.gradle.kts file and only referenced inside of modules build.gradle.kts file. The result for the sample project looks like below:

dependencyResolutionManagement {
versionCatalogs {
val ktorVersion: String by settings
val logbackVersion: String by settings
val kotlinVersion: String by settings

create("libs") {
library("ktorServerCoreJvm", "io.ktor", "ktor-server-core-jvm").version(ktorVersion)
library("ktorServerAuthJvm", "io.ktor", "ktor-server-auth-jvm").version(ktorVersion)
library("ktorServerContentNegotiationJvm", "io.ktor", "ktor-server-content-negotiation-jvm").version(ktorVersion)
library("ktorServerJacksonJvm", "io.ktor", "ktor-serialization-jackson-jvm").version(ktorVersion)
library("ktorServerNettyJvm", "io.ktor", "ktor-server-netty-jvm").version(ktorVersion)
library("logback", "ch.qos.logback", "logback-classic").version(logbackVersion)
library("ktorServerTestsJvm", "io.ktor", "ktor-server-tests-jvm").version(ktorVersion)
library("kotlinTestJunit", "org.jetbrains.kotlin", "kotlin-test-junit").version(kotlinVersion)
}
}
}

Inside the settings.gradle.kts file I define the variables to reference the dependency versions, which are defined in gradle.properties. After that I use the VersionCatalogBuilder to define the used dependencies. I specify a name for the version catalog (which is later used for reference).

For every dependency I need to specify an alias (which is later used for reference), the group and the artifact name. To use the correct version I need to set it to the corresponding variable defined above.

To use the dependencies inside a build.gradle.kts file I need to specify the name of the version catalog and the alias of the library, that’s all.

dependencies {
implementation(libs.ktorServerCoreJvm)
implementation(libs.ktorServerAuthJvm)
implementation(libs.ktorServerContentNegotiationJvm)
implementation(libs.ktorServerJacksonJvm)
implementation(libs.ktorServerNettyJvm)
implementation(libs.logback)
testImplementation(libs.ktorServerTestsJvm)
testImplementation(libs.kotlinTestJunit)
}

With this change I can manage all the dependency versions in one place.

There is one additional optimization available, which can help to reduce the definition of several related dependencies. In the above example I can see that I define lots of Ktor related libraries all with the same version. It is possible to group them together as a so called bundle. Inside the VersionCatalogBuilder body, I add a bundle, which also has an alias name for reference and additionally a list of library alias names, which should be used together.

bundle("ktor", listOf(
"ktorServerCoreJvm",
"ktorServerAuthJvm",
"ktorServerContentNegotiationJvm",
"ktorServerJacksonJvm",
"ktorServerNettyJvm"
))

With this change I can update the depencency section of the build.gradle.kts file. It’s much shorter now.

dependencies {
implementation(libs.bundles.ktor)
implementation(libs.logback)
testImplementation(libs.ktorServerTestsJvm)
testImplementation(libs.kotlinTestJunit)
}

In the next step I do the same changes for the used plugins.

Inside the settings.gradle.kts file I define a pluginManagement block. To use the plugin version, which I defined in gradle.properties I again define them as variables. As a next step I add a plugins section and add the necessary plugins with corresponding versions to it.

pluginManagement{
val ktorVersion: String by settings
val kotlinVersion: String by settings

plugins {
id("io.ktor.plugin") version ktorVersion
kotlin("jvm") version kotlinVersion
}

repositories{
gradlePluginPortal()
}
}

Now that this is done, I can use the dependencies inside of build.gradle.kts files without version.

plugins {
kotlin("jvm")
id("io.ktor.plugin")
}

The result of this changes, I have defined all the used versions of dependencies and plugins in one place, the gradle.properties file. This makes the process of updating version easier, because I don’t have to search on several places for the definitions. But what still remains is the manual work to search for new versions for every dependency/plugin.

Version Catalog Update Plugin

For the automation of the update version process there is a Gradle plugin available: https://github.com/littlerobots/version-catalog-update-plugin. This plugins comes with 2 relevant tasks:

  • Create a report for all used dependencies/plugins, listing which of them are up-to-date and for which there are new versions available.
  • Automatically update all dependencies/plugins with new versions according to the plugin configuration.

To be able to use this plugin I need to change the way I use the version catalog. Instead of defining the dependencies and plugins inside the settings.gradle.kts file, I need to move them to a separate file, called libs.versions.toml located inside the gradle directory. The syntax is very simple.

As the next step I migrate the version catalog configuration of settings.gradle.kts to the toml file. The versions inside the gradle.properties file are removed and because they are now part of the toml file.

[versions]
ktorVersion = "2.2.1"
kotlinVersion = "1.8.0"
logbackVersion = "1.2.11"

[libraries]
ktorServerCoreJvm = {module = "io.ktor:ktor-server-core-jvm", version.ref = "ktorVersion"}
ktorServerAuthJvm = {module = "io.ktor:ktor-server-auth-jvm", version.ref = "ktorVersion"}
ktorServerContentNegotiationJvm = {module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktorVersion"}
ktorServerJacksonJvm = {module = "io.ktor:ktor-serialization-jackson-jvm", version.ref = "ktorVersion"}
ktorServerNettyJvm = {module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktorVersion"}
logback = {module = "ch.qos.logbac:logback-classic", version.ref = "logbackVersion"}
ktorServerTestsJvm = {module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktorVersion"}
kotlinTestJunit = {module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinVersion"}

[bundles]
ktor = [
"ktorServerCoreJvm",
"ktorServerAuthJvm",
"ktorServerContentNegotiationJvm",
"ktorServerJacksonJvm",
"ktorServerNettyJvm"
]

For the dependencies definition inside of build.gradle.kts no change is necessary, but for the plugins I need to do the following update:

@Suppress("DSL_SCOPE_VIOLATION") 
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.ktor)
}

The current IntelliJ version shows an error for this syntax. But this is an IntelliJ problem, not a Gradle problem (e.g. see https://github.com/gradle/gradle/issues/18107). The Gradle tasks are still working as expected. To not show errors in problems view of IntelliJ I added a suppression for now.

The next step is to add the 2 required plugins for the automatic version update. I also define them inside the toml file.

[versions]
versionUpdateVersion = "0.44.0"
versionCatalogUpdateVersion = "0.7.0"

[plugins]
versionUpdate = { id = "com.github.ben-manes.versions", version.ref = "versionUpdateVersion"}
catalogUpdate = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdateVersion"}

If a multi-module project is used, the plugins needs to be defined inside the root build.gradle.kts file, in the sample project only one is available. After refreshing the project there are the already mentioned new Gradle tasks available.

I first execute the

gradle versionCatalogUpdate 

task, which is creating a report for the current project (located in build/dependencyUpdates). The report is available as json, xml and text file:

------------------------------------------------------------
: Project Dependency Updates (report to plain text file)
------------------------------------------------------------

The following dependencies are using the latest milestone version:
- com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin:0.44.0
- io.ktor:ktor-bom:2.2.1
- io.ktor:ktor-serialization-jackson-jvm:2.2.1
- io.ktor:ktor-server-auth-jvm:2.2.1
- io.ktor:ktor-server-content-negotiation-jvm:2.2.1
- io.ktor:ktor-server-core-jvm:2.2.1
- io.ktor:ktor-server-netty-jvm:2.2.1
- io.ktor:ktor-server-tests-jvm:2.2.1
- io.ktor.plugin:io.ktor.plugin.gradle.plugin:2.2.1
- nl.littlerobots.version-catalog-update:nl.littlerobots.version-catalog-update.gradle.plugin:0.7.0
- org.jetbrains:annotations:13.0
- org.jetbrains.kotlin:kotlin-reflect:1.6.21
- org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.8.0
- org.jetbrains.kotlin:kotlin-stdlib:1.6.21
- org.jetbrains.kotlin:kotlin-stdlib-common:1.6.21
- org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21
- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21
- org.jetbrains.kotlin:kotlin-test-junit:1.8.0
- org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:1.8.0

The following dependencies have later milestone versions:
- ch.qos.logback:logback-classic [1.2.11 -> 1.4.5]
http://logback.qos.ch
- org.apache.logging.log4j:log4j-core [2.17.1 -> 2.19.0]
https://logging.apache.org/log4j/2.x/

Gradle release-candidate updates:
- Gradle: [7.5.1 -> 7.6 -> 8.0-rc-1]

Because I don’t want to update all dependency automatically I can execute the above Gradle task with interactive flag, which gives an overview about the updates that will be done.

gradle versionCatalogUpdate --interactive

This creates a libs.versions.update.toml file in gradle directory.

# Version catalog updates generated at 2023-01-03T19:26:29.4054163
#
# Contents of this file will be applied to libs.versions.toml when running versionCatalogApplyUpdates.
#
# Comments will not be applied to the version catalog when updating.
# To prevent a version upgrade, comment out the entry or remove it.
#
[libraries]
# From version 1.2.11 --> 1.4.5
logback = "ch.qos.logback:logback-classic:1.4.5"

Now I have an overview about what version updates will be done when applying them. This gives me the chance to disable the update for specified dependencies/plugins e.g. because of version incompatibility. In this case I can comment the corresponding entry in the file and it will be skipped by the apply of the updates.

With below Gradle task I can apply the mentioned version updates:

gradle versionCatalogApplyUpdates

Until now I used the default configuration of the version update plugin. This automatically updates all available dependencies/plugins, for which a newer version is available. It is also not configured which versions are treated as new (snapshot, final, release candidate).

Configure Versions Plugin

I start by configuration of the Gradle versions plugin. Because I don’t want to update to not stable versions, I can configure the plugin accordingly. You can find a good documentation in the README of the plugin. I take one of the example snippets of the README to only take stable versions as candidates for update.

fun isNonStable(version: String): Boolean {
val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.toUpperCase().contains(it) }
val regex = "^[0-9,.v-]+(-r)?$".toRegex()
val isStable = stableKeyword || regex.matches(version)
return isStable.not()
}

// https://github.com/ben-manes/gradle-versions-plugin
tasks.withType<DependencyUpdatesTask> {
rejectVersionIf {
isNonStable(candidate.version)
}
}

Configure Version Catalog Update Plugin

The version catalog update plugin can be configured in order to specify the update behavior for dependencies/plugins explicitely. For this there are 2 ways available. I can add a configuration to the build.gradle.kts file

versionCatalogUpdate {
// sort the catalog by key (default is true)
sortByKey.set(true)
// Referenced that are pinned are not automatically updated.
// They are also not automatically kept however (use keep for that).
pin {
// keep has the same options as pin to keep specific entries
}
keep {
// keep has the same options as pin to keep specific entries

// keep versions without any library or plugin reference
keepUnusedVersions.set(true)
// keep all libraries that aren't used in the project
keepUnusedLibraries.set(true)
// keep all plugins that aren't used in the project
keepUnusedPlugins.set(true)
}
}

or directly work inside of libs.versions.toml file.

[versions]
# @keep this version, for example because it is not used in a dependency declaration
minSdk = "21"
# Pinning the version will keep every library using this version on 1.6.10
# @pin
kotlin = "1.7.20"

[libraries]
# @pin this library to version 1.0
my-library = "com.example.library:1.0"

Because I want to keep all the version related information in once place, I choose the second option.

There are two annotations available:

  • @pin — keeps the specified version or library with version.
  • @keep — not removes e.g. unused version entries

With this I have a good possibility to configure dependencies/plugins explicitely and still use the automatic version update mechanism.

You can find the repository for this article on Github:

https://github.com/PoisonedYouth/gradle-dependency-update

--

--