The workshop will guide you through the process of creating Gradle tasks, focusing on inputs,outputs, and making tasks cacheable.
You'll also learn:
How to create a Gradle project extension to extend plugin functionality. How to create a Gradle task that codegen a Kotlin file. Wire the generated code with the Kotlin source sets. If time, how to use theProblems
API to report issues. Our hands-on project involves building a Gradle plugin that generates a Kotlin file containingproject metadata such as project version, group and name. This practical experience will covercreating Gradle plugins, tasks, and extensions, as well as reporting issues and integrating withsource sets.
The repository contains everything needed to start the workshop.
Thebuild-logic
included build contains the Gradle plugin which is going to be created. Theapplication
module will be used as a simple application to run the generated code by theplugin. To run the application, use the next CLI command:
Step 1: Create the Qonto plugin ✅ Create the Gradle plugin by extending the `Plugin` interface. Right-click on thebuild-logic
module. Create the directorysrc/main/kotlin/com/qonto/
. Create the fileQontoPlugin.kt
in the directory. Create the classQontoPlugin
and extends thePlugin
interface usingProject
as its typeparameter. package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project class QontoPlugin :Plugin <Project > {override fun apply (target : Project ) { target.logger.quiet(" Hello from QontoPlugin!" ) }}Register the plugin in the `build-logic` module with the `qonto` id. Open thebuild.gradle.kts
file inbuild-logic
module. Add the following code to the file below the plugins block. plugins { `kotlin- dsl`}gradlePlugin { plugins { register(" QontoPlugin" ) { id= " qonto" implementationClass= " com.qonto.QontoPlugin" } }} Add it to the version catalog. Open thelibs.versions.toml
file inside thegradle
directory. Add the plugin to the bottom of theplugins
section and sync the Gradle project. [versions ]kotlin =" 2.0.21" [plugins ]kotlin-jvm = {id =" org.jetbrains.kotlin.jvm" ,version.ref =" kotlin" }qonto = {id =" qonto" }# Add this line Apply the plugin in the `application` project. Open thebuild.gradle.kts
file inside theapplication
project. Apply the plugin in theplugins
block. plugins { application alias(libs.plugins.kotlin.jvm) alias(libs.plugins.qonto)// Add this line }application { mainClass= " com.qonto.application.MainKt" }group= " com.qonto" version= " 1.0.0" Step 2: Create the QontoGenerateProjectDataTask task ✅ Create a task with the minimum amount of code. Create the fileQontoGenerateProjectDataTask.kt
in thecom.qonto
package. Create the classQontoGenerateProjectDataTask
class and extends theDefaultTask
class. package com.qonto import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.logging.Logger import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.register import org.slf4j.LoggerFactory open class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger ): DefaultTask () {init { group= " qonto" description= " Generates the project data" } @TaskActionfun run () { logger.quiet(" Generating project data..." ) }companion object {const val NAME : String = " generateProjectData" fun register (project : Project ) {val generateProjectData: TaskProvider <QontoGenerateProjectDataTask >= project.tasks.register<QontoGenerateProjectDataTask >( name= NAME ,LoggerFactory .getLogger(" qonto" ), ) } }}Register the task. Call theregister
method on the taskcompanion object
within theapply
block in the plugin. package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project class QontoPlugin :Plugin <Project > {override fun apply (target : Project ) { target.logger.quiet(" Hello from QontoPlugin!" )QontoGenerateProjectDataTask .register(target)// Add this line }}Apply the base plugin. Use thepluginManager
to apply theBasePlugin
plugin package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.BasePlugin // Add this lineimport org.gradle.kotlin.dsl.apply // Add this lineclass QontoPlugin :Plugin <Project > {override fun apply (target : Project ) { target.pluginManager.apply (BasePlugin ::class )// Add this line target.logger.quiet(" Hello from QontoPlugin!" )QontoGenerateProjectDataTask .register(target) }}Wire the task with the `assemble` task. Use thenamed
method on thetasks
to get theassemble
task. UsedependsOn
to make theassemble
task depend on thegenerateProjectData
task. package com.qonto import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.logging.Logger import org.gradle.api.plugins.BasePlugin // Add this lineimport org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.register import org.slf4j.LoggerFactory open class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger ): DefaultTask () {init { group= " qonto" description= " Generates the project data" } @TaskActionfun run () { logger.quiet(" Generating project data..." ) }companion object {const val NAME : String = " generateProjectData" fun register (project : Project ) {val generateProjectData: TaskProvider <QontoGenerateProjectDataTask >= project.tasks.register<QontoGenerateProjectDataTask >( name= NAME ,LoggerFactory .getLogger(" qonto" ), )// Add these lines project.tasks.named(BasePlugin .ASSEMBLE_TASK_NAME ).configure { dependsOn(generateProjectData) } } }}Step 3: Add inputs and outputs to the task ✅ Make the task cacheable. Add the@CacheableTask
annotation to theQontoGenerateProjectDataTask
class. package com.qonto // ...import org.gradle.api.tasks.CacheableTask // Add this line// ... @CacheableTask// Add this lineopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger ): DefaultTask () {// ... }Add inputs to the task and configure them. Use the@Input
annotation to mark the properties as inputs in theQontoGenerateProjectDataTask
. Wire them within theconfigure
method block from theTaskProvider
. Use theprovider
lambda to do lazy evaluation of the provided properties. package com.qonto import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.logging.Logger import org.gradle.api.model.ObjectFactory import org.gradle.api.plugins.BasePlugin import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.property import org.gradle.kotlin.dsl.register import org.slf4j.LoggerFactory @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger ,private val objects: ObjectFactory ,): DefaultTask () { @Inputval projectGroup: Property <String >= objects.property() @Inputval projectName: Property <String >= objects.property() @Inputval projectVersion: Property <String >= objects.property()init { group= " qonto" description= " Generates the project data" } @TaskActionfun run () { logger.quiet(" Generating project data..." ) logger.quiet(" Project group:${projectGroup.get()} " ) logger.quiet(" Project name:${projectName.get()} " ) logger.quiet(" Project version:${projectVersion.get()} " ) }companion object {const val NAME : String = " generateProjectData" fun register (project : Project ) {val generateProjectData: TaskProvider <QontoGenerateProjectDataTask >= project.tasks.register<QontoGenerateProjectDataTask >( name= NAME ,LoggerFactory .getLogger(" qonto" ), ) generateProjectData.configure { projectGroup.set(project.provider {" ${project.group} " }) projectName.set(project.provider { project.name }) projectVersion.set(project.provider {" ${project.version} " }) } project.tasks.named(BasePlugin .ASSEMBLE_TASK_NAME ).configure { dependsOn(generateProjectData) } } }}Add outputs to the task and configure them. Use the@OutputDirectory
annotation to mark theoutputDir
property as an output in theQontoGenerateProjectDataTask
. Use the@Internal
annotation to mark theoutputFile
property as an internal property in theQontoGenerateProjectDataTask
. package com.qonto import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.ProjectLayout import org.gradle.api.file.RegularFileProperty import org.gradle.api.logging.Logger import org.gradle.api.model.ObjectFactory import org.gradle.api.plugins.BasePlugin import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.property import org.gradle.kotlin.dsl.register import org.slf4j.LoggerFactory @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () { @Inputval projectGroup: Property <String >= objects.property() @Inputval projectName: Property <String >= objects.property() @Inputval projectVersion: Property <String >= objects.property() @OutputDirectoryval outputDir: DirectoryProperty = objects .directoryProperty() .convention(layout.buildDirectory.dir(" generated/kotlin/com/qonto" )) @Internalval outputFile: RegularFileProperty = objects .fileProperty() .convention { outputDir.file(" Project.kt" ).get().asFile }init { group= " qonto" description= " Generates the project data" } @TaskActionfun run () { logger.quiet(" Generating project data..." ) logger.quiet(" Project group:${projectGroup.get()} " ) logger.quiet(" Project name:${projectName.get()} " ) logger.quiet(" Project version:${projectVersion.get()} " ) }companion object {const val NAME : String = " generateProjectData" fun register (project : Project ) {val generateProjectData: TaskProvider <QontoGenerateProjectDataTask >= project.tasks.register<QontoGenerateProjectDataTask >( name= NAME ,LoggerFactory .getLogger(" qonto" ), ) generateProjectData.configure { projectGroup.set(project.provider {" ${project.group} " }) projectName.set(project.provider { project.name }) projectVersion.set(project.provider {" ${project.version} " }) } project.tasks.named(BasePlugin .ASSEMBLE_TASK_NAME ).configure { dependsOn(generateProjectData) } } }}Step 4: Change the task implementation to codegen a file and wire it with the Kotlin source set ✅ Change the task implementation to generate a file by using the inputs and outputs. Use theoutputFile
andoutputDir
properties to generate a file with the project data. package com.qonto // ... @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () {// ... @TaskActionfun run () {// ... outputDir.get().asFile.mkdirs() outputFile.get().asFile.apply { createNewFile() writeText(""" package com.qonto data object Project { const val group: String = "${projectGroup.get()} " const val name: String = "${projectName.get()} " const val version: String = "${projectVersion.get()} " } """ .trimIndent(), ) } }// ... }Add the generated directory to the main Kotlin source set (WRONG WAY). UsepluginManager
to react to theorg.jetbrains.kotlin.jvm
plugin being applied. Use theconfigure
method on theKotlinProjectExtension
to add the generated directory to themain Kotlin source set. Run./gradlew assemble
or./gradlew run
to see the issue. package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.BasePlugin import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension class QontoPlugin :Plugin <Project > {override fun apply (target : Project ) { target.pluginManager.apply (BasePlugin ::class ) target.logger.quiet(" Hello from QontoPlugin!" )QontoGenerateProjectDataTask .register(target) target.pluginManager.withPlugin(" org.jetbrains.kotlin.jvm" ) { target.configure<KotlinProjectExtension > { sourceSets.named(" main" ) { kotlin.srcDirs(target.layout.buildDirectory.dir(" generated/kotlin" )) } } } }}Fix the issue above by wiring the task directly with the Kotlin source set. Use thenamed
method on thesourceSets
to get themain
source set. Use thekotlin.srcDirs
method to add the task outputs to the source set. Run./gradlew assemble
or./gradlew run
to see the task being executed. Modify themain
function to print the generated project data. package com.qonto // ... @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () {// ...companion object {const val NAME : String = " generateProjectData" fun register (project : Project ) {// .. project.pluginManager.withPlugin(" org.jetbrains.kotlin.jvm" ) { project.configure<KotlinProjectExtension > { sourceSets.named(" main" ) { kotlin.srcDirs(generateProjectData) } } } } }}package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.BasePlugin import org.gradle.kotlin.dsl.apply class QontoPlugin :Plugin <Project > {override fun apply (target : Project ) { target.pluginManager.apply (BasePlugin ::class ) target.logger.quiet(" Hello from QontoPlugin!" )QontoGenerateProjectDataTask .register(target) }}package com.qonto.application fun main () {println (""" Project data: Group:${com.qonto.Project .group} Name:${com.qonto.Project .name} Version:${com.qonto.Project .version} """ .trimIndent() )}Step 5: Create the QontoExtension to allow the user to specify default values ✅ Create the QontoExtension. Create the fileQontoExtension.kt
in thecom.qonto
package. Create the classQontoExtension
and add theprojectDescription
property. package com.qonto import javax.inject.Inject import org.gradle.api.Project import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.property open class QontoExtension @Injectconstructor ( objects: ObjectFactory ,) {val projectDescription: Property <String >= objects.property<String >().convention(" Gradle workshop" )companion object {const val NAME = " qonto" fun register (project : Project ):QontoExtension = project.extensions.create(NAME ) }}Change the task implementation and wire its configuration with the extension. Add theprojectDescription
property as input in theQontoGenerateProjectDataTask
. Use theqontoExtension
to wire theprojectDescription
property of the task in thePluginQonto
. Modify thebuild.gradle.kts
file in theapplication
module to use theqonto
extension. Modify themain
function to print the generated project data with theprojectDescription
. Run./gradlew run
to see the task being executed. package com.qonto import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.ProjectLayout import org.gradle.api.file.RegularFileProperty import org.gradle.api.logging.Logger import org.gradle.api.model.ObjectFactory import org.gradle.api.plugins.BasePlugin import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.property import org.gradle.kotlin.dsl.register import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension import org.slf4j.LoggerFactory @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () { @Inputval projectGroup: Property <String >= objects.property() @Inputval projectName: Property <String >= objects.property() @Inputval projectVersion: Property <String >= objects.property() @Inputval projectDescription: Property <String >= objects.property<String >() @OutputDirectoryval outputDir: DirectoryProperty = objects .directoryProperty() .convention(layout.buildDirectory.dir(" generated/kotlin/com/qonto" )) @Internalval outputFile: RegularFileProperty = objects .fileProperty() .convention { outputDir.file(" Project.kt" ).get().asFile }init { group= " qonto" description= " Generates the project data" } @TaskActionfun run () { logger.quiet(" Generating project data..." ) logger.quiet(" Project group:${projectGroup.get()} " ) logger.quiet(" Project name:${projectName.get()} " ) logger.quiet(" Project version:${projectVersion.get()} " ) logger.quiet(" Project description:${projectDescription.get()} " ) outputDir.get().asFile.mkdirs() outputFile.get().asFile.apply { createNewFile() writeText(""" package com.qonto data object Project { const val group: String = "${projectGroup.get()} " const val name: String = "${projectName.get()} " const val version: String = "${projectVersion.get()} " const val description: String = "${projectDescription.get()} " } """ .trimIndent(), ) } }companion object {const val NAME : String = " generateProjectData" fun register (project : Project ,qontoExtension : QontoExtension ) {val generateProjectData: TaskProvider <QontoGenerateProjectDataTask >= project.tasks.register<QontoGenerateProjectDataTask >( name= NAME ,LoggerFactory .getLogger(" qonto" ), ) generateProjectData.configure { projectGroup.set(project.provider {" ${project.group} " }) projectName.set(project.provider { project.name }) projectVersion.set(project.provider {" ${project.version} " }) projectDescription.set(qontoExtension.projectDescription) } project.tasks.named(BasePlugin .ASSEMBLE_TASK_NAME ).configure { dependsOn(generateProjectData) } project.pluginManager.withPlugin(" org.jetbrains.kotlin.jvm" ) { project.configure<KotlinProjectExtension > { sourceSets.named(" main" ) { kotlin.srcDirs(generateProjectData) } } } } }}package com.qonto import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.BasePlugin import org.gradle.kotlin.dsl.apply class QontoPlugin :Plugin <Project > {override fun apply (target : Project ) {val qontoExtension: QontoExtension = QontoExtension .register(target) target.pluginManager.apply (BasePlugin ::class ) target.logger.quiet(" Hello from QontoPlugin!" )QontoGenerateProjectDataTask .register(target, qontoExtension) }}plugins { application alias(libs.plugins.kotlin.jvm) alias(libs.plugins.qonto)}application { mainClass= " com.qonto.application.MainKt" }group= " com.qonto" version= " 1.0.0" qonto { projectDescription= " The Qonto Gradle Workshop!" // projectDescription.set("Qonto Workshop!") same as above due to the new Kotlin Compiler plugin } package com.qonto.application fun main () {println (""" Project data: Group:${com.qonto.Project .group} Name:${com.qonto.Project .name} Version:${com.qonto.Project .version} Additional lines:${com.qonto.Project .description} """ .trimIndent() )}Step 6: Change one task's input to be an option ✅ Change the task's input to be an option. Add the@Option
annotation to theprojectDescription
property in theQontoGenerateProjectDataTask
. package com.qonto // ...import org.gradle.api.tasks.options.Option // ... @CacheableTaskopen class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () {// ... @Input @Option(option= " projectDescription" , description= " The project description" )val projectDescription: Property <String >= objects.property<String >()// ... }Run the task via CLI by passing the option with a different value. Run the task with the--projectDescription
option to see the new value. ./gradlew run generateProjectData --projectDescription=" New project description!" Check the output to see the new project description. Step 7: Add version validation report withProblems
API ✅ Gradle documentation about the `Problems` API Gradle has aProblems
API that allows you to report problems. The docs can be found:
It is very simple, theProblems
interface is injected in any place you want to do a report, it canbe a plugin, a task, etc. Then you can use thereporting
orthrowing
methods to report aproblem.
Update the task `QontoGenerateProjectDataTask` to report an invalid version @CacheableTaskabstract class QontoGenerateProjectDataTask @Injectconstructor (private val logger: Logger , objects: ObjectFactory , layout: ProjectLayout ,): DefaultTask () {// Inject via constructor fails in Gradle 8.12, move to constructor when it is fixed @get:Injectabstract val problems: Problems // ... @TaskActionfun run () {if (! projectVersion.get().matches(VersionRegex )) {val problemGroup: ProblemGroup = ProblemGroup .create(" qonto" ," qonto" )val problemId: ProblemId = ProblemId .create(" invalid-version" ," invalid-version" , problemGroup)val exception= IllegalStateException (" The project version is invalid" ) problems.reporter.throwing(exception, problemId) { contextualLabel(" The project version '${projectVersion.get()} ' is invalid" ) severity(Severity .ERROR ) solution(" Provide a valid version (example: 'project.version = 1.0.0')" ) } }// ... }companion object {// ...private val VersionRegex = Regex (""" ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?${' $' } """ , ) }} After calling the task, if theproject::version
assigned in thebuild.gradle.kts
file is notvalid, the build will fail and the error will be added to the problems report file, which can befound ingradle-workshop/build/reports/problems/problems-reports.html
.
The file is in thebuild
root directory as it will summarize all the problems in the wholeproject, that includes all Gradle projects.