Gradle "Build Finished Plugin": How to ensure compatibility with older Gradle versions

Our Advanced CI team is always trying to push what we have further, which often means we have to support APIs and code that are not yet fully stable. On the other hand, we are bound to support projects that are using deprecated code to a certain extent.

This is particularly true for build systems that are rapidly changing to support the needs of application developers, such as Gradle. During our investigation for certain features coming soon™ to our platform, we encountered a funny situation that seemed worth sharing.

How to know when a build finished

Over the years, Gradle changed a lot, and that is certainly observable in the events we can get around the lifecycle. One of them is particularly interesting: buildFinished. There are multiple ways to be notified of the end of a project’s build, but the most commonly known are:

– BuildEventListener.buildFinished - which is deprecated under Gradle 8+

– FlowProviders.buildWorkResult - which is still in Incubating state

The above leaves us in a peculiar situation where the old way is not acceptable any more (especially because it doesn’t play well with configuration cache… or at all), and the new one is still a bit volatile. But that shouldn’t stop us!

Now the next question is how can we write a plugin that is applicable to both situations? Or rather, how can we make a facade that hides the differences and Gradle doesn’t complain when applied to a Gradle 8+ project but is still applicable to a Gradle 7 one.

Make the compiler decide

There is always much to learn, in this case: the Java compiler does not need ugly #ifdefs to ignore part of your code to tell compile time that part of it will never run. So if you hide the implementations of a plugin that won’t be compatible with the old versions of Gradle in an if like that, everything should be fine. Now, this is probably something that is old news for Kotlin veterans, but if there is one thing we have learned working here, it’s that there is always fresh blood out there who haven’t heard about the old tricks. So let’s define an extension on Project that can apply a plugin based on the current Gradle version.

1package io.bitrise.gradle.common.etc
2
3import org.gradle.api.Project
4import org.gradle.api.invocation.Gradle
5import org.gradle.kotlin.dsl.apply
6
7fun Gradle.willUseConfigCache(): Boolean {
8    val parts = gradleVersion.split(".")
9    val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
10    val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
11
12    when (major) {
13        in 9 .. Int.MAX_VALUE -> return true
14        8 -> return minor >= 1
15        else -> return false
16    }
17}
18
19fun Project.applyBuildFinishedPlugin() {
20    println("[Bitrise Etc] Gradle version: ${gradle.gradleVersion}")
21    if (gradle.willUseConfigCache()) {
22        apply<BuildFinishedFlowPlugin>()
23    } else {
24        apply<BuildFinishedCompatPlugin>()
25    }
26}

The above takes the gradle parameter available through the project, checks the version through gradleVersion and then calls the Kotlin DSL apply function with the plugin that is acceptable for that version. As simple as that.

You can apply this for example through an init.gradle.kts file with the following lines:

1import io.bitrise.gradle.common.etc.applyBuildFinishedPlugin
2
3initscript {
4    repositories {
5        mavenLocal()
6        mavenCentral()
7    }
8    dependencies {
9        classpath("io.bitrise.gradle:common:local-SNAPSHOT")
10    }
11}
12
13rootProject {
14    applyBuildFinishedPlugin()
15}

Assuming that you are using a local snapshot for testing. The above, of course, contains the package paths we used in our tests. As the Flow API is still a bit under the radar for some people out there, we will share the implementations of the plugins as well.

The plugins

The simpler one is the old-school BuildListener approach. Not much to say here: you create an almost empty plugin that adds a custom listener in which you hook up your logic in the buildFinished method.

1class BuildFinishedCompatPlugin: Plugin<Project> {
2    val logger = SimpleConsoleLogger(LogLevel.DEBUG, prefixerWithTimestamp("Bitrise Etc"))
3
4    override fun apply(target: Project) {
5        target.gradle.addBuildListener(BuildFinishedListener(logger))
6    }
7}
8
9class BuildFinishedListener(val logger: SimpleLogger): BuildListener {
10    override fun settingsEvaluated(settings: Settings) {
11    }
12
13    override fun projectsLoaded(gradle: Gradle) {
14    }
15
16    override fun projectsEvaluated(gradle: Gradle) {
17    }
18
19    @Deprecated("Deprecated")
20    override fun buildFinished(result: BuildResult) {
21        logger.debug("Build finished - logged from Compat version")
22    }
23} 

The more interesting one is the “modern” plugin where you have to create a FlowAction and tie that to the flow provider buildWorkResult. Both the FlowScope and the FlowProviders are available through injection in a plugin, so the action should be meeting them in the body of the plugin's apply function.

The FlowAction.execute function will be executed only when every input in the action’s Parameters is ready, so we have to use the FlowProviders.buildWorkResult and map it into one of the properties in the Parameters.

In a real-life scenario, that might be something more meaningful (like a file or another resource that was generated during the build) but here we only take that as a boolean property which will only have a value when buildWorkResult also does.

1abstract class BuildFinishedFlowPlugin: Plugin<Project> {
2    @get:Inject
3    abstract val flowScope: FlowScope
4
5    @get:Inject
6    abstract val flowProviders: FlowProviders
7
8    val logger = SimpleConsoleLogger(LogLevel.DEBUG, prefixerWithTimestamp("Bitrise Etc"))
9
10    override fun apply(target: Project) {
11        flowScope.always(BuildFinishedFlowHandler::class.java) {
12            parameters.logger.set(logger)
13            parameters.buildFinished.set(
14                flowProviders.buildWorkResult.map{ true }
15            )
16        }
17    }
18}
19
20class BuildFinishedFlowHandler: FlowAction<BuildFinishedFlowHandler.Parameters> {
21    interface Parameters : FlowParameters {
22        @get:Input
23        val buildFinished: Property<Boolean>
24
25        @get: Input
26        val logger: Property<SimpleLogger>
27    }
28
29    override fun execute(parameters: Parameters) {
30        parameters.logger.orNull?.debug("Build finished - logged from Flow version")
31    }
32}

The results

The above runs without problems on projects running under Gradle 7, ignoring the other implementation. In our case, it prints out the Gradle version at the beginning and a prompt at the end.

1[Bitrise Etc] Gradle version: 7.6.2
2
3> Configure project :app
4...
5> Task :app:assembleDebug UP-TO-DATE
6[Bitrise Etc] [14:08:01] Build finished - logged from Compat version
7
8BUILD SUCCESSFUL in 1s
930 actionable tasks: 30 up-to-date

Projects running newer versions of Gradle will ignore the BuildListener addition—which would otherwise stop the build with a configuration-cache error—and will instead print the Gradle version (only if the configuration cache is not found) followed by the “build finished” message at the end.

1Calculating task graph as configuration cache cannot be reused because init script '../../.gradle/init.d/etc.bitrise.init.gradle.kts' has changed.
2[Bitrise Etc] Gradle version: 8.9
3> Task :app:preBuild UP-TO-DATE
4...
5> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
6[Bitrise Etc] [13:45:04] Build finished - logged from Flow version
7
8BUILD SUCCESSFUL in 1s
936 actionable tasks: 1 executed, 35 up-to-date
10Configuration cache entry stored.

We hope this helps some people out there and allows a peek behind the scenes in our day-to-day business. Happy building!

– Bitrise Advanced CI team –

Get Started for free

Start building now, choose a plan later.

Sign Up

Get started for free

Start building now, choose a plan later.