A beautiful workaround for accessing Gradle Version Catalogs from Precompiled Script Plugins

TL;DR: Apply the gradle-buildconfig-plugin or the BuildKonfig plugin to buildSrc/build.gradle.kts.

Featured Image Source
Source: Medium

There is a long-lasted Gradle issue gradle/gradle#15383 that has almost been impossible to resolve, and it has been very frustrating for the Gradle community. It is about accessing the version catalogs from precompiled script plugins. While many workarounds exist, today I want to show you the workaround I found recently.

Let’s begin.

The issue gradle/gradle#15383, created by melix  Cรฉdric Champeau (melix), states:

In a similar way to gradle/gradle#15382, we want to make the version catalogs accessible to precompiled script plugins. Naively, one might think that it’s easier to do because precompiled script plugins are applied to the “main” build scripts, but this isn’t necessarily the case:

  • First, precompiled script plugins can be published too, meaning that they are no different from regular plugins in practice
  • Second, precompiled script plugins can be declared in included builds, not just buildSrc

Therefore, the “version catalog” that a precompiled script plugin should see cannot be the catalog declared in the “main build”. It cannot be, either, the catalog declared in the project which itself declares the precompiled script plugin (typically the settings file of the buildSrc project): in particular, the catalogs declared in buildSrc/settings.gradle are for the build logic of buildSrc itself, not for the “main” build.

In short, precompiled script plugins are not able to access version catalogs, and it is quite hard to fix this issue. But why? To better understand this issue, let’s review some concepts in Gradle.

Gradle official documentation says:

A composite build is a build that includes other builds.

Basically, this is the includeBuild("relative/path/to/another/gradle/project") statement in your settings.gradle.kts file. The included build itself is a valid, isolated, and self-contained Gradle project that has its own settings.gradle.kts and/or build.gradle.kts file.

You can try adding the Gradle wrapper to relative/path/to/another/gradle/project and opening the folder in your IDEA. Watch your IDEA treat it as a normal Gradle project, just like your typical Java Gradle projects ๐Ÿ˜‚.

Gradle official documentation recommends two ways to structure your multi-module project. Either use buildSrc or a composite build to extract common build logic into a build project, used by subprojects in your multi-module project.

https://docs.gradle.org/current/userguide/img/multi-project-standards.png
Structure of the multi-module project, from the Gradle official documentation

Here is the folder structure:

text

.
โ”œโ”€โ”€ gradle/
โ”œโ”€โ”€ gradlew
โ”œโ”€โ”€ settings.gradle.kts
โ”œโ”€โ”€ build-logic/ or buildSrc/
โ”‚   โ”œโ”€โ”€ settings.gradle.kts
โ”‚   โ””โ”€โ”€ conventions
โ”‚       โ”œโ”€โ”€ build.gradle.kts
โ”‚       โ””โ”€โ”€ src/main/kotlin/shared-build-conventions.gradle.kts
โ”œโ”€โ”€ sub-project1/
โ”‚   โ””โ”€โ”€ build.gradle.kts
โ”œโ”€โ”€ sub-project2/
โ”‚   โ””โ”€โ”€ build.gradle.kts
โ””โ”€โ”€ lib/
    โ””โ”€โ”€ build.gradle.kts

Since Gradle 8.0, buildSrc has started to behave more like a composite build. Both ways are now more or less the same.

Cool! ๐Ÿ˜ฒ So now your common build logic is a self-contained Gradle project by itself.

In fact, if you look closely at the folder structure of the build project.

text

.
โ”œโ”€โ”€ settings.gradle.kts
โ””โ”€โ”€ conventions
    โ”œโ”€โ”€ build.gradle.kts
    โ””โ”€โ”€ src/main/kotlin/shared-build-conventions.gradle.kts

It looks like a normal Gradle project, right?

So, here comes the truth about the precompiled script plugin.

Have you ever wondered why the precompiled script plugin is placed in the src/main/kotlin folder? ๐Ÿค” Like the shared-build-conventions.gradle.kts example from above. Can it just stay in the first level in the build project like build-logic/shared-build-conventions.gradle.kts?

Well, here I would like to invite you to watch a video from jjohannes  Jendrik Johannes (jjohannes) titled Understanding Gradle #25 โ€“ Using Java to configure builds. Basically, each Gradle script is just an implementation of the Plugin interface. The build script build.gradle.kts corresponds to Plugin<Project>, and the settings script settings.gradle.kts corresponds to Plugin<Settings>.

https://docs.gradle.org/current/userguide/img/author-gradle-4.png
A build script is just a piece of code configuring a Project instance, same applies to the precompiled script plugin

Therefore, the precompiled script plugin, placed inside the src/main/<jvm language> folder, is also business logic code, but the business logic here is the build logic of your main project. Such code typically starts from the apply(Project project) method in your class MyPlugin implements Plugin<Project> implementation.

Now, let’s look back to the statement from the issue gradle/gradle#15383:

We want to make the version catalogs accessible to precompiled script plugins

Let’s translate this using the knowledge we reviewed above; it becomes:

“A file called libs.versions.toml is meant to be used in the build.gradle.kts file. Now we want to use it in the business code in the src/main/kotlin folder.”

Now it sounds weird, right? ๐Ÿค”

That’s why the issue is rarely possible to be solved, because it is a design issue that breaks the separation of concerns principle.

Several intelligent people have proposed great workarounds to this issue. The most famous one is from Vampire  Bjรถrn Kautler (Vampire)’s comment, where you add a sneaky Gradle internal file to the dependencies {} block. It works, but it is very hacky, not guaranteed to work in any Gradle project (at least it doesn’t work for me ๐Ÿ˜•), not working in the plugins {} block, and the workaround relies on a Gradle internal API that is subject to change anytime in the future.

Are there other workarounds, preferably without hacks?

Fortunately, there is one for the plugins {} block mentioned by this comment. Since applying external plugins to the precompiled script plugin requires adding the corresponding dependency of the external plugin to the build.gradle.kts file, you can add that dependency to the version catalog. This method assumes that the settings.gradle.kts file in the build project imports the same version catalog used in the main project. I also discovered that the method works for setting plugins, as I described in this forum post.

img/Screenshot 2024-05-12 170857.png
Screenshot from Jendrik Johannes (jjohannes)’s video about writing your own precompiled script plugin. One of the steps is to find the right coordinate of the plugin dependency. Fortunately, that coordinate can go into your version catalog.

I also found a hack-free workaround for the dependencies {} block and I described it in this comment. This method plays around the Gradle platform and build phases of Gradle in order to do the trick. However, that method is very annoying because adding/removing a dependency requires 3 modifications around your project.

Together, non-hacky workarounds can cover the plugins {} block and the dependencies {} block. In most cases, this is enough. But what about extensions? For example, if a precompiled script plugin applies the Micronaut Gradle Plugin, how to set the version of the Micronaut framework in the micronaut {} extension using the version catalog? ๐Ÿคทโ€โ™‚๏ธ

Off topic: A fun fact about my feature request to the Micronaut Gradle Plugin

One day, I was investigating the Micronaut framework, and I realized that I couldn’t apply any centralized version management solution mentioned in Understanding Gradle #09 โ€“ Centralizing Dependency Versions from jjohannesโ€‰โ€‰Jendrik Johannes (jjohannes).

So I created a feature request to the Micronaut team. I even investigated into the source code of the plugin and pointed out the codes that prevent the use of the version catalog or the Gradle platform.

The same person who created the issue gradle/gradle#15383, melixโ€‰โ€‰Cรฉdric Champeau (melix), responded. ๐Ÿ˜ฒ

He quickly came up with a PR, which adds a new option importMicronautPlatform to the Micronaut Gradle Plugin that can be set to false to allow you to achieve centralized version management with Gradle platform. Once I am able to use Gradle platform, I will be able to use the version catalog using the workaround I mentioned above.

Today, you can see that the option is mentioned in the Micronaut Gradle Plugin document.

All workarounds mentioned above are more or less doing one thing: “sending” the variables that are meant to be used in Gradle build scripts into the business code in the src/main/kotlin folder.

One day, I unintentionally found two Gradle plugins: the gradle-buildconfig-plugin and the BuildKonfig plugin.

Both plugins are typically used in Kotlin Multiplatform projects (typically Compose Multiplatform apps) to generate a Kotlin file that contains configuration variables you defined inside the build.gradle.kts script.

For example, if you have:

build.gradle.kts

plugins {
  // ...
  id("com.github.gmazzo.buildconfig") version <current version>
}

buildConfig {
  className("MyConfig")   // forces the class name. Defaults to 'BuildConfig'
  packageName("com.foo")  // forces the package. Defaults to '${project.group}'

  buildConfigField(String::class.java, 'APP_NAME', "my-project")
}
//...

You will get:

com.foo.MyConfig.kt

package com.foo

object MyConfig {
  const val APP_NAME: String = "my-project"
}

Suddenly, I realized that what if I apply this plugin to the build.gradle.kts inside the build project? ๐Ÿ˜ฒ

gradle/libs.versions.toml

[versions]
java = "21"
slf4j = "2.0.13"

[libraries]
slf4j-api = {module = "org.slf4j:slf4j-api", version.ref = "slf4j"}

build-logic/conventions/build.gradle.kts

plugins {
  // ...
  id("com.github.gmazzo.buildconfig") version <current version>
}

buildConfig {
  className("VersionCatalog")   // forces the class name. Defaults to 'BuildConfig'
  packageName("my.util")  // forces the package. Defaults to '${project.group}'

  buildConfigField(Int::class.java, "JAVA_VERSION", libs.versions.java.get().toInt())
  buildConfigField(String::class.java, "SLF4J_API", libs.dep.slf4j.get().toString())
}

Can my precompiled script plugin access JAVA_VERSION and SLF4J_API variables?

Guess what? It actually works! ๐Ÿ˜ฑ

build-logic/conventions/src/main/kotlin/shared-build-conventions.gradle.kts

import my.util.VersionCatalog

// other configs

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(VersionCatalog.JAVA_VERSION))
  }
}

dependencies {
  implementation(VersionCatalog.SLF4J_API)
}

// other configs

The method, that utilizes the gradle-buildconfig-plugin or the BuildKonfig plugin for accessing Gradle Version Catalogs from precompiled script plugins, is a beautiful workaround. It is not too hacky, guaranteed to work in any Gradle project, and doesn’t have the limitation where it is only accessible from the plugins {} block or the dependencies {} block. It is a great solution for centralizing version management in your Gradle project.

I hope this workaround can help you in your Gradle project. If you have any questions or suggestions, feel free to leave a comment below. ๐Ÿ˜Š