Configuration Cache Requirements for your Build Logic
- Certain Types must not be Referenced by Tasks
- Custom Collection and Map Types
- Using the
ProjectObject at Execution Time - Reducing Captured References in Ad-Hoc Tasks
- Accessing a Task Instance from Another Instance
- Sharing Mutable Objects
- Accessing Task Extensions or Conventions
- Using Build Listeners
- Using Build Services
- Using External Information Sources
- Running External Processes
- Reading System Properties and Environment Variables
- Undeclared Reading of Files
- Bytecode Modifications and Java Agent
- Handling of Credentials and Secrets
- Third-party Java Agents with the Configuration Cache
To capture and reload the task graph state using the Configuration Cache, Gradle enforces specific requirements on tasks and build logic.
Many violations of these requirements are detected and reported as Configuration Cache "problems," which cause the build to fail. However, not all violations can be detected automatically. Some (such as starting an external process at configuration time through a code path that bypasses Gradle’s bytecode instrumentation) are not reported, but will produce incorrect results on a cache hit because the value is not tracked as a configuration input.
In most cases, these requirements expose undeclared inputs, making builds more strict, correct, and reliable. Using the Configuration Cache is effectively an opt-in to these improvements.
The following sections describe each requirement and provide guidance on resolving issues in your build.
Certain Types must not be Referenced by Tasks
Some types must not be referenced by task fields or in task actions (methods annotated @TaskAction, or the doFirst {} and doLast {} DSL blocks).
These types fall into the following categories:
-
Live JVM state types
-
Gradle model types
-
Dependency management types
These restrictions exist because these types cannot easily be stored or reconstructed by the Configuration Cache.
Live JVM State Types
Live JVM state types (e.g., ClassLoader, Thread, OutputStream, Socket) are disallowed, as they do not represent task inputs or outputs.
The only exceptions are standard streams (System.in, System.out, System.err), which can be used, for example, as parameters for Exec and JavaExec tasks.
Concurrency and Synchronization Primitives
Common synchronization primitives from the JDK must not be referenced by task fields or captured in task actions. These types represent live state that cannot be safely serialized.
Prohibited types include interfaces (and all their implementations, e.g. ReentrantLock) and classes from the java.util.concurrent and java.util.concurrent.locks packages, such as:
-
Lock,ReadWriteLock -
CountDownLatch,CyclicBarrier,Phaser,Semaphore -
Exchanger,SynchronousQueue
Using these for cross-task synchronization will not work because the configuration cache isolates tasks: each task instance receives its own independent object, and no global synchronization occurs.
The isolation also applies to objects used in synchronized blocks, so they cannot be used for cross-task synchronization either. Unlike the primitives above, Gradle cannot detect this case automatically.
To share or coordinate state between tasks, use shared build services.
Classes from User-defined Modules
Types loaded into a user-defined ModuleLayer (for example, one created at runtime via ModuleLayer.defineModulesWith*Loader(…)) must not be referenced by task state.
The Configuration Cache ignores module information when saving and cannot restore such types properly, so they’re rejected at store time.
This restriction only applies to build logic (including plugins and dependencies of plugins).
Code built with Gradle isn’t affected.
Standard JDK types are unaffected: every module shipped with the JVM lives in the boot ModuleLayer and is fully supported, including types from non-java.base modules such as java.desktop (java.awt.Color, javax.swing.*) or java.xml.
When the Gradle Java agent is enabled (the default), Gradle automatically opens the JDK packages it needs during serialization, so user code does not need to add --add-opens for them.
Gradle Model Types
Gradle model types (e.g., Gradle, Settings, Project, SourceSet, Configuration) are often used to pass task inputs that should instead be explicitly declared.
For example, instead of referencing a Project to retrieve project.version at execution time, declare the project version as a Property<String> input.
Similarly, instead of referencing a SourceSet for source files or classpath resolution, declare these as a FileCollection input.
A common pattern reaches into project.version (or another model value) from within a task action:
tasks.register("writeVersionStamp") {
val output = layout.buildDirectory.file("version.txt")
outputs.file(output)
doLast {
// BAD: project.version is the live Project model, accessed at execution time
output.get().asFile.writeText(project.version.toString())
}
}
tasks.register('writeVersionStamp') {
def output = layout.buildDirectory.file('version.txt')
outputs.file output
doLast {
// BAD: project.version is the live Project model, accessed at execution time
output.get().asFile.text = project.version.toString()
}
}
The simplest Configuration Cache-safe rewrite keeps the ad-hoc tasks.register { doLast } shape.
Capture the value from project into a local at configuration time, and reference the local (never project) from inside doLast:
tasks.register("writeVersionStamp") {
val versionProvider = providers.provider { project.version.toString() } (1)
val output = layout.buildDirectory.file("version.txt")
outputs.file(output)
doLast {
output.get().asFile.writeText(versionProvider.get()) (2)
}
}
tasks.register('writeVersionStamp') {
def versionProvider = providers.provider { project.version.toString() } (1)
def output = layout.buildDirectory.file('version.txt')
outputs.file output
doLast {
output.get().asFile.text = versionProvider.get() (2)
}
}
| 1 | providers.provider { … } (from ProviderFactory) captures project.version lazily at the configuration-to-execution boundary. The local versionProvider holds a Provider<String>, not a reference to Project. |
| 2 | The doLast closure captures only the two locals (versionProvider and output). No project.* access happens at execution time, so CC can serialize the task cleanly. |
If the task is invoked from more than one place or you want proper Gradle typed inputs / up-to-date checking, promote it to a typed task class instead:
abstract class WriteVersionStampTask : DefaultTask() {
@get:Input
abstract val version: Property<String> (1)
@get:OutputFile
abstract val output: RegularFileProperty (2)
@TaskAction
fun run() {
output.get().asFile.writeText(version.get())
}
}
tasks.register<WriteVersionStampTask>("writeVersionStamp") {
version = providers.provider { project.version.toString() } (3)
output = layout.buildDirectory.file("version.txt")
}
abstract class WriteVersionStampTask extends DefaultTask {
@Input
abstract Property<String> getVersion() (1)
@OutputFile
abstract RegularFileProperty getOutput() (2)
@TaskAction
void run() {
output.get().asFile.text = version.get()
}
}
tasks.register('writeVersionStamp', WriteVersionStampTask) {
version = providers.provider { project.version.toString() } (3)
output = layout.buildDirectory.file('version.txt')
}
| 1 | project.version is captured into a typed Property<String> at configuration time. The task body no longer touches project. |
| 2 | output is declared as an @OutputFile, which lets Gradle correctly track the output and gives the task automatic up-to-date checking. |
| 3 | The value is wired with providers.provider { … } so it is computed lazily at the configuration-to-execution boundary, not eagerly. |
The same pattern applies to other Gradle model types: capture the value you actually need (project.name, project.rootDir, a source set’s files, a configuration’s resolved classpath) into a typed task property at configuration time.
Dependency Management Types
The same requirement applies to dependency management types with some nuances.
Some dependency management types, such as Configuration and SourceDirectorySet, should not be used as task inputs because they contain unnecessary state and are not precise.
Use a less specific type that gives necessary features instead:
-
If referencing a
Configurationto get resolved files, declare aFileCollectioninput. -
If referencing a
SourceDirectorySet, declare aFileTreeinput.
Additionally, referencing resolved dependency results directly is disallowed (e.g., ArtifactResolutionQuery, ResolvedArtifact, ArtifactResult).
Instead, use lazy providers that defer resolution:
-
Use a
Provider<ResolvedComponentResult>fromResolutionResult.getRootComponent()for dependency graph metadata. -
Alternatively, reference the
ResolutionResultdirectly. It is Configuration Cache-compatible, so it can be wired into a task input property (for example, aProperty<ResolutionResult>) and accessed via its full API at execution time. -
Use
ArtifactCollection.getResolvedArtifacts(), which returns aProvider<Set<ResolvedArtifactResult>>, for artifact metadata and files.
The Provider returned by getResolvedArtifacts() is Configuration Cache-compatible, and the ResolvedArtifactResult type is itself serializable, so you can wire the provider directly into a SetProperty<ResolvedArtifactResult> task property. If you prefer, you can still use Provider.map() to extract only the data you need (artifact identifiers, variant information, files) into your own serializable types and wire those into your task’s input properties. See Resolving artifacts for a complete example of this pattern.
|
Some types, such as Publication or Dependency, are not serializable but could be made so in the future.
Gradle may allow them as task inputs if necessary.
The following task references a SourceSet, which is not allowed:
abstract class SomeTask : DefaultTask() {
@get:Input lateinit var sourceSet: SourceSet (1)
@TaskAction
fun action() {
val classpathFiles = sourceSet.compileClasspath.files
// ...
}
}
abstract class SomeTask extends DefaultTask {
@Input SourceSet sourceSet (1)
@TaskAction
void action() {
def classpathFiles = sourceSet.compileClasspath.files
// ...
}
}
| 1 | This will be reported as a problem because referencing SourceSet is not allowed |
The following is the fixed version:
abstract class SomeTask : DefaultTask() {
@get:InputFiles @get:Classpath
abstract val classpath: ConfigurableFileCollection (1)
@TaskAction
fun action() {
val classpathFiles = classpath.files
// ...
}
}
abstract class SomeTask extends DefaultTask {
@InputFiles @Classpath
abstract ConfigurableFileCollection getClasspath() (1)
@TaskAction
void action() {
def classpathFiles = classpath.files
// ...
}
}
| 1 | No more problems reported, the task now uses the supported type ConfigurableFileCollection |
If an ad-hoc task in a script captures a disallowed reference in a doLast {} closure:
tasks.register("someTask") {
doLast {
val classpathFiles = sourceSets.main.get().compileClasspath.files (1)
}
}
tasks.register('someTask') {
doLast {
def classpathFiles = sourceSets.main.compileClasspath.files (1)
}
}
| 1 | This will be reported as a problem because the doLast {} closure is capturing a reference to the SourceSet |
You still need to fulfill the same requirement, that is, do not reference the disallowed type during task execution.
This is how the task declaration above can be fixed:
tasks.register("someTask") {
val classpath = sourceSets.main.get().compileClasspath (1)
doLast {
val classpathFiles = classpath.files
}
}
tasks.register('someTask') {
def classpath = sourceSets.main.compileClasspath (1)
doLast {
def classpathFiles = classpath.files
}
}
| 1 | No more problems reported, the doLast {} closure now only captures classpath which is of the supported FileCollection type |
Sometimes, a disallowed type is indirectly referenced through another type. For example, a task may reference an allowed type that, in turn, references a disallowed type. The hierarchical view in the HTML problem report can help you trace such issues and identify the offending reference.
Custom Collection and Map Types
The Configuration Cache serializes a fixed set of standard collection, set, map, and queue implementations directly:
-
List:ArrayList,LinkedList,CopyOnWriteArrayList -
Set:HashSet,LinkedHashSet,TreeSet,CopyOnWriteArraySet -
Map:HashMap,LinkedHashMap,TreeMap,ConcurrentHashMap,Properties,Hashtable -
Queue:ArrayDeque
These types, and Guava’s immutable collections (ImmutableList, ImmutableSet, ImmutableMap), are stored and restored faithfully.
A custom subtype of one of the standard types above (for example, class MyList extends ArrayList<String> {}) cannot be restored as its own type: it is restored as the nearest standard type instead.
Any state or behavior added by the custom type — extra fields, overridden methods — is lost.
Referencing a custom collection or map subtype from task fields or task actions is deprecated and will become an error in Gradle 10.
Use a standard collection type (for example, a plain ArrayList or LinkedHashMap) and hold any extra data in separate, explicitly serialized fields.
Using the Project Object at Execution Time
Tasks must not use any Project objects during execution.
This includes calling Task.getProject() while a task is running.
Some cases can be resolved similarly to those described in disallowed types.
Often, equivalent functionality is available on both Project and Task.
For example:
-
If you need a
Logger, useTask.loggerinstead ofProject.logger. -
For file operations, use injected services rather than
Projectmethods.
The following task incorrectly references the Project object at execution time:
abstract class SomeTask : DefaultTask() {
@TaskAction
fun action() {
project.copy { (1)
from("source")
into("destination")
}
}
}
abstract class SomeTask extends DefaultTask {
@TaskAction
void action() {
project.copy { (1)
from 'source'
into 'destination'
}
}
}
| 1 | This will be reported as a problem because the task action is using the Project object at execution time |
Fixed version:
abstract class SomeTask : DefaultTask() {
@get:Inject abstract val fs: FileSystemOperations (1)
@TaskAction
fun action() {
fs.copy {
from("source")
into("destination")
}
}
}
abstract class SomeTask extends DefaultTask {
@Inject abstract FileSystemOperations getFs() (1)
@TaskAction
void action() {
fs.copy {
from 'source'
into 'destination'
}
}
}
| 1 | No more problem reported, the injected FileSystemOperations service is supported as a replacement for project.copy {} |
If the same problem occurs in an ad-hoc task in a script:
tasks.register("someTask") {
doLast {
project.copy { (1)
from("source")
into("destination")
}
}
}
tasks.register('someTask') {
doLast {
project.copy { (1)
from 'source'
into 'destination'
}
}
}
| 1 | This will be reported as a problem because the task action is using the Project object at execution time |
Fixed version:
interface Injected {
@get:Inject val fs: FileSystemOperations (1)
}
tasks.register("someTask") {
val injected = project.objects.newInstance<Injected>() (2)
doLast {
injected.fs.copy { (3)
from("source")
into("destination")
}
}
}
interface Injected {
@Inject FileSystemOperations getFs() (1)
}
tasks.register('someTask') {
def injected = project.objects.newInstance(Injected) (2)
doLast {
injected.fs.copy { (3)
from 'source'
into 'destination'
}
}
}
| 1 | Services can’t be injected directly in scripts, we need an extra type to convey the injection point |
| 2 | Create an instance of the extra type using project.objects outside the task action |
| 3 | No more problem reported, the task action references injected that provides the FileSystemOperations service, supported as a replacement for project.copy {} |
Fixing ad-hoc tasks in scripts requires additional effort, making it a good opportunity to refactor them into proper task classes.
Ad-hoc tasks and scripts can also obtain these services directly with the service(…) lookup, avoiding the extra injected type shown above.
|
The table below lists recommended replacements for commonly used Project methods:
| Instead of: | Use: |
|---|---|
|
A task input or output property or a script variable to capture the result of using |
|
A task input or output property or a script variable to capture the result of using |
|
|
|
A task input or output property or a script variable to capture the result of using |
|
A task input or output property or a script variable to capture the result of using |
|
A task input or output property or a script variable to capture the result of using |
|
A task input or output property or a script variable to capture the result of using |
|
|
|
|
|
|
|
A task input or output property or a script variable to capture the result of using |
|
A task input or output property or a script variable to capture the result of using |
|
|
|
|
|
|
|
|
|
|
|
A task input or output property or a script variable to capture the result of using |
|
|
|
|
|
|
|
|
|
The Kotlin, Groovy or Java API available to your build logic. |
|
|
|
|
|
|
|
Reducing Captured References in Ad-Hoc Tasks
When using ad-hoc tasks in build scripts, task actions (such as doLast {}) can inadvertently capture references to the enclosing build script scope.
The Configuration Cache must serialize these captured references, and project-level objects are not serializable.
Implicit project property capture
Referencing a project property like version inside a task action causes the action to capture the project object at execution time.
This applies to both Groovy and Kotlin DSL scripts.
To fix this, assign the project property to a local variable inside the task configuration block:
tasks.register("checkVersion") {
doLast {
println(version) (1)
}
}
| 1 | References version inside doLast, causing the action to capture the project scope. |
tasks.register("printVersion") {
val projectVersion = version (1)
doLast {
println(projectVersion) (2)
}
}
| 1 | Assigns the project property to a local variable at configuration time. |
| 2 | The action now captures only the local variable, not the project. |
Script-level variable capture (Kotlin DSL)
In Kotlin DSL scripts, top-level val and var declarations are compiled as properties of the build script class.
Referencing them inside a doLast {} lambda causes the lambda to capture the build script object, which is not serializable:
val outputFile = layout.buildDirectory.file("output.txt")
tasks.register("produce") {
outputs.file(outputFile)
doLast {
outputFile.get().asFile.writeText("Hello") (1)
}
}
| 1 | References the script-level outputFile, causing the action to capture the build script object. |
To fix this, shadow the script-level property with a local variable inside the task configuration block:
val reportFile = layout.buildDirectory.file("report.txt")
tasks.register("report") {
val reportFile = reportFile (1)
outputs.file(reportFile)
doLast {
reportFile.get().asFile.writeText("Hello") (2)
}
}
| 1 | Shadows the script-level property with a local variable (val reportFile = reportFile). |
| 2 | The action now captures only the local variable, not the build script object. |
Type widening (Kotlin DSL)
Even after extracting a value into a local variable, Kotlin’s type inference may preserve a type that carries a project reference.
For example, configurations.runtimeClasspath returns a NamedDomainObjectProvider<Configuration>, which holds a reference to the project’s configuration container.
Declaring an explicit, wider type drops this reference:
tasks.register("resolveClasspath") {
val runtimeClasspath = configurations.runtimeClasspath (1)
doLast {
println(runtimeClasspath.get().files)
}
}
| 1 | The inferred type is NamedDomainObjectProvider<Configuration>, which carries a project reference. |
tasks.register("resolveClasspathSafe") {
val runtimeClasspath: Provider<out FileCollection> = configurations.runtimeClasspath (1)
doLast {
println(runtimeClasspath.get().files)
}
}
| 1 | The explicit Provider type, dropping both the container reference and the unsupported Configuration type, allows the Configuration Cache to simplify the internal representation and ignore the unsupported state. |
|
These techniques apply whenever an ad-hoc task action references a value defined at the script level.
For task classes (extending |
Accessing a Task Instance from Another Instance
Tasks must not directly access the state of another task instance. Instead, they should be connected using input and output relationships.
This requirement ensures that tasks remain isolated and correctly cacheable. As a result, it is unsupported to write tasks that configure other tasks at execution time.
The pre-Configuration Cache pattern reaches directly into another task’s properties from within a task action. Here processData reads the output of generateData by holding a reference to the task and dereferencing it at execution time:
val generateData = tasks.register<GenerateDataTask>("generateData") {
outputFile = layout.buildDirectory.file("data.json")
}
tasks.register("processData") {
dependsOn(generateData)
doLast {
// BAD: reading another task's property at execution time
val source = generateData.get().outputFile.get().asFile
println("Processing ${source.readText()}")
}
}
def generateData = tasks.register('generateData', GenerateDataTask) {
outputFile = layout.buildDirectory.file('data.json')
}
tasks.register('processData') {
dependsOn generateData
doLast {
// BAD: reading another task's property at execution time
def source = generateData.get().outputFile.get().asFile
println "Processing ${source.text}"
}
}
Under the Configuration Cache, this fails serialization: a task cannot hold a TaskProvider referring to another task, whether directly as a field or indirectly through a closure capture in a doLast. To consume another task’s output, the reference must be either wrapped in a ConfigurableFileCollection (so it flows through as file inputs), or transformed with .map { … } / .flatMap { … } into a serializable value (typically a Provider of the file itself).
The Configuration Cache-compatible version declares the source file as an @InputFile on the consuming task and wires the two tasks together with flatMap. Gradle infers the task dependency automatically from the property wiring:
abstract class ProcessDataTask : DefaultTask() {
@get:InputFile
abstract val source: RegularFileProperty (1)
@TaskAction
fun run() {
println("Processing ${source.get().asFile.readText()}")
}
}
val generateData = tasks.register<GenerateDataTask>("generateData") {
outputFile = layout.buildDirectory.file("data.json")
}
tasks.register<ProcessDataTask>("processData") {
source = generateData.flatMap { it.outputFile } (2)
}
abstract class ProcessDataTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getSource() (1)
@TaskAction
void run() {
println "Processing ${source.get().asFile.text}"
}
}
def generateData = tasks.register('generateData', GenerateDataTask) {
outputFile = layout.buildDirectory.file('data.json')
}
tasks.register('processData', ProcessDataTask) {
source = generateData.flatMap { it.outputFile } (2)
}
| 1 | processData now declares source as an @InputFile. The task action reads from source.get() and never touches the generateData instance. Because the consuming task holds only a typed input value (not a foreign task reference), it serializes cleanly into the cache entry. |
| 2 | source = generateData.flatMap { it.outputFile } wires the two tasks lazily. Gradle resolves the provider at execution time and infers the task dependency from the wiring, so the explicit dependsOn is no longer needed. |
Sharing Mutable Objects
When storing a task in the Configuration Cache, all objects referenced through the task’s fields are serialized.
In most cases, deserialization preserves reference equality—if two fields a and b reference the same instance at configuration time, they will still reference the same instance after deserialization (a == b, or a === b in Groovy/Kotlin syntax).
However, for performance reasons, certain classes—such as java.lang.String, java.io.File, and many java.util.Collection implementations—are serialized without preserving reference equality.
After deserialization, fields that referred to these objects may reference different but equal instances.
Consider a task that stores a user-defined object and an ArrayList as task fields:
class StateObject {
// ...we assume there is some mutable state here, and an equals implementation...
}
abstract class StatefulTask : DefaultTask() {
@get:Internal
var stateObject: StateObject? = null
@get:Internal
var strings: List<String>? = null
}
tasks.register<StatefulTask>("checkEquality") {
val objectValue = StateObject()
// ...configure objectValue as needed...
val stringsValue = arrayListOf("a", "b")
stateObject = objectValue
strings = stringsValue
doLast { (1)
println("POJO reference equality: ${stateObject === objectValue}") (2)
println("Collection reference equality: ${strings === stringsValue}") (3)
println("Collection equality: ${strings == stringsValue}") (4)
}
}
class StateObject {
// ...we assume there is some mutable state here, and an equals implementation...
}
abstract class StatefulTask extends DefaultTask {
@Internal
StateObject stateObject
@Internal
List<String> strings
}
tasks.register("checkEquality", StatefulTask) {
def objectValue = new StateObject()
// ...configure objectValue as needed...
def stringsValue = ["a", "b"] as ArrayList<String>
stateObject = objectValue
strings = stringsValue
doLast { (1)
println("POJO reference equality: ${stateObject === objectValue}") (2)
println("Collection reference equality: ${strings === stringsValue}") (3)
println("Collection equality: ${strings == stringsValue}") (4)
}
}
| 1 | doLast action captures the references from the enclosing scope. These captured references are also serialized to the Configuration Cache. |
| 2 | Compare the reference to an object of user-defined class stored in the task field and the reference captured in the doLast action. |
| 3 | Compare the reference to ArrayList instance stored in the task field and the reference captured in the doLast action. |
| 4 | Check the equality of stored and captured lists. |
Without Configuration Cache, reference equality is preserved in both cases:
$ ./gradlew --no-configuration-cache checkEquality
> Task :checkEquality
POJO reference equality: true
Collection reference equality: true
Collection equality: true
With Configuration Cache enabled, only user-defined object references remain identical. List references are different, although the lists themselves remain equal:
$ ./gradlew --configuration-cache checkEquality
> Task :checkEquality
POJO reference equality: true
Collection reference equality: false
Collection equality: true
Best Practices:
-
Avoid sharing mutable objects between configuration and execution phases.
-
If sharing state is necessary, wrap it in a user-defined class.
-
Do not rely on reference equality for standard Java, Groovy, Kotlin, or Gradle-defined types.
Reference equality is never preserved between tasks—each task is an isolated "realm." To share objects across tasks, use a Build Service to wrap the shared state.
Accessing Task Extensions or Conventions
Tasks must not access conventions, extensions, or extra properties at execution time.
Instead, any value relevant to task execution should be explicitly modeled as a task property to ensure proper caching and reproducibility.
|
This restriction is enforced even when the Configuration Cache is not enabled.
Whenever you run a build without the Configuration Cache, accessing a task’s extensions or conventions at execution time triggers a deprecation warning.
This warning is independent of the |
A common pre-Configuration Cache pattern stores configuration values on a task’s ExtraPropertiesExtension (the ext block) and reads them from within a task action. Here a release task reads a banner from ext.banner:
tasks.register("release") {
extra["banner"] = "=== Releasing build ${rootProject.version} ==="
doLast {
// BAD: reading extra properties at execution time
val banner = extra["banner"] as String
println(banner)
}
}
tasks.register('release') {
ext.banner = "=== Releasing build ${rootProject.version} ==="
doLast {
// BAD: reading extra properties at execution time
println(ext.banner)
}
}
Under the Configuration Cache, reading extra["banner"] (or ext.banner) from inside the task action is unsupported, because extension storage isn’t part of the captured task state.
The Configuration Cache-compatible version exposes the value as a typed @Input property on a proper task class:
abstract class ReleaseTask : DefaultTask() {
@get:Input
abstract val banner: Property<String> (1)
@TaskAction
fun run() {
println(banner.get()) (2)
}
}
tasks.register<ReleaseTask>("release") {
banner = providers.provider { "=== Releasing build ${rootProject.version} ===" } (3)
}
abstract class ReleaseTask extends DefaultTask {
@Input
abstract Property<String> getBanner() (1)
@TaskAction
void run() {
println banner.get() (2)
}
}
tasks.register('release', ReleaseTask) {
banner = providers.provider { "=== Releasing build ${rootProject.version} ===" } (3)
}
| 1 | The value moves from extra / ext storage into a typed @Input Property<String> on the task. Because the input is now declared with @Input, Gradle also includes it in up-to-date checking, which improves correctness as a side benefit. |
| 2 | The task action reads from banner.get() (a serialized field on the task instance) instead of the extension storage. |
| 3 | The wiring at registration uses providers.provider { … } so the value is captured lazily at the configuration-to-execution boundary, which is also what makes the rootProject.version capture itself Configuration Cache-safe (see Gradle Model Types). |
Adding an Extension to a Task You Don’t Own
When you cannot change the task’s class (typically because it comes from another plugin) but you still want to add typed configuration to that task and consume it from an action, use a Gradle-managed extension interface backed by Property<T> (or ListProperty, MapProperty, etc.), then capture the properties into locals at configuration time. The action reads the locals — never the extension.
interface StampExtension { (1)
val message: Property<String>
}
tasks.named("someThirdPartyTask") {
val stamp = extensions.create<StampExtension>("stamp") (2)
stamp.message.convention("built by team X")
val messageProvider = stamp.message (3)
doLast {
println(messageProvider.get()) (4)
}
}
interface StampExtension { (1)
Property<String> getMessage()
}
tasks.named('someThirdPartyTask') {
def stamp = extensions.create('stamp', StampExtension) (2)
stamp.message.convention('built by team X')
def messageProvider = stamp.message (3)
doLast {
println messageProvider.get() (4)
}
}
| 1 | Define a Gradle-managed extension interface with Property-typed getters. Gradle materializes the implementation for you, and each Property is a first-class lazy value that CC can serialize. |
| 2 | Register the extension on the task instance at configuration time via extensions.create(…). In a plugin this is where your DSL (e.g., a stamp { message = … } block) plugs in. |
| 3 | Capture the extension’s Property into a local at configuration time. messageProvider is a Provider<String> — a serializable value, not a live reference to the extension container. |
| 4 | The doLast closure captures only the local messageProvider. It never calls task.extensions[…] at execution time, so CC can serialize the task cleanly. |
Using Build Listeners
Plugins and build scripts must not register build listeners that are created at configuration time and triggered at execution time.
This includes listeners such as BuildListener, TaskExecutionListener, and the gradle.taskGraph.afterTask and gradle.buildFinished callbacks.
These callbacks run as part of the configuration phase, so they don’t fire when the configuration phase is skipped on a cache hit.
The Configuration Cache provides supported replacements for the two most common patterns: observing task execution events, and running logic at the end of the build.
Replacing taskGraph.afterTask and buildFinished with a Build Service
The pre-Configuration Cache pattern registers a closure that captures script-level state. Here a callback collects failed tasks and prints them at the end of the build:
val failedTasks = mutableListOf<String>()
gradle.taskGraph.afterTask {
if (state.failure != null) {
failedTasks.add(path)
}
}
gradle.buildFinished {
if (failedTasks.isNotEmpty()) {
println("Failed tasks: ${failedTasks.joinToString()}")
}
}
def failedTasks = []
gradle.taskGraph.afterTask { Task task, TaskState state ->
if (state.failure != null) {
failedTasks << task.path
}
}
gradle.buildFinished {
if (failedTasks) {
println "Failed tasks: ${failedTasks.join(', ')}"
}
}
Under the Configuration Cache, registering either callback emits a CC problem, so the build fails to store a CC entry. The callbacks do not fire — on a cache miss or a cache hit — because CC prohibits these listener registrations entirely.
The Configuration Cache-compatible replacement is a BuildService that implements OperationCompletionListener, registered with BuildEventsListenerRegistry.onTaskCompletion. End-of-build logic moves into the service’s close() method:
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.build.event.BuildEventsListenerRegistry
import org.gradle.kotlin.dsl.support.serviceOf
import org.gradle.tooling.events.FinishEvent
import org.gradle.tooling.events.OperationCompletionListener
import org.gradle.tooling.events.task.TaskFinishEvent
import org.gradle.tooling.events.task.TaskFailureResult
abstract class FailureCollector :
BuildService<BuildServiceParameters.None>,
OperationCompletionListener, (1)
AutoCloseable {
private val failedTasks = mutableListOf<String>() (3)
override fun onFinish(event: FinishEvent) { (1)
if (event is TaskFinishEvent && event.result is TaskFailureResult) {
failedTasks.add(event.descriptor.taskPath)
}
}
override fun close() { (2)
if (failedTasks.isNotEmpty()) {
println("Failed tasks: ${failedTasks.joinToString()}")
}
}
}
val collector = gradle.sharedServices.registerIfAbsent("failureCollector", FailureCollector::class) {}
val eventListenerRegistry = gradle.serviceOf<BuildEventsListenerRegistry>()
eventListenerRegistry.onTaskCompletion(collector) (4)
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.build.event.BuildEventsListenerRegistry
import org.gradle.tooling.events.FinishEvent
import org.gradle.tooling.events.OperationCompletionListener
import org.gradle.tooling.events.task.TaskFinishEvent
import org.gradle.tooling.events.task.TaskFailureResult
abstract class FailureCollector implements
BuildService<BuildServiceParameters.None>,
OperationCompletionListener, (1)
AutoCloseable {
private final List<String> failedTasks = [] (3)
@Override
void onFinish(FinishEvent event) { (1)
if (event instanceof TaskFinishEvent && event.result instanceof TaskFailureResult) {
failedTasks << event.descriptor.taskPath
}
}
@Override
void close() { (2)
if (failedTasks) {
println "Failed tasks: ${failedTasks.join(', ')}"
}
}
}
def collector = gradle.sharedServices.registerIfAbsent('failureCollector', FailureCollector) {}
def eventListenerRegistry = project.gradle.services.get(BuildEventsListenerRegistry)
eventListenerRegistry.onTaskCompletion(collector) (4)
| 1 | gradle.taskGraph.afterTask is replaced by a BuildService that implements OperationCompletionListener. The service receives TaskFinishEvent instances at execution time. |
| 2 | gradle.buildFinished end-of-build logic moves into the service’s close() method. Gradle calls close() when the service is no longer needed, on both cache hits and cache misses. |
| 3 | The mutable script-level failedTasks list is replaced by a field on the service. State is owned by the service instance, not captured by a closure. |
| 4 | BuildEventsListenerRegistry.onTaskCompletion(provider) is the supported way to subscribe an OperationCompletionListener-implementing service to task-completion events. Gradle instantiates the service lazily when the first event is dispatched, so nothing about the service has to be materialized at configuration time. |
Replacing buildFinished with a Dataflow Action
For end-of-build logic that does not need to observe each task’s outcome, use a dataflow action instead. Dataflow actions are the supported replacement for plain gradle.buildFinished callbacks and they fire on both cache hits and cache misses.
Using Build Services
Build Services are the Gradle-supported way to share state across tasks, replace BuildListener-style hooks, and observe task execution events.
They are also the most common feature-level integration point with the Configuration Cache, and a few caveats apply.
Parameter values are part of the cache entry.
The parameters interface of a build service is a custom Gradle type, and its property values are serialized into the Configuration Cache entry alongside the rest of the build’s configured state.
Parameter property types must be Configuration Cache-compatible.
Standard managed property types (Property<T>, RegularFileProperty, ListProperty<T>, and so on) are supported containers, but the value type T must also be serializable by the Configuration Cache.
For example, Property<String> is safe, but Property<Thread> is not.
Raw Configuration references, live JVM state, and Project references are not supported as parameter values.
The service lifecycle differs under the Configuration Cache. Only the parameters are cached — service instances are not. With the Configuration Cache enabled, build services follow this lifecycle:
-
End of the configuration phase: all build services that were created at configuration time (by calling
get()on their provider) are destroyed. The one exception is a service registered as anOperationCompletionListenerand created at configuration time — the same instance is reused during the execution phase of that build. -
Execution time: services are created upon first use. This includes re-creating services that were destroyed in step 1. As a consequence, a build service may be created and destroyed twice in a single build.
-
Cache hit (no configuration phase): all services are created from scratch upon first use. Registered
OperationCompletionListenerservices are created upon the first event if nothing triggers them earlier.
A BuildServiceProvider cannot drive a ValueSource at configuration time.
Passing a provider of a BuildService (or anything derived from it via map or flatMap) as a ValueSourceParameters value that is read at configuration time is not supported.
If you do not need the service-derived value at configuration time, use @ServiceReference for direct injection or store the build service reference in an @Internal-annotated property on a task instead.
Sometimes you may want to use a BuildService to derive a parameter for a ValueSource, like this:
val serviceProvider = gradle.sharedServices.registerIfAbsent("myService", MyService::class) { /* ... */ }
val valueSource = providers.of(MyValueSource::class) {
parameters {
// Doesn't work when valueSource.get() is called at configuration time
input = serviceProvider.map { it.getValueFromService() }
}
}
This is only supported by the Configuration Cache if the resulting ValueSource isn’t used at configuration time.
If you need the ValueSource’s value at configuration time, you have to tell the Configuration Cache to use the computed input value for fingerprinting by wrapping the eager computation in a `provider {} block:
val serviceProvider = gradle.sharedServices.registerIfAbsent("myService", MyService::class) { /* ... */ }
val valueSource = providers.of(MyValueSource::class) {
parameters {
input = provider { serviceProvider.get().getValueFromService() }
}
}
With this workaround, the entire computation of getValueFromService() contributes to configuration inputs rather than being lazily evaluated when checking the fingerprint.
The cache is invalidated whenever the computation’s result changes, not just when the ValueSource result changes.
This approach also does not work when the computed value is transient and changes between builds (for example, a publishing URL).
|
See the related not-yet-implemented entry for the current state.
Register as OperationCompletionListener to observe task execution.
A build service that implements OperationCompletionListener and is registered via BuildEventsListenerRegistry.onTaskCompletion is the supported way to observe task execution under the Configuration Cache.
If the service was created at configuration time, the same instance is reused during the execution phase of that build.
On a cache hit, when no configuration phase runs, the service is created from scratch upon the first event.
Using External Information Sources
Examples of external sources include client environment variables, system properties, configuration files, shell commands, network services, among others.
To represent such sources in a Gradle build in a Configuration Cache friendly way we use ValueSource.
They make it possible for Gradle to transparently manage the Configuration Cache as values obtained from those sources change.
For example, a build might run a different set of tasks depending on whether the CI environment variable is set or not.
A ValueSource implementation is exempt from the automatic detection of configuration cache inputs.
For example, if the obtain() method reads a system property, an environment variable, or a file, those reads do not individually become configuration cache inputs.
Instead, only the value returned by obtain() is tracked.
If that value changes between builds, the Configuration Cache is invalidated.
This makes ValueSource the recommended escape hatch when built-in providers like providers.systemProperty(), providers.environmentVariable(), or providers.fileContents() are too limited for your use case.
Creating a ValueSource
To integrate a new type of value source:
-
Create an abstract class implementing
ValueSource<T, P>. -
Implement the
obtain()method to compute and return the value. The returned value must be effectively immutable (e.g., astring,int,boolean, an unmodifiable collection, or a serializable data class). -
Use
providers.of(Class, Action)to get aProviderbacked by your source.
The returned Provider can be passed to task properties or queried during the configuration phase.
If the value is queried at configuration time, the source is automatically considered a build configuration input.
Do not implement getParameters() in your class—Gradle provides the implementation automatically.
|
Your ValueSource implementation does not need to be thread-safe, Gradle synchronizes calls to obtain().
|
Parameterizing a ValueSource
A value source implementation will most likely take parameters.
To do this, create a subtype of ValueSourceParameters and declare it as the type parameter P of your ValueSource implementation.
The parameters are configured in the Action passed to providers.of().
If no parameters are needed, use ValueSourceParameters.None.
Injecting Services
Gradle services can be injected into a ValueSource implementation by adding a parameter to the constructor and annotating it with @Inject.
Currently, the following service is supported:
-
ExecOperations— provides means to execute external processes. This service can be used even at configuration time. Becauseobtain()is called on every build (see Configuration Cache Behavior below), only fast-running commands should be used.
You can also use standard Java/Kotlin/Groovy process APIs such as java.lang.ProcessBuilder inside obtain().
Configuration Cache Behavior
When a provider backed by a ValueSource is queried at configuration time, the value becomes a build configuration input.
The obtain() method is then executed on every subsequent build to determine whether the Configuration Cache entry is still UP-TO-DATE:
-
If the value has not changed, the cached configuration is reused.
-
If the value has changed, the cache is invalidated and the configuration phase runs again.
Because obtain() runs on every build, it is recommended to keep the implementation fast.
ValueSource is the recommended approach for the following scenarios:
-
Running external processes at configuration time — such as calling git or a CLI tool to compute a version string or check a precondition.
-
Complex system property or environment variable access patterns — when you need to combine, transform, or conditionally read multiple properties.
-
Reading files at configuration time — when
providers.fileContents()is insufficient, such as parsing a file in a custom format.
Running External Processes
| Plugin and build scripts should avoid running external processes at configuration time. |
You must not use these APIs directly for running processes during configuration:
-
Java/Kotlin:
ProcessBuilder,Runtime.exec(…), etc… -
Groovy:
*.execute(), etc… -
Gradle:
ExecOperations.exec,ExecOperations.javaexec, etc…
The flexibility of these methods prevents Gradle from determining how the calls impact the build configuration, making it difficult to ensure that the Configuration Cache entry can be safely reused. Therefore, Gradle reports a Configuration Cache problem when it detects such an invocation. Even when the Configuration Cache is disabled, calling these methods at configuration time still emits a deprecation warning and will become an error in a future Gradle version.
If running processes is required at configuration time, you can use the configuration-cache-compatible APIs detailed below.
For simpler cases, when grabbing the output of the process is enough,
providers.exec() and
providers.javaexec() can be used:
val gitVersion = providers.exec {
commandLine("git", "--version")
}.standardOutput.asText.get()
def gitVersion = providers.exec {
commandLine("git", "--version")
}.standardOutput.asText.get()
For more complex cases, a custom ValueSource implementation with injected ExecOperations can be used (see Using External Information Sources for a full overview of ValueSource).
This ExecOperations instance can be used at configuration time without restrictions:
abstract class GitVersionValueSource : ValueSource<String, ValueSourceParameters.None> {
@get:Inject
abstract val execOperations: ExecOperations
override fun obtain(): String {
val output = ByteArrayOutputStream()
execOperations.exec {
commandLine("git", "--version")
standardOutput = output
}
return String(output.toByteArray(), Charset.defaultCharset())
}
}
abstract class GitVersionValueSource implements ValueSource<String, ValueSourceParameters.None> {
@Inject
abstract ExecOperations getExecOperations()
String obtain() {
ByteArrayOutputStream output = new ByteArrayOutputStream()
execOperations.exec {
it.commandLine "git", "--version"
it.standardOutput = output
}
return new String(output.toByteArray(), Charset.defaultCharset())
}
}
You can also use standard Java/Kotlin/Groovy process APIs like java.lang.ProcessBuilder in the ValueSource.
The ValueSource implementation can then be used to create a provider with providers.of:
val gitVersionProvider = providers.of(GitVersionValueSource::class) {}
val gitVersion = gitVersionProvider.get()
def gitVersionProvider = providers.of(GitVersionValueSource.class) {}
def gitVersion = gitVersionProvider.get()
In both approaches, if the value of the provider is used at configuration time then it will become a build configuration input.
The external process will be executed for every build to determine if the Configuration Cache is UP-TO-DATE, so it is recommended to only call fast-running processes at configuration time.
If the value changes then the cache is invalidated and the process will be run again during this build as part of the configuration phase.
Reading System Properties and Environment Variables
Plugins and build scripts may read system properties and environment variables directly at configuration time with standard Java, Groovy, or Kotlin APIs, or lazily through the value supplier APIs. Both work with the Configuration Cache: the read is correctly recorded as a build configuration input, and changing the value invalidates the cache so the build sees the new value.
The Configuration Cache report includes a list of these build configuration inputs to help track them.
The value supplier APIs — providers.systemProperty() and
providers.environmentVariable() — return a Provider that resolves at execution time.
Wiring the provider into a task property lets the value change without invalidating the cache, so the task simply re-runs with the new value instead of forcing a full configuration re-run.
The eager pattern reads an environment variable directly at configuration time and assigns the resolved string to a task property:
abstract class StampBuildTask : DefaultTask() {
@get:Input
abstract val buildNumber: Property<String>
@get:OutputFile
abstract val output: RegularFileProperty
@TaskAction
fun run() {
output.get().asFile.writeText("Build ${buildNumber.get()}")
}
}
tasks.register<StampBuildTask>("stampBuild") {
// Eager: reads the env var at configuration time; still tracked correctly, but re-runs configuration whenever the value changes
buildNumber = System.getenv("BUILD_NUMBER") ?: "local"
output = layout.buildDirectory.file("build-stamp.txt")
}
abstract class StampBuildTask extends DefaultTask {
@Input
abstract Property<String> getBuildNumber()
@OutputFile
abstract RegularFileProperty getOutput()
@TaskAction
void run() {
output.get().asFile.text = "Build ${buildNumber.get()}"
}
}
tasks.register('stampBuild', StampBuildTask) {
// Eager: reads the env var at configuration time; still tracked correctly, but re-runs configuration whenever the value changes
buildNumber = System.getenv('BUILD_NUMBER') ?: 'local'
output = layout.buildDirectory.file('build-stamp.txt')
}
This is a valid build under the Configuration Cache. The env-var read happens during configuration and Gradle records BUILD_NUMBER as a build configuration input, so changing the value correctly invalidates the cache. The cost is that a value change re-runs the entire configuration phase, which is undesirable on CI builds where the value rotates frequently.
The deferred version uses providers.environmentVariable() and pipes the lazy provider into the task property:
tasks.register<StampBuildTask>("stampBuild") {
buildNumber = providers.environmentVariable("BUILD_NUMBER").orElse("local") (1) (2)
output = layout.buildDirectory.file("build-stamp.txt")
}
tasks.register('stampBuild', StampBuildTask) {
buildNumber = providers.environmentVariable('BUILD_NUMBER').orElse('local') (1) (2)
output = layout.buildDirectory.file('build-stamp.txt')
}
| 1 | System.getenv("BUILD_NUMBER") is replaced by providers.environmentVariable("BUILD_NUMBER"). The Provider is stored into the task property, but the actual env-var read only happens at execution time when the value is requested. That’s the point: the env var becomes a task input (its value affects that task’s inputs), not a build configuration input (which would invalidate the whole cache entry when the value changes). Changing BUILD_NUMBER between builds no longer forces the configuration phase to re-run. |
| 2 | The fallback ?: "local" becomes .orElse("local") on the provider. For system properties, the equivalent is providers.systemProperty("name"). |
Some access patterns that potentially enumerate all environment variables or system properties (for example, calling System.getenv().forEach() or using the iterator of its keySet()) are discouraged.
In this case, Gradle cannot find out what properties are actual build configuration inputs, so every available property becomes one.
Even adding a new property will invalidate the cache if this pattern is used.
Using a custom predicate to filter environment variables is an example of this discouraged pattern:
val jdkLocations = System.getenv().filterKeys {
it.startsWith("JDK_")
}
def jdkLocations = System.getenv().findAll {
key, _ -> key.startsWith("JDK_")
}
The logic in the predicate is opaque to the Configuration Cache, so all environment variables are considered inputs.
One way to reduce the number of inputs is to always use methods that query a concrete variable name, such as getenv(String), or getenv().get():
val jdkVariables = listOf("JDK_8", "JDK_11", "JDK_17")
val jdkLocations = jdkVariables.filter { v ->
System.getenv(v) != null
}.associate { v ->
v to System.getenv(v)
}
def jdkVariables = ["JDK_8", "JDK_11", "JDK_17"]
def jdkLocations = jdkVariables.findAll { v ->
System.getenv(v) != null
}.collectEntries { v ->
[v, System.getenv(v)]
}
The fixed code above, however, is not exactly equivalent to the original as only an explicit list of variables is supported. Prefix-based filtering is a common scenario, so there are provider-based APIs to access system properties and environment variables:
val jdkLocationsProvider = providers.environmentVariablesPrefixedBy("JDK_")
def jdkLocationsProvider = providers.environmentVariablesPrefixedBy("JDK_")
Note that the Configuration Cache would be invalidated not only when the value of the variable changes or the variable is removed but also when another variable with the matching prefix is added to the environment.
For more complex use cases, a custom ValueSource implementation can be used (see Using External Information Sources for a full overview of ValueSource).
System properties and environment variables referenced in the code of the ValueSource do not become build configuration inputs, so any processing can be applied.
Instead, the value of the ValueSource is recomputed each time the build runs and only if the value changes the Configuration Cache is invalidated.
For example, a ValueSource can be used to get all environment variables with names containing the substring JDK:
abstract class EnvVarsWithSubstringValueSource : ValueSource<Map<String, String>, EnvVarsWithSubstringValueSource.Parameters> {
interface Parameters : ValueSourceParameters {
val substring: Property<String>
}
override fun obtain(): Map<String, String> {
return System.getenv().filterKeys { key ->
key.contains(parameters.substring.get())
}
}
}
val jdkLocationsProvider = providers.of(EnvVarsWithSubstringValueSource::class) {
parameters {
substring = "JDK"
}
}
abstract class EnvVarsWithSubstringValueSource implements ValueSource<Map<String, String>, Parameters> {
interface Parameters extends ValueSourceParameters {
Property<String> getSubstring()
}
Map<String, String> obtain() {
return System.getenv().findAll { key, _ ->
key.contains(parameters.substring.get())
}
}
}
def jdkLocationsProvider = providers.of(EnvVarsWithSubstringValueSource.class) {
parameters {
substring = "JDK"
}
}
Undeclared Reading of Files
Plugins and build scripts may read files directly at configuration time using standard Java, Groovy, or Kotlin APIs, or lazily through providers.fileContents().
Both work with the Configuration Cache: Gradle tracks the read via bytecode instrumentation, records the file as a build configuration input, and invalidates the cache when its contents change.
The eager pattern looks like this:
val config = file("some.conf").readText()
def config = file('some.conf').text
The deferred version reads the file through providers.fileContents(), so the read happens at execution time and the file becomes a task input rather than a build configuration input:
val config = providers.fileContents(layout.projectDirectory.file("some.conf"))
.asText
def config = providers.fileContents(layout.projectDirectory.file('some.conf'))
.asText
Preferring the deferred form is a good idea when the file content changes often, because a change re-runs only the affected task instead of forcing a full configuration re-run.
Here is a more complete example that reads a config file and uses its contents to compute a task input:
abstract class GenerateReportTask : DefaultTask() {
@get:Input
abstract val title: Property<String>
@get:OutputFile
abstract val output: RegularFileProperty
@TaskAction
fun run() {
output.get().asFile.writeText("Title: ${title.get()}")
}
}
tasks.register<GenerateReportTask>("generateReport") {
// Eager: file read happens during configuration; tracked correctly, but any change re-runs configuration
title = file("config/title.txt").readText().trim()
output = layout.buildDirectory.file("report.txt")
}
abstract class GenerateReportTask extends DefaultTask {
@Input
abstract Property<String> getTitle()
@OutputFile
abstract RegularFileProperty getOutput()
@TaskAction
void run() {
output.get().asFile.text = "Title: ${title.get()}"
}
}
tasks.register('generateReport', GenerateReportTask) {
// Eager: file read happens during configuration; tracked correctly, but any change re-runs configuration
title = file('config/title.txt').text.trim()
output = layout.buildDirectory.file('report.txt')
}
Gradle tracks this read and records config/title.txt as a build configuration input, so a change correctly invalidates the CC entry. The cost is that a change re-runs the entire configuration phase, not just the affected task.
The Configuration Cache-friendly version reads the file through providers.fileContents():
tasks.register<GenerateReportTask>("generateReport") {
title = providers.fileContents(layout.projectDirectory.file("config/title.txt")) (1)
.asText
.map { it.trim() } (2)
output = layout.buildDirectory.file("report.txt")
}
tasks.register('generateReport', GenerateReportTask) {
title = providers.fileContents(layout.projectDirectory.file('config/title.txt')) (1)
.asText
.map { it.trim() } (2)
output = layout.buildDirectory.file('report.txt')
}
| 1 | file("config/title.txt").readText() is replaced by providers.fileContents(…).asText. The Provider is stored on the task, but the actual file read only happens at execution time when the value is requested. That’s the point: the file becomes a task input (its contents affect that task’s fingerprint), not a build configuration input (which would invalidate the whole CC entry). Changing config/title.txt between builds no longer forces the configuration phase to re-run. |
| 2 | The .trim() post-processing happens inside .map { … } on the provider, so it stays lazy and runs at execution time. |
Bytecode Modifications and Java Agent
To detect the configuration inputs, Gradle modifies the bytecode of classes on the build script classpath, like plugins and their dependencies. Gradle uses a Java agent to modify the bytecode. Integrity self-checks of some libraries may fail because of the changed bytecode or the agent’s presence.
To work around this, you can use the Worker API with classloader or process isolation to encapsulate the library code. The bytecode of the worker’s classpath is not modified, so the self-checks should pass. When process isolation is used, the worker action is executed in a separate worker process that doesn’t have the Gradle Java agent installed.
A task that calls a problematic library directly might look like this:
abstract class VerifySignatureTask : DefaultTask() {
@get:InputFile
abstract val artifact: RegularFileProperty
@TaskAction
fun run() {
// BAD when the library does bytecode integrity checks on itself:
// it sees the Gradle-modified bytecode and fails
com.example.signing.SignatureVerifier.verify(artifact.get().asFile)
}
}
abstract class VerifySignatureTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getArtifact()
@TaskAction
void run() {
// BAD when the library does bytecode integrity checks on itself:
// it sees the Gradle-modified bytecode and fails
com.example.signing.SignatureVerifier.verify(artifact.get().asFile)
}
}
The Configuration Cache-compatible version moves the call into a worker action. The worker classpath is not modified by the Gradle Java agent, so library integrity self-checks pass. With process isolation, the worker runs in a separate JVM that doesn’t have the agent installed at all:
interface VerifyWorkParameters : WorkParameters { (3)
val artifact: RegularFileProperty
}
abstract class VerifyWorkAction : WorkAction<VerifyWorkParameters> { (1)
override fun execute() {
com.example.signing.SignatureVerifier.verify(parameters.artifact.get().asFile)
}
}
abstract class VerifySignatureTask : DefaultTask() {
@get:InputFile
abstract val artifact: RegularFileProperty
@get:Inject
abstract val workerExecutor: WorkerExecutor
@TaskAction
fun run() {
val workQueue = workerExecutor.processIsolation() (2)
workQueue.submit(VerifyWorkAction::class) {
artifact = this@VerifySignatureTask.artifact
}
}
}
interface VerifyWorkParameters extends WorkParameters { (3)
RegularFileProperty getArtifact()
}
abstract class VerifyWorkAction implements WorkAction<VerifyWorkParameters> { (1)
@Override
void execute() {
com.example.signing.SignatureVerifier.verify(parameters.artifact.get().asFile)
}
}
abstract class VerifySignatureTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getArtifact()
@Inject
abstract WorkerExecutor getWorkerExecutor()
@TaskAction
void run() {
def workQueue = workerExecutor.processIsolation() (2)
workQueue.submit(VerifyWorkAction) {
artifact = this.artifact
}
}
}
| 1 | The library call moves from the task action into a WorkAction. The worker classpath is separate from the build-script classpath and is not modified by the agent. |
| 2 | processIsolation() runs the worker in a separate JVM that does not have the Gradle Java agent installed at all. classLoaderIsolation() also avoids agent modification of the worker classpath, but it shares the JVM; choose processIsolation() if the library is sensitive to the agent’s mere presence in the JVM. |
| 3 | Inputs are passed through WorkParameters rather than captured from the task, which keeps the work action serializable in its own right. |
In simple cases, when the libraries also provide command-line entry points (public static void main() method), you can also use the JavaExec task to isolate the library. The JavaExec JVM runs without the Gradle agent.
The agent also affects hot-swapping build logic classes in a running build. It prevents Hot Code Replace of such classes: Gradle logs a warning and ignores the recompiled definition. You should not rely on Hot Code Replace when debugging build logic; restart the build to pick up such a change.
Handling of Credentials and Secrets
The Configuration Cache serializes all non-transient fields reachable from scheduled tasks into the cache entry, which is stored under .gradle/configuration-cache in the root build directory.
Sensitive values such as credentials, tokens, and API keys that are held in task state will be included in the entry.
While Java’s transient keyword can exclude individual fields from serialization, there is no Gradle-level API to selectively mark a property as "do not cache."
See Selective Exclusion of Sensitive Values for the current status.
To mitigate the risk of accidental exposure, Gradle encrypts the Configuration Cache.
When required, Gradle transparently generates a machine-specific secret key, caches it under the
GRADLE_USER_HOME directory, and uses it to encrypt data in the project-specific caches.
To further enhance security, follow these recommendations:
-
Store credentials in
GRADLE_USER_HOME/gradle.properties. The content of this file is not included in the Configuration Cache — only its fingerprint is. If storing secrets in this file, ensure access is properly restricted. -
Use the Gradle credentials API for repository authentication, so that credential values are looked up from Gradle properties rather than hardcoded in build scripts.
-
Use lazy wiring like
providers.environmentVariable()to feed sensitive data to tasks and only obtain the values at execution time, keeping them out of the serialized task graph. -
Mark fields holding runtime secrets as
transientif the value is not needed after deserialization. -
Restrict access to the
.gradle/configuration-cachedirectory and the encryption key inGRADLE_USER_HOME.
For an overview, see Security Considerations. See also gradle/gradle#3972.
A common pre-Configuration Cache pattern reads a credential from an environment variable at configuration time and passes it into a task. The captured value lives on the task instance and ends up in the cache entry:
abstract class PublishArtifactTask : DefaultTask() {
@get:Input
abstract val apiToken: Property<String>
@TaskAction
fun run() {
// ... uses apiToken.get() to call the registry
}
}
tasks.register<PublishArtifactTask>("publishArtifact") {
// BAD: the literal token value is captured into the task field at configuration time
apiToken = System.getenv("REGISTRY_TOKEN") ?: ""
}
abstract class PublishArtifactTask extends DefaultTask {
@Input
abstract Property<String> getApiToken()
@TaskAction
void run() {
// ... uses apiToken.get() to call the registry
}
}
tasks.register('publishArtifact', PublishArtifactTask) {
// BAD: the literal token value is captured into the task field at configuration time
apiToken = System.getenv('REGISTRY_TOKEN') ?: ''
}
Under the Configuration Cache, System.getenv("REGISTRY_TOKEN") resolves the environment variable eagerly at configuration time, so the resolved string is captured into the task’s apiToken field and serialized into the cache entry alongside the rest of the task graph. The entry is encrypted on disk, but the plaintext value still lives inside the file. The env var also becomes a build configuration input, so rotating the token invalidates the cache on every CI build.
The Configuration Cache-friendly pattern keeps the secret in the environment variable but reads it lazily through providers.environmentVariable():
abstract class PublishArtifactTask : DefaultTask() {
@get:Input
abstract val apiToken: Property<String>
@TaskAction
fun run() {
// ... uses apiToken.get() to call the registry
}
}
tasks.register<PublishArtifactTask>("publishArtifact") {
apiToken = providers.environmentVariable("REGISTRY_TOKEN").orElse("") (1) (2)
}
abstract class PublishArtifactTask extends DefaultTask {
@Input
abstract Property<String> getApiToken()
@TaskAction
void run() {
// ... uses apiToken.get() to call the registry
}
}
tasks.register('publishArtifact', PublishArtifactTask) {
apiToken = providers.environmentVariable('REGISTRY_TOKEN').orElse('') (1) (2)
}
| 1 | providers.environmentVariable("REGISTRY_TOKEN") returns a Provider<String> that resolves at execution time. The task’s apiToken field holds the provider, not the resolved secret, so the plaintext value never enters the CC entry. |
| 2 | Because the read happens at execution time, REGISTRY_TOKEN becomes a task input (part of that task’s fingerprint), not a build configuration input. Rotating the token no longer invalidates the CC entry; the task that actually consumes the token simply re-runs with the new value. |
If you also want to avoid setting the env var at all — for example on a developer’s local machine — put the value in GRADLE_USER_HOME/gradle.properties (as registryToken=…) and read it with providers.gradleProperty("registryToken") instead. gradle.properties values are Gradle-provided, not process-level env vars, and the resolved string is likewise never serialized into the CC entry.
For credentials provided to repositories specifically, use the PasswordCredentials mechanism with a credential identity prefix and let Gradle resolve it from properties or environment variables. See Basic authentication for the canonical setup.
Providing an Encryption Key with the GRADLE_ENCRYPTION_KEY Environment Variable
By default, Gradle automatically generates and manages the encryption key as a Java keystore, stored under the GRADLE_USER_HOME directory.
For environments where this behavior is undesirable—such as when the GRADLE_USER_HOME directory is shared across multiple machines—you can explicitly provide an encryption key using the GRADLE_ENCRYPTION_KEY environment variable.
|
The same encryption key must be consistently provided across multiple Gradle runs; otherwise, Gradle will be unable to reuse existing cached configurations. |
Generating an Encryption Key compatible with GRADLE_ENCRYPTION_KEY
To encrypt the Configuration Cache using a user-specified encryption key, Gradle requires the GRADLE_ENCRYPTION_KEY environment variable to be set with a valid AES key, encoded as a Base64 string.
You can generate a Base64-encoded AES-compatible key using the following command:
$ openssl rand -base64 16
This command works on Linux and macOS, and on Windows if using a tool like Cygwin.
Once generated, set the Base64-encoded key as the value of the GRADLE_ENCRYPTION_KEY environment variable:
$ export GRADLE_ENCRYPTION_KEY="your-generated-key-here"
Third-party Java Agents with the Configuration Cache
The Configuration Cache supports third-party -javaagent: attachments to the build JVM (for example a coverage agent such as Jacoco, IntelliJ’s idea_rt.jar, or a custom user agent) in regular daemon builds and in TestKit's default (daemon) mode.
Only agents attached at JVM startup are supported. Agents attached dynamically via the Attach API after the JVM has started are not detected and may cause incorrect bytecode observation on classes loaded after attachment.
It is not supported when TestKit runs in embedded mode (withDebug(true)). To use a third-party Java agent with the Configuration Cache from there, drop withDebug(true) and let TestKit use its daemon default.
To debug the daemon TestKit spawns with the Configuration Cache enabled:
-
Do not use
withDebug(true). -
Pass
-Dorg.gradle.debug=trueto the build under test (see debugging options). -
Attach the debugger manually.