Writing Custom Gradle Tasks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; no toolchain was
involved, because the task reads a text file and does not compile anything). Every example was run through the wrapper in
scratch copies of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files, and the
console output is the same in both apart from the timings, the task counts (the Kotlin buildSrc has one task more) and
the build file name. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the
properties file that a real project commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
The commands use --console=plain. In the listings the blank lines, the "Consider enabling configuration cache" hint, the
link to the problems report and the Try: hints of failures are left out, the tasks of the buildSrc build are
collapsed to the single line (buildSrc tasks omitted), and the scratch directory is shown as /work/tidepool. Examples run
with --configuration-cache say so, and each example states whether it is compatible with the configuration cache.
Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not
run here".
Gradle ships tasks for the common jobs (Copy, Zip, Test, JavaCompile), and most builds only configure them. When
no built-in task fits, you write a task class: a small class with typed inputs and outputs and one method that does
the work. Declaring the inputs and outputs is what turns the class into something Gradle can order, skip when nothing
changed, run in parallel, restore from the build cache and cache in the configuration cache; the work itself is the easy
part. The Maven counterpart is a Mojo in a plugin (see
Writing Maven Plugins), and the Ant counterpart is a
Java class that extends Task (see
Writing Custom Ant Tasks); the same tidepool
station-file check is built on that page, so the three versions can be compared. The task concepts used below (the task
graph, outcomes, register versus create) are on
Gradle Tasks, and the Property and Provider types are on
Lazy Configuration & Providers.
Three ways to define a task
| Way | What it looks like | When to use it |
|---|---|---|
Configure a built-in task type |
|
The job is archiving, copying, running a program or testing; see Working with Files. |
Ad hoc task with |
|
A few lines of logic that live in one build script and are not reused. |
Task class |
|
The logic is reused, tested, cached or has a non-trivial set of inputs and outputs. |
The Gradle manual warns that the ad hoc style makes it easy to mix configuration-time and execution-time state and that
its inputs are untyped; a class keeps the two apart, because the properties and annotations are configuration and the
@TaskAction method is execution. The rest of this page is about the class.
The smallest task class
A task class extends DefaultTask, is declared abstract, declares each input as an abstract property, and has one
method annotated with @TaskAction:
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
abstract class Hello : DefaultTask() {
@get:Input
abstract val station: Property<String>
@TaskAction
fun greet() {
println("Hello, ${station.get()}")
}
}
tasks.register<Hello>("hello") {
station = "San Francisco"
}
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
abstract class Hello extends DefaultTask {
@Input
abstract Property<String> getStation()
@TaskAction
void greet() {
println "Hello, ${station.get()}"
}
}
tasks.register('hello', Hello) {
station = 'San Francisco'
}
$ ./gradlew hello --configuration-cache
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
Hello, San Francisco
BUILD SUCCESSFUL in 897ms
1 actionable task: 1 executed
Configuration cache entry stored.
Run with --configuration-cache on Gradle 9.8.1 (JDK 26); the example is compatible with the configuration cache, and the
Groovy DSL build prints the same lines. Source: Gradle User Manual: Task actions
Four things in these few lines carry most of the design:
-
The class is
abstract. Gradle generates the implementation of every abstract property getter (a "managed property"), creates theProperty<String>for you and gives the task a constructor it can call; you never writeObjectFactory.property(...)in a task. All the Gradle-provided property types work this way:Property<T>,ListProperty<T>,SetProperty<T>,MapProperty<K, V>,RegularFileProperty,DirectoryPropertyandConfigurableFileCollection. Since Gradle 9.0 every Gradle class with an@Injectgetter has that getter declared abstract, so any subclass of such a class has to be abstract too; see Injection getters are now abstract. -
Configuration code and execution code are separate. The properties and the block passed to
registerrun during the configuration phase, when Gradle builds the model; the@TaskActionmethod runs in the execution phase, and only if the task is in the task graph and is neither up to date nor restored from the build cache (see Gradle Build Lifecycle). -
In Kotlin the annotation goes on the getter.
@get:Inputputs@Inputon the generated getter, which is where Gradle looks. Withoutget:Kotlin applies the annotation to a different element (or rejects it), and Gradle never sees it. -
The assignment
station = "San Francisco"works in the Kotlin DSL since Gradle 8.2 because Gradle turns=intoset()forPropertytypes; before 8.2 (not run here) it wasstation.set("San Francisco"), which still works in both DSLs.
Task actions
A task action is code that runs when the task executes. A class declares its main action with a @TaskAction method (a method that takes an
InputChanges parameter makes the task incremental, which is covered on
Incremental Build). Any task, including a
built-in one, can add more actions from a build script with doFirst (runs before the existing actions) and doLast
(runs after them). A task has a list of actions, which is why the methods have those names, and actions.clear() empties
the list.
An ad hoc task is a DefaultTask (the type used when register gets no class) with doLast and its inputs and outputs
declared through the task’s inputs and outputs properties. The equivalent of a very small task class for counting the
stations in data/stations.csv:
tasks.register("stationCount") {
val csv = layout.projectDirectory.file("data/stations.csv")
val out = layout.buildDirectory.file("station-count.txt")
inputs.file(csv)
outputs.file(out)
doLast {
val count = csv.asFile.readLines().size - 1
out.get().asFile.writeText("$count\n")
println("$count stations")
}
}
tasks.register('stationCount') {
def csv = layout.projectDirectory.file('data/stations.csv')
def out = layout.buildDirectory.file('station-count.txt')
inputs.file(csv)
outputs.file(out)
doLast {
def count = csv.asFile.readLines().size() - 1
out.get().asFile.text = "$count\n"
println "$count stations"
}
}
$ ./gradlew stationCount --configuration-cache
Calculating task graph as no cached configuration is available for tasks: stationCount
(buildSrc tasks omitted)
> Task :stationCount
3 stations
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Configuration cache entry stored.
$ ./gradlew stationCount --configuration-cache
Reusing configuration cache.
> Task :stationCount UP-TO-DATE
BUILD SUCCESSFUL in 896ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.
Both runs used --configuration-cache. The example is compatible with it because the doLast block captures only the two
local values csv and out, not the project; it is not compatible if the block reads project or this of the build
script (see Classic mistakes). The up-to-date check works because the inputs and the output are declared; without the
outputs.file(out) line the task would be treated as always out of date.
Source: Gradle User Manual: Task actions
Older Gradle versions (and the Gradle 1.x book this section’s outline cites) defined such tasks with the left-shift operator,
task stationCount << { ... }. That syntax was removed in Gradle 5.0 (see
Upgrading from 4.x to 5.0); write doLast inside
tasks.register.
Declaring inputs and outputs with annotations
Gradle can only skip a task, cache it or order it against other tasks if it knows what the task reads and writes. A task
class declares that with annotations on the getters. Every property of a task class must carry exactly one of the
following annotations (or @Internal); Gradle fails the build when one is missing, as shown in Classic mistakes.
| Annotation | Meaning |
|---|---|
|
A value (string, number, boolean, enum, a serializable object, or the content of a |
|
One file; the content counts. Use it on a |
|
A set of files, typically a |
|
A directory and everything under it; use it on a |
|
Files that form a Java runtime classpath. The order counts; the path of a JAR is ignored, and so are the timestamps and the order of entries inside it. |
|
A Java compile classpath: only changes that affect the API of the classes count. Changes to resources, manifests, private members and method bodies are ignored. |
|
A bean (here, an interface of properties) whose own getters carry annotations. Gradle instantiates an abstract getter of a nested type for you; the implementation of the bean is tracked as an input too. |
|
A modifier: the input or output may have no value. Without it a property with no value is an error. |
|
One output file, or several in a file collection. |
|
One output directory, or several. |
|
A file or directory that belongs to another task and that this task destroys (as |
|
State kept between runs that is neither an input nor an output (for example a task’s own cache). It is removed when the outputs of a cached task are restored from the cache. |
|
Not an input or an output: changing it does not make the task out of date. It is inherently optional, and adding
|
|
A value that only affects what is printed; like |
|
How the path of an input file counts: |
|
On a file input: when it is empty the task is skipped and reported as |
|
Used with |
|
Used with |
|
On a file input of an incremental task: the changes to it can be queried through |
Source: Gradle User Manual: Annotating inputs and outputs and Incremental build: task input and output annotations
Two further annotations describe the task type as a whole rather than one property:
| Annotation | Meaning |
|---|---|
|
The task’s outputs may be stored in and restored from the build cache. Apply it only when the output is reproducible and relocatable. |
|
The task type is not cacheable, and the reason is documented in the source. Build logic can still override the decision for one task. |
|
Gradle does not capture the state of the task at all: it is always out of date and is never cached. Use it when the task reads or writes things Gradle cannot track, such as a remote database. |
Source: Gradle User Manual: Favor @CacheableTask and @DisableCachingByDefault and Build cache: marking tasks as non-cacheable by default
The how and why of fingerprints, normalisation and the stale-output rules are on Incremental Build; the cache itself is on a later page of this section (Build Cache).
The tidepool checkStations task
The tidepool engine reads data/stations.csv: one station per row, with id, name, latitude, longitude and a
semicolon-separated list of constituents. checkStations validates that file, like the Ant <checkstations> task
on the Ant page (the Gradle version adds an upper limit on constituents): five columns per row, latitude within [-90, 90],
longitude within [-180, 180], no duplicate ids, enough constituents, and every required id present. The contract is:
| Property | Meaning |
|---|---|
|
|
|
A |
|
|
|
|
|
|
|
|
|
|
The task class lives in a package of its own, io.tidepool.build. It is shown here as it sits in buildSrc; the next
sections explain each annotation and where else the class could live.
package io.tidepool.build
import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option
enum class ReportMode { SUMMARY, FULL }
interface Limits {
@get:Input
val minConstituents: Property<Int>
@get:Input
val maxConstituents: Property<Int>
}
@CacheableTask
abstract class CheckStations : DefaultTask() {
@get:InputFiles
@get:SkipWhenEmpty
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val stations: ConfigurableFileCollection
@get:Nested
abstract val limits: Limits
@get:Input
@get:Optional
@get:Option(option = "require", description = "A station id that must be present.")
abstract val required: SetProperty<String>
@get:Input
@get:Option(option = "fail-on-error", description = "Fails the build when a problem is found.")
abstract val failOnError: Property<Boolean>
@get:Input
@get:Option(option = "report", description = "How much the report file lists.")
abstract val report: Property<ReportMode>
@get:Internal
abstract val maxMessages: Property<Int>
@get:OutputFile
abstract val reportFile: RegularFileProperty
init {
failOnError.convention(true)
report.convention(ReportMode.SUMMARY)
maxMessages.convention(10)
limits.minConstituents.convention(1)
limits.maxConstituents.convention(8)
}
fun limits(action: Action<in Limits>) = action.execute(limits)
@TaskAction
fun check() {
val min = limits.minConstituents.get()
val max = limits.maxConstituents.get()
val seen = sortedSetOf<String>()
val messages = mutableListOf<String>()
for (file in stations.files.sortedBy { it.name }) {
file.readLines().drop(1).forEachIndexed { index, line ->
val where = "${file.name}:${index + 2}"
val columns = line.split(",")
if (columns.size != 5) {
messages += "$where expected 5 columns, found ${columns.size}"
return@forEachIndexed
}
if (!seen.add(columns[0])) messages += "$where duplicate station id ${columns[0]}"
range(where, "latitude", columns[2], -90.0, 90.0)?.let(messages::add)
range(where, "longitude", columns[3], -180.0, 180.0)?.let(messages::add)
val n = if (columns[4].isEmpty()) 0 else columns[4].split(";").size
if (n < min) messages += "$where has $n constituent(s), at least $min required"
if (n > max) messages += "$where has $n constituent(s), at most $max allowed"
}
}
required.get().filter { it !in seen }.forEach { messages += "required station $it is missing" }
messages.take(maxMessages.get()).forEach { logger.error(it) }
val out = reportFile.get().asFile
out.parentFile.mkdirs()
out.writeText(buildString {
appendLine("stations=${seen.size}")
appendLine("problems=${messages.size}")
if (report.get() == ReportMode.FULL) appendLine("ids=${seen.joinToString(",")}")
})
if (messages.isEmpty()) {
logger.lifecycle("${stations.files.size} file(s), ${seen.size} station(s): OK")
} else if (failOnError.get()) {
throw GradleException("${messages.size} problem(s) found in station data")
} else {
logger.warn("${messages.size} problem(s) found in station data")
}
}
private fun range(where: String, what: String, text: String, min: Double, max: Double): String? {
val value = text.toDoubleOrNull() ?: return "$where $what is not a number: '$text'"
return if (value < min || value > max) "$where $what $value is outside [$min, $max]" else null
}
}
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
For the Groovy DSL project the class is in three files under buildSrc/src/main/groovy/io/tidepool/build/, with this
buildSrc/build.gradle:
plugins {
id 'groovy'
}
package io.tidepool.build
enum ReportMode { SUMMARY, FULL }
package io.tidepool.build
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
interface Limits {
@Input
Property<Integer> getMinConstituents()
@Input
Property<Integer> getMaxConstituents()
}
package io.tidepool.build
import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option
@CacheableTask
abstract class CheckStations extends DefaultTask {
@InputFiles
@SkipWhenEmpty
@PathSensitive(PathSensitivity.RELATIVE)
abstract ConfigurableFileCollection getStations()
@Nested
abstract Limits getLimits()
@Input
@Optional
@Option(option = 'require', description = 'A station id that must be present.')
abstract SetProperty<String> getRequired()
@Input
@Option(option = 'fail-on-error', description = 'Fails the build when a problem is found.')
abstract Property<Boolean> getFailOnError()
@Input
@Option(option = 'report', description = 'How much the report file lists.')
abstract Property<ReportMode> getReport()
@Internal
abstract Property<Integer> getMaxMessages()
@OutputFile
abstract RegularFileProperty getReportFile()
CheckStations() {
failOnError.convention(true)
report.convention(ReportMode.SUMMARY)
maxMessages.convention(10)
limits.minConstituents.convention(1)
limits.maxConstituents.convention(8)
}
void limits(Action<? super Limits> action) {
action.execute(limits)
}
@TaskAction
void check() {
int min = limits.minConstituents.get()
int max = limits.maxConstituents.get()
def seen = new TreeSet<String>()
def messages = []
for (File file : stations.files.sort { it.name }) {
List<String> lines = file.readLines()
for (int i = 1; i < lines.size(); i++) {
def where = "${file.name}:${i + 1}"
def columns = lines[i].split(',', -1)
if (columns.length != 5) {
messages << "$where expected 5 columns, found ${columns.length}".toString()
continue
}
if (!seen.add(columns[0])) messages << "$where duplicate station id ${columns[0]}".toString()
[range(where, 'latitude', columns[2], -90d, 90d),
range(where, 'longitude', columns[3], -180d, 180d)].findAll { it }.each { messages << it }
int n = columns[4].isEmpty() ? 0 : columns[4].split(';').length
if (n < min) messages << "$where has $n constituent(s), at least $min required".toString()
if (n > max) messages << "$where has $n constituent(s), at most $max allowed".toString()
}
}
required.get().findAll { !(it in seen) }.each { messages << "required station $it is missing".toString() }
messages.take(maxMessages.get()).each { logger.error(it) }
def out = reportFile.get().asFile
out.parentFile.mkdirs()
def text = new StringBuilder()
text << "stations=${seen.size()}\n" << "problems=${messages.size()}\n"
if (report.get() == ReportMode.FULL) text << "ids=${seen.join(',')}\n"
out.text = text.toString()
if (messages.isEmpty()) {
logger.lifecycle("${stations.files.size()} file(s), ${seen.size()} station(s): OK")
} else if (failOnError.get()) {
throw new GradleException("${messages.size()} problem(s) found in station data")
} else {
logger.warn("${messages.size()} problem(s) found in station data")
}
}
private static String range(String where, String what, String text, double min, double max) {
Double value = text.isDouble() ? text.toDouble() : null
if (value == null) return "$where $what is not a number: '$text'".toString()
return (value < min || value > max) ? "$where $what $value is outside [$min, $max]".toString() : null
}
}
Points worth noting in the class:
-
stationscombines@InputFiles,@SkipWhenEmptyand@PathSensitive(PathSensitivity.RELATIVE): the files' content is an input, a build with no station files skips the task, and only the path relative to the build counts, so the result can be reused from another directory (see Up to date, NO-SOURCE and the build cache). -
limitsis declared@Nestedon an abstract getter of the interface typeLimits. Gradle creates the instance. The extra methodlimits(Action<in Limits>)is what lets a build script writelimits { minConstituents = 2 }. It is needed in both DSLs: with the method removed, the Kotlin DSL build fails to compile the script and the Groovy DSL build fails withNo signature of method: io.tidepool.build.CheckStations.limits() is applicable. -
requiredis@Optional: it has no convention, so it can be empty. -
Conventions (
failOnError.convention(true)and friends) are set in the constructor (initin Kotlin), so a task has usable defaults before the build script configures it, and the build script can still replace them.conventionis a default thatsetoverrides; it is explained on Lazy Configuration & Providers. -
The action calls
get()on every property. That is correct here: it runs in the execution phase, when all values are final. Callingget()in the configuration phase is the classic bug of the lazy API. -
The Groovy class avoids a closure around the private helper
range. A first version of the Groovy class called it from insideeach { }and failed withargument type mismatch(see Classic mistakes).
The build scripts register four instances of the one class: the real file with a required id and a stricter limit, a
deliberately broken file that fails the build, the same broken file with failOnError off, and an empty one. The broken
file is
id,name,latitude,longitude,constituents
SF,San Francisco,37.8063,-122.4659,M2:0.55:120;S2:0.13:150
SF,San Francisco again,37.8063,-122.4659,M2:0.55:120
XX,Nowhere,95.0,-122.4,M2:1.0:0
HNL,Honolulu,21.3067,not-a-number,
import io.tidepool.build.CheckStations
val checkStations = tasks.register<CheckStations>("checkStations") {
stations.from("data/stations.csv")
required.add("SF")
limits {
minConstituents = 2
}
reportFile = layout.buildDirectory.file("reports/stations.txt")
}
tasks.register<CheckStations>("checkBrokenStations") {
stations.from("data/stations-broken.csv")
reportFile = layout.buildDirectory.file("reports/stations-broken.txt")
}
tasks.register<CheckStations>("checkBrokenLenient") {
stations.from("data/stations-broken.csv")
failOnError = false
reportFile = layout.buildDirectory.file("reports/stations-lenient.txt")
}
tasks.register("printReport") {
val report = checkStations.flatMap { it.reportFile }
inputs.file(report)
doLast {
println(report.get().asFile.readText())
}
}
tasks.register<CheckStations>("checkNothing") {
reportFile = layout.buildDirectory.file("reports/nothing.txt")
}
import io.tidepool.build.CheckStations
def checkStations = tasks.register('checkStations', CheckStations) {
stations.from('data/stations.csv')
required.add('SF')
limits {
minConstituents = 2
}
reportFile = layout.buildDirectory.file('reports/stations.txt')
}
tasks.register('checkBrokenStations', CheckStations) {
stations.from('data/stations-broken.csv')
reportFile = layout.buildDirectory.file('reports/stations-broken.txt')
}
tasks.register('checkBrokenLenient', CheckStations) {
stations.from('data/stations-broken.csv')
failOnError = false
reportFile = layout.buildDirectory.file('reports/stations-lenient.txt')
}
tasks.register('printReport') {
def report = checkStations.flatMap { it.reportFile }
inputs.file(report)
doLast {
println(report.get().asFile.text)
}
}
tasks.register('checkNothing', CheckStations) {
reportFile = layout.buildDirectory.file('reports/nothing.txt')
}
Running the task
The first run executes the task, the second finds nothing changed. The buildSrc tasks that compile the class are the
first thing that runs after the class is edited; later runs find them up to date.
$ ./gradlew checkStations --build-cache --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkStations
(buildSrc tasks omitted)
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 883ms
4 actionable tasks: 1 executed, 3 up-to-date
Configuration cache entry stored.
$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
BUILD SUCCESSFUL in 684ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.
Both runs used --build-cache --configuration-cache. The class and the builds are compatible with the configuration
cache: the task action reads only the task’s own properties and the logger.
A failing check names the file and the row, then fails the task with one summary line; with failOnError off the same
problems are logged at the warn level and the build succeeds:
$ ./gradlew checkBrokenStations
(buildSrc tasks omitted)
> Task :checkBrokenStations FAILED
stations-broken.csv:3 duplicate station id SF
stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
stations-broken.csv:5 longitude is not a number: 'not-a-number'
stations-broken.csv:5 has 0 constituent(s), at least 1 required
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkBrokenStations' (registered in build file 'build.gradle.kts').
> 4 problem(s) found in station data
BUILD FAILED in 918ms
4 actionable tasks: 1 executed, 3 up-to-date
$ ./gradlew checkBrokenLenient
(buildSrc tasks omitted)
> Task :checkBrokenLenient
stations-broken.csv:3 duplicate station id SF
stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
stations-broken.csv:5 longitude is not a number: 'not-a-number'
stations-broken.csv:5 has 0 constituent(s), at least 1 required
4 problem(s) found in station data
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
The Groovy DSL project prints the same lines (the text registered in build file 'build.gradle' replaces
'build.gradle.kts').
Wiring a task’s output into another task
When one task’s output property is used as another task’s input, Gradle infers the dependency and runs them in the right
order, with no dependsOn. The printReport task of the example takes checkStations.flatMap { it.reportFile } as its
input file, so running it runs checkStations first when the report is stale or missing:
$ ./gradlew printReport
(buildSrc tasks omitted)
> Task :checkStations UP-TO-DATE
> Task :printReport
stations=3
problems=0
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date
$ ./gradlew printReport
(buildSrc tasks omitted)
> Task :checkStations
1 file(s), 3 station(s): OK
> Task :printReport
stations=3
problems=0
BUILD SUCCESSFUL in 1s
5 actionable tasks: 2 executed, 3 up-to-date
The first run found checkStations up to date; the second ran after rm -rf build, which removed the report, so
checkStations executed. flatMap (rather than map) is used because reportFile is itself a property: the result is a
provider that carries the producing task along. Passing reportFile.get() instead would lose the link and break the
ordering. The same rule explains why inputs.file(report) is declared on a task with no class: it declares the input.
The page on tasks explains why wired inputs are preferred over dependsOn
(see Gradle Tasks).
Command-line options
A property of a task class can be set from the command line when it is annotated with @Option. The annotation goes on
the getter of a Property (@get:Option in Kotlin) or on the setter of a plain property, takes an option name without
the dashes and a description, and the option is then valid after the task name. The supported types are:
| Property type | Command-line form |
|---|---|
|
|
|
|
|
|
|
|
|
The option repeated: |
|
A path; a relative path is resolved against the project directory. |
@OptionValues("name") on a method that returns a List documents the accepted values of a String option; Gradle does
not enforce them, the task action must. Options are self-documenting through help --task:
$ ./gradlew -q help --task checkStations
Detailed task information for checkStations
Path
:checkStations (registered in build file 'build.gradle.kts')
Type
CheckStations (io.tidepool.build.CheckStations)
Options
--fail-on-error Fails the build when a problem is found.
--no-fail-on-error Disables option --fail-on-error.
--report How much the report file lists.
Available values are:
FULL
SUMMARY
--require A station id that must be present.
--rerun Causes the task to be re-run even if up-to-date.
Description
-
Group
-
The output also shows the built-in --rerun option that every task has. Using the repeated option and the negated boolean
option on the real data:
$ ./gradlew checkStations --require=SF --require=XX --report=full --no-fail-on-error
(buildSrc tasks omitted)
> Task :checkStations
required station XX is missing
1 problem(s) found in station data
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
$ cat build/reports/stations.txt
stations=3
problems=1
ids=BOS,HNL,SF
--require=SF --require=XX asks for two ids and XX is not in the file; --no-fail-on-error kept the build green, and
--report=full made the report list the ids. An invalid enum value is rejected before the task runs:
$ ./gradlew checkStations --report=nope
(buildSrc tasks omitted)
FAILURE: Build failed with an exception.
* What went wrong:
Problem configuring option 'report' on task ':checkStations' from command line.
> Cannot convert string value 'nope' to an enum value of type 'io.tidepool.build.ReportMode' (valid case insensitive values: SUMMARY, FULL)
BUILD FAILED in 973ms
3 actionable tasks: 3 up-to-date
Options can only be declared with the annotation: there is no programmatic equivalent, and they cannot be declared on a
project or in a plugin. The task that exposes the option must be named explicitly (gradle check --tests abc does not
reach the test task that check depends on).
Up to date, NO-SOURCE and the build cache
The same annotations drive the outcome that Gradle prints next to the task name:
-
UP-TO-DATE: inputs and outputs are the same as in the last execution of this task in this directory.
-
FROM-CACHE: the build cache (
--build-cache, ororg.gradle.caching=true) holds an entry for these inputs, and the outputs were restored from it. It requires@CacheableTask. -
NO-SOURCE: an
@SkipWhenEmptyinput is empty (the taskcheckNothinghas no station files). -
EXECUTED (no suffix): the action ran.
$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 692ms
1 actionable task: 1 from cache
Configuration cache entry reused.
$ ./gradlew checkNothing
(buildSrc tasks omitted)
> Task :checkNothing NO-SOURCE
BUILD SUCCESSFUL in 1s
3 actionable tasks: 3 up-to-date
The first listing is a run after rm -r build/reports: the report was gone, but the build cache had it from the first run,
so Gradle restored it. The task needs --build-cache for that (the build also sets the local cache directory to
build-cache in settings.gradle.kts, to keep the example self-contained: buildCache { local { directory = file("build-cache") } };
the Groovy DSL project uses file('build-cache')). To see why a task is out of date, run with --info; this excerpt is
the run after the first station’s latitude was edited (37.8063 to 37.8064), after which the file was restored and the
next run was FROM-CACHE again:
$ ./gradlew checkStations --build-cache --configuration-cache --info (excerpt)
Task ':checkStations' is not up-to-date because:
Input property 'stations' file /work/tidepool/data/stations.csv has changed.
$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 764ms
1 actionable task: 1 from cache
Configuration cache entry reused.
Relocatability. An input file is identified by its absolute path unless @PathSensitive says otherwise, so a cacheable
task whose inputs use the default would never hit the cache from another directory or another machine. checkStations
uses PathSensitivity.RELATIVE. To check that, the project (including its build-cache directory but not build) was
copied to another directory and run there:
$ ./gradlew checkStations --build-cache
(buildSrc tasks omitted)
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 from cache, 3 up-to-date
The result was restored from the cache although the project had a different location; the buildSrc tasks ran because that
copy had never built them. A task is only worth marking @CacheableTask when its outputs are reproducible and relocatable,
and when the work is more than moving files (which is why Copy and Jar are not cacheable); the page on the build cache
(later in this section) covers cache keys and remote caches.
Where to put task classes
| Location | Visible to | Notes |
|---|---|---|
The build script itself |
Only that script. |
Fine for a prototype, as in the first example. A class declared in a build script is compiled with it and cannot be reused by another project or unit-tested. |
|
The build scripts of every project in the build. |
Built automatically before the rest of the build, added to the build script classpath, and no |
An included build, conventionally |
The projects that apply a plugin from it. |
Declared with |
A binary plugin published or reused across builds |
Any build that applies the plugin. |
The task class is part of the plugin JAR; see Binary Plugins. |
The buildSrc version was shown above; its build scripts import the class (import io.tidepool.build.CheckStations) and
use it directly. In an included build-logic build the class is not on the build script classpath by itself; the usual
pattern is a precompiled script plugin that registers the task, applied by id:
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "tidepool"
rootProject.name = "build-logic"
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
import io.tidepool.build.CheckStations
tasks.register<CheckStations>("checkStations") {
stations.from("data/stations.csv")
reportFile = layout.buildDirectory.file("reports/stations.txt")
}
plugins {
id("tidepool.stations")
}
The task class CheckStations.kt is the same file as above, under build-logic/src/main/kotlin/io/tidepool/build/.
pluginManagement {
includeBuild 'build-logic'
}
rootProject.name = 'tidepool'
rootProject.name = 'build-logic'
plugins {
id 'groovy-gradle-plugin'
}
import io.tidepool.build.CheckStations
tasks.register('checkStations', CheckStations) {
stations.from('data/stations.csv')
reportFile = layout.buildDirectory.file('reports/stations.txt')
}
plugins {
id 'tidepool.stations'
}
The three Groovy class files are the ones shown above, under build-logic/src/main/groovy/io/tidepool/build/. The file name
tidepool.stations.gradle(.kts) becomes the plugin id tidepool.stations. Running it for the first time compiles
build-logic; the second run is up to date:
$ ./gradlew checkStations --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkStations
> Task :build-logic:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :build-logic:generateExternalPluginSpecBuilders
> Task :build-logic:extractPrecompiledScriptPluginPlugins
> Task :build-logic:generateScriptPluginAdapters
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:compilePluginsBlocks
> Task :build-logic:generatePrecompiledScriptPluginAccessors
> Task :build-logic:compileKotlin
> Task :build-logic:compileJava NO-SOURCE
> Task :build-logic:classes
> Task :build-logic:jar
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 5s
10 actionable tasks: 10 executed
Configuration cache entry stored.
$ ./gradlew checkStations --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
BUILD SUCCESSFUL in 729ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.
(Kotlin DSL; run with --configuration-cache, and compatible with it.) The Groovy DSL project ran the same two builds with
the tasks of the groovy-gradle-plugin (:build-logic:compileGroovyPlugins, :build-logic:extractPluginRequests and
so on) and the same checkStations lines. Convention plugins, precompiled script plugins and how they replace
allprojects/subprojects blocks are the subject of
Convention Plugins & Sharing Build Logic.
Custom task classes are also what binary plugins register for their users; the plugin side (extensions, plugin ids, the
gradlePlugin {} block, validatePlugins) is on Binary Plugins
and its test side on Testing & Publishing Plugins.
Services and work that should not block the task
A task needing a Gradle service (file operations, archive operations, the worker executor, ExecOperations) declares an
abstract getter annotated with @Inject; Gradle supplies the implementation, which is the supported replacement for
the project.copy, project.exec and similar methods at execution time (not run here; the mechanism is described in
Lazy Configuration & Providers). A task
that splits its work into units that can run in parallel, or in an isolated classloader or process, hands them to the
Worker API; that and shared build services are on
Worker API & Build Services.
Classic mistakes
All of the following were run; the Kotlin DSL listings are shown (the Groovy DSL builds fail the same way, with the type
shown as Unannotated instead of Build_gradle.Unannotated).
A property with no annotation. The failure comes before any task runs:
abstract class Unannotated : DefaultTask() {
abstract val label: Property<String>
@TaskAction
fun run() {
println("label = ${label.get()}")
}
}
$ ./gradlew unannotated
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':unannotated' (type 'Build_gradle.Unannotated').
Missing annotation
Type 'Build_gradle.Unannotated' property 'label' is missing an input or output annotation
Properties must be annotated so that Gradle knows how to handle them during up-to-date checking
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#missing_annotation in the Gradle documentation.
Possible solutions:
1. Add an input or output annotation.
2. Mark it as @Internal.
BUILD FAILED in 746ms
An input that nobody sets. An @Input without @Optional and without a value fails when the task is about to run:
$ ./gradlew unset
> Task :unset FAILED
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':unset' (type 'Build_gradle.Unset').
Value not set
Type 'Build_gradle.Unset' property 'label' doesn't have a configured value
This property isn't marked as optional and no value has been configured
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#value_not_set in the Gradle documentation.
Possible solutions:
1. Assign a value to 'label'.
2. Mark property 'label' as optional.
BUILD FAILED in 788ms
1 actionable task: 1 executed
Using project in the task action. It works with the cache off, with a deprecation warning that Gradle 10 turns into an
error, and it fails with the configuration cache on, because the cache stores tasks and cannot store the Project:
abstract class UsesProject : DefaultTask() {
@TaskAction
fun run() {
println("project = ${project.name}")
}
}
$ ./gradlew usesProject --warning-mode all
> Task :usesProject
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. [...]
at Build_gradle$UsesProject.run(build.gradle.kts:31)
(Run with --stacktrace to get the full stack trace of this deprecation warning.)
project = bugs
BUILD SUCCESSFUL in 792ms
1 actionable task: 1 executed
$ ./gradlew usesProject --configuration-cache
Calculating task graph as no cached configuration is available for tasks: usesProject
> Task :usesProject FAILED
1 problem was found storing the configuration cache.
- Task `:usesProject` of type `Build_gradle$UsesProject`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':usesProject' (registered in build file 'build.gradle.kts').
> Invocation of 'Task.project' by task ':usesProject' at execution time is unsupported with the configuration cache.
BUILD FAILED in 842ms
1 actionable task: 1 executed
Configuration cache entry discarded with 1 problem.
(The long deprecation line is cut at [...].) The fix is to copy what the action needs into a property during configuration
(@get:Input abstract val projectName: Property<String>, set with projectName = project.name in the register block) or to
inject a service. The deprecation was introduced in Gradle 7.4 behind a feature flag; since 8.12 the flag no longer controls it,
and it is issued whenever the configuration cache is off. With the cache on, the same call is reported as a configuration cache
problem instead, as the second run above shows (see
Upgrading within Gradle 8.x: Calling Task.getProject() from a task action).
| Symptom | Cause | Fix |
|---|---|---|
|
A getter with no input, output or |
Annotate it, or mark it |
|
An |
Set it in the build script, give it a |
A Kotlin annotation that has no effect |
The annotation was written without |
|
|
A |
Add |
The task always runs |
No outputs declared, or an |
Declare an |
Cache hit on one machine only |
File inputs with the default absolute path sensitivity. |
|
|
A closure inside the task action called a |
Call the helper from a plain loop, or make it |
The consuming task runs before the producing one |
The producer’s output was passed to the consumer as a plain value ( |
Wire the |
The Groovy argument type mismatch row is an observation from a first version of the Groovy class (a closure inside
check() calling the private range method); the cause given is inferred from the stack trace, where the call goes
through Gradle’s call-intercepting meta class on the decorated subclass, and is not taken from the manual.
What changed from Gradle 8.14 and from the Gradle 1.x book
Nothing on this page was run on Gradle 8.14.6; the notes below come from the official upgrade guides and are labelled "not run here".
| Item | Status |
|---|---|
|
Deprecated since 7.4 behind a flag, issued without the flag since 8.12 when the configuration cache is off, an error in Gradle 10; with the cache on, a configuration cache problem instead. Not run here on 8.14. |
Subclasses of Gradle classes with |
Must be abstract since 9.0. Not run here on 8.14. |
|
Since 8.2. On earlier versions use |
Configuration cache |
Stable since 8.1, the preferred mode in 9.0, announced as the default for Gradle 10. Every example above whose command shows |
|
Removed in 5.0; use |
|
Eager creation still works but is discouraged; |
Task classes with |
Still work, but without the lazy |
The same idea in the other two tools
| Concept | Gradle | Maven | Ant |
|---|---|---|---|
Unit of custom work |
A task class ( |
A Mojo ( |
A Java class that extends |
Inputs |
Annotated properties, tracked for up-to-date checks and the cache. |
|
Attribute setters and nested elements; no tracking. |
Skip when unchanged |
Built in ( |
Each plugin decides; Maven does not track task inputs itself. |
Each task decides; the |
Where it lives |
Build script, |
A plugin, always. |
A JAR registered with |
See Writing Maven Plugins, Writing Custom Ant Tasks and the cross-tool view in Comparing Ant, Maven & Gradle.