Gradle Dependency Locking, Verification & Caching
|
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 and the compiler both ran on JDK 26;
the code is compiled with sourceCompatibility 21, and no toolchain was involved). Every example was run through the wrapper
in a scratch copy of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files. 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
In the listings the project directory of the scratch copies is replaced by /work/tidepool (and by /work/consumer and
/work/ro in the cache section); where a message contained something else that is not part of the lesson, it is marked as
omitted. The examples that need a clean state ran with their own Gradle user home, so that nothing depended on, or changed, the
cache of the machine. The dependencies come from Maven Central, so the version numbers that you see (for example
commons-csv 1.14.1, the newest 1.x on the day of the run) are those of 2026-10-16. All examples are compatible with the
configuration cache and were run with --configuration-cache, with these exceptions, each said where it happens: the task
that resolves every configuration for locking (resolveAndLockAll, which is declared incompatible on purpose), the runs
that demonstrate an error (the configuration cache report would hide the message), and the runs that demonstrate caching,
where the effect of the configuration cache is explained separately. Features newer than Gradle 8.14 name their release;
Gradle 8.14.x was not installed here, so statements about it come from the official documentation and the release notes and are
labelled "not run here".
A build that downloads code from the internet has three questions to answer, and Gradle has one feature for each. Which
versions will I get? A dynamic version such as 1.+ answers differently tomorrow than it does today; dependency locking
records the answer in a file, so that it stays the same until you ask for a new one. Is this really the file that the
author published? A repository, a mirror or a man in the middle can substitute another; dependency verification checks
every downloaded byte against checksums and signatures that you committed. Do I have to download it again? Not if you
already did; the dependency cache remembers artifacts and the results of resolution, and a handful of switches say how far it
is trusted. The three work at different moments of the same resolution, as the figure shows.
The three features answer different questions and do not replace each other. Locking does not check bytes, verification does not pin versions, and the cache does neither. The tool-neutral discussion of why a build should care is on Supply-Chain Security (which also summarises the Gradle verification flow in a few commands) and on Dependency Management Fundamentals; this page is the full reference for the Gradle side. The configurations and the declaration syntax are on Declaring Dependencies & Configurations; the repositories that the cache remembers are on Repositories & Content Filtering; the rich version declarations that locking turns into constraints are on Platforms, Constraints & Rich Versions.
The project used on this page
The examples use the three-module tidepool project: tidepool-model (no dependencies), tidepool-engine (reads the
station CSV with Commons CSV) and tidepool-cli (a picocli command line). The repository is declared once in
settings.gradle.kts, and the versions live in the version catalog (see
Version Catalogs). Both are written with dynamic
versions on purpose, because dynamic versions are what locking exists for: 1.+ means "the newest 1.x", and 4.7.+ "the
newest 4.7.x".
// settings.gradle.kts
rootProject.name = "tidepool"
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
include("tidepool-model", "tidepool-engine", "tidepool-cli")
// settings.gradle
rootProject.name = 'tidepool'
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
# gradle/libs.versions.toml
[versions]
commons-csv = "1.+"
picocli = "4.7.+"
[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
// tidepool-engine/build.gradle.kts
dependencies {
api(project(":tidepool-model"))
implementation(libs.commons.csv)
}
// tidepool-cli/build.gradle.kts
plugins {
application
}
dependencies {
implementation(project(":tidepool-engine"))
implementation(libs.picocli)
}
application {
mainClass = "io.tidepool.cli.Main"
}
// tidepool-engine/build.gradle
dependencies {
api project(':tidepool-model')
implementation libs.commons.csv
}
// tidepool-cli/build.gradle
plugins {
id 'application'
}
dependencies {
implementation project(':tidepool-engine')
implementation libs.picocli
}
application {
mainClass = 'io.tidepool.cli.Main'
}
tidepool-model/build.gradle(.kts) is empty; the shared part (the java-library plugin and the Java level) is applied from the
root build file with subprojects { ... }, as in the root build below (a real project would use a convention plugin, see
Convention Plugins & Sharing Build Logic).
Without locking, the dependency report shows what the dynamic versions resolved to today:
compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
\--- org.apache.commons:commons-csv:1.+ -> 1.14.1
+--- commons-io:commons-io:2.20.0
\--- commons-codec:commons-codec:1.19.0
(The report was produced with ./gradlew :tidepool-engine:dependencies --configuration compileClasspath; the --scan
hint line at its end is omitted here and in the other dependencies listings.) Commons CSV 1.14.1 brings
commons-io and commons-codec with it. The versions of those two were not written by anyone in the build; they come from
the POM of the version that 1.+ selected. A different day, another repository mirror or a new release would change them, and
nothing in the build would say so. That is the problem the next section solves.
These files are this page’s own setup for the examples, not an example taken from the manual.
Dependency locking
Dependency locking makes the result of resolution a file in the repository. The file lists the exact version of every module that the locked configuration resolved to, including the transitive ones, and from then on Gradle uses it to verify that the build still resolves the same set.
Turning locking on
Locking is enabled per configuration; once enabled, a lock state must exist (or be created, see below), and the build then fails if resolution no longer produces what the file says. For a few configurations the resolution strategy has the switch:
configurations {
compileClasspath {
resolutionStrategy.activateDependencyLocking()
}
}
configurations {
compileClasspath {
resolutionStrategy.activateDependencyLocking()
}
}
Only resolvable configurations carry lock state; the call on a consumable or declarable one does nothing. A build that wants
every project configuration locked says so once, with the dependencyLocking extension, typically for all projects:
// build.gradle.kts (root)
allprojects {
dependencyLocking {
lockAllConfigurations()
}
tasks.register("resolveAndLockAll") {
notCompatibleWithConfigurationCache("Resolves configurations at execution time")
doFirst {
require(gradle.startParameter.isWriteDependencyLocks) { "Run with --write-locks" }
}
doLast {
configurations.filter { it.isCanBeResolved }.forEach { it.resolve() }
}
}
}
subprojects {
apply(plugin = "java-library")
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_21
}
}
// build.gradle (root)
allprojects {
dependencyLocking {
lockAllConfigurations()
}
tasks.register('resolveAndLockAll') {
notCompatibleWithConfigurationCache('Resolves configurations at execution time')
doFirst {
if (!gradle.startParameter.writeDependencyLocks) {
throw new GradleException('Run with --write-locks')
}
}
doLast {
configurations.matching { it.canBeResolved }.each { it.resolve() }
}
}
}
subprojects {
apply plugin: 'java-library'
java {
sourceCompatibility = JavaVersion.VERSION_21
}
}
lockAllConfigurations() locks the configurations of the project, not those of the build script (buildscript { configurations.classpath })
nor those of the settings; both have their own switch, shown in the section "The build script and the settings". The task
resolveAndLockAll is the helper that the Gradle documentation describes for writing all the lock states in one run, and it is
explained in the next section. In the Groovy build it uses matching and not findAll: on Gradle 9.8.1 the first
version of this build file used configurations.findAll { it.canBeResolved }.each { ... }, and the build printed The
DomainObjectCollection.findAll(Closure) method has been deprecated. This is scheduled to be removed in Gradle 10. with a link
to the upgrade guide, because findAll on a domain object collection is going away; matching returns the live, filtered
collection and has no such problem. (The Kotlin filter is the Kotlin standard library function on a collection and is not affected.)
Writing the lock state
Lock state is only written when you ask for it, with --write-locks, on a build that resolves the locked configurations:
./gradlew resolveAndLockAll --write-locks
> Task :resolveAndLockAll
> Task :tidepool-cli:resolveAndLockAll
> Task :tidepool-engine:resolveAndLockAll
> Task :tidepool-model:resolveAndLockAll
Persisted dependency lock state for Dependency Resolution Management
Persisted dependency lock state for project ':tidepool-cli'
Persisted dependency lock state for project ':tidepool-engine'
Persisted dependency lock state for project ':tidepool-model'
BUILD SUCCESSFUL in 4s
4 actionable tasks: 4 executed
(The line about starting a daemon that could not reuse others is omitted; the machine had several daemons of other builds.)
Gradle writes one gradle.lockfile per project, next to its build file, and one settings-gradle.lockfile for the dependency
resolution management of the settings (the version catalog is resolved there). The lock file of the engine:
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :tidepool-engine:dependencies --write-locks
commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor
and of the command line project, which shows how the same module can be locked in some configurations and absent from others
(the engine is an implementation dependency, so commons-csv is not on the compile classpath of the CLI):
commons-codec:commons-codec:1.19.0=runtimeClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=runtimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=runtimeClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor
The format is simple on purpose: one group:name:version per line followed by the configurations that contain it, sorted
alphabetically so that version control diffs are small, and a last empty= line for locked configurations that resolved to
nothing. The file belongs in version control, and Gradle does not write it when the build fails, so a failed resolution cannot
leave a half-written state behind. The settings lock file had only the line empty=incomingCatalogForLibs0 (the
configuration that resolves the catalog libs, which had no external dependencies).
Two details of the command are easy to miss. ./gradlew dependencies --write-locks writes the state of the configurations that
the dependencies report resolves, and in a multi-project build the unqualified dependencies task runs in only one project
(the one in the current directory): in the tidepool build it printed No configurations for the root project, persisted only the
settings lock state, and created no gradle.lockfile at all. The two ways around it are naming the projects
(./gradlew :tidepool-engine:dependencies :tidepool-cli:dependencies --write-locks) or a task that resolves everything, which is what
resolveAndLockAll does. It declares itself incompatible with the configuration cache because it filters and resolves
configurations at execution time; that is why this one command ran without --configuration-cache.
The second detail: with --write-locks, Gradle ignores existing lock state and writes what the current resolution produced, and
it writes only for the configurations that were resolved in that build. After ./gradlew compileJava --write-locks the lock file
of the engine listed only compileClasspath and annotationProcessor; the runtime and test configurations had no entry
(and so, in the default mode, nothing locked them) until a build resolved them.
What a lock does during resolution
Gradle turns each locked entry into a strictly version constraint, which is the strongest of the rich version declarations
(see Platforms, Constraints & Rich Versions).
The dependency report shows them, marked (c) for constraint:
compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
+--- org.apache.commons:commons-csv:1.+ -> 1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
+--- org.apache.commons:commons-csv:{strictly 1.14.1} -> 1.14.1 (c)
+--- commons-io:commons-io:{strictly 2.20.0} -> 2.20.0 (c)
\--- commons-codec:commons-codec:{strictly 1.19.0} -> 1.19.0 (c)
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
and dependencyInsight gives the reason in words:
org.apache.commons:commons-csv:1.14.1
Variant compile:
| Attribute Name | Provided | Requested |
|--------------------------------|----------|--------------|
| org.gradle.status | release | |
| org.gradle.category | library | library |
| org.gradle.libraryelements | jar | classes |
| org.gradle.usage | java-api | java-api |
| org.gradle.dependency.bundling | | external |
| org.gradle.jvm.environment | | standard-jvm |
| org.gradle.jvm.version | | 21 |
Selection reasons:
- By constraint: Dependency version enforced by Dependency Locking
(./gradlew :tidepool-engine:dependencyInsight --dependency commons-csv --configuration compileClasspath, first lines.) Because the lock
is only a constraint, the ordinary rules of version conflict resolution apply to it, and they explain every failure below.
For a transitive dependency the strict constraint pins the version, whatever the transitive path would have chosen. For a
direct dependency, the declared version is a requirement and the lock a strict constraint: if the requirement is lower than or
equal to the locked version (or a range or dynamic selector that the locked version satisfies), the locked version wins; if the
requirement is higher than the locked version, the two conflict and resolution fails.
When the lock and the build disagree
With lock state present, resolution is validated against it in the default mode. The three ways it can go wrong are shown
here, each with the real message. These runs are without --configuration-cache because, with it, Gradle reports the same
failure inside a configuration cache serialization problem (error writing value of type DefaultResolvableConfiguration) that
hides the message in a report.
A dependency is added and the lock was not updated. The engine gets a direct dependency on org.slf4j:slf4j-api:2.0.17
and nothing else changes:
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
> Resolved 'org.slf4j:slf4j-api:2.0.17' which is not part of the dependency lock state
A dependency is removed and the lock still lists it:
> Task :tidepool-engine:compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
> Did not resolve 'org.slf4j:slf4j-api:2.0.17' which is part of the dependency lock state
A direct dependency asks for more than the lock allows. The catalog was first [1.10,1.12), which locked Commons CSV
at 1.11.0, and was then changed to the fixed version 1.14.1:
* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
> Could not resolve org.apache.commons:commons-csv:1.14.1.
Required by:
project ':tidepool-engine'
> Cannot find a version of 'org.apache.commons:commons-csv' that satisfies the version constraints:
Dependency path: project ':tidepool-engine' (compileClasspath) --> 'org.apache.commons:commons-csv:1.14.1'
Constraint path: project ':tidepool-engine' (compileClasspath) --> 'org.apache.commons:commons-csv:{strictly 1.11.0}' because of the following reason: Dependency version enforced by Dependency Locking
> There is 1 more failure with an identical cause.
This last message is the one people find confusing, because nothing in the build file says strictly: the constraint comes from
the lock file, and its path line says so ("Dependency version enforced by Dependency Locking"). The reverse direction does not
fail: with the catalog at [1.10,1.12) and the lock at 1.11.0, changing it to the dynamic 1.+ left the report at
1.+ -> 1.11.0, because the locked version satisfies 1.+; the build kept 1.11.0 even though 1.14.1 existed, which is exactly
what locking is for.
In the default mode there are therefore only two ways to change what the build resolves: change the declarations and update the lock in the same commit, or update the lock on its own. The next sections show the second.
Updating the lock state
--write-locks rewrites everything. To move only some modules, --update-locks takes a comma-separated list of module
notations; the existing lock state is still used as input, except for the modules named, which are free to resolve again:
./gradlew resolveAndLockAll --update-locks org.apache.commons:commons-csv
The starting point was the lock at 1.11.0 (written when the catalog said [1.10,1.12)) and a catalog that had been widened to
1.+. The lock files afterwards:
commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor
The module that was named moved from 1.11.0 to 1.14.1, and so did its transitive dependencies commons-io (2.16.1 to 2.20.0)
and commons-codec (1.16.1 to 1.19.0), because the resolution rules choose their versions once the lock on the parent is
gone. picocli stayed at 4.7.7 in the same run. Wildcards are allowed in the group and the name, but only as the whole
value or at the end: org.apache.commons:*, *:guava and org.springframework.spring*:spring* are all valid notations. The
option is marked [incubating] in ./gradlew --help on 9.8.1; the behaviour may still change. The same caveat as for
--write-locks holds: only the configurations resolved in that build are updated.
This is also the whole renewal workflow of a team that wants fresh dependencies but reviewed ones: a scheduled job (or a bot)
runs --write-locks, and the pull request that changes gradle.lockfile is the review. The next section gives the third
option, loosening the rules.
Lock modes
The default mode, described so far, requires that existing entries are matched and that resolution adds nothing. Two other
modes are selected with lockMode:
| Mode | Behaviour |
|---|---|
|
Existing lock entries must be matched; a version mismatch, a missing module or an extra module fails the build. A locked configuration with no lock state is resolved normally (and not locked). |
|
The default rules, and in addition a locked configuration that has no lock state is an error. |
|
Dynamic versions are still pinned, but other changes are no longer errors: dependencies may be added or removed, and transitive dependencies may shift as long as dynamic versions stay pinned. Meant for trying nightly or snapshot builds without breaking the build. |
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
The default mode lets a configuration without a lock file through, which is the dangerous case: a new project or a new
configuration is simply not locked, and nobody notices. With STRICT, deleting the lock file of tidepool-cli and building gives
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-cli:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all dependencies for configuration ':tidepool-cli:compileClasspath'.
> Locking strict mode: Configuration ':tidepool-cli:compileClasspath' is locked but does not have lock state.
Use STRICT in CI and for projects that must be reproducible; use LENIENT in a throwaway branch or a job that deliberately
tests against newer versions. In LENIENT mode the same dependency added to the engine, with a dynamic version
org.slf4j:slf4j-api:2.0.+ that has no lock entry, built without errors, and the report resolved it freely to 2.0.20 (the
newest 2.0.x on the day) while the locked modules stayed at their locked versions:
compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
+--- org.apache.commons:commons-csv:1.+ -> 1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
+--- org.slf4j:slf4j-api:2.0.+ -> 2.0.20
+--- org.apache.commons:commons-csv:1.14.1 (c)
+--- commons-io:commons-io:2.20.0 (c)
\--- commons-codec:commons-codec:1.19.0 (c)
Note that in this mode the locked entries are no longer strictly (they print as plain 1.14.1 (c)), which is how a dependency
can shift.
Ignored dependencies and the location of the lock file
Some dependencies are meant to move: an organisation’s own libraries, for example, which should always be the newest. The
extension can exclude them from the lock state (they still resolve, but are neither recorded nor validated); the wildcard
rules are the same as for --update-locks. The lock file location can also be changed, which is useful when one project has
several variants of its dependencies (the manual’s example is the Scala binary version in the artifact coordinates, with a
lock file per version):
allprojects {
dependencyLocking {
lockAllConfigurations()
ignoredDependencies.add("org.slf4j:*")
lockFile = layout.projectDirectory.file("gradle/locks/${project.name}.lockfile")
}
}
allprojects {
dependencyLocking {
lockAllConfigurations()
ignoredDependencies.add('org.slf4j:*')
lockFile = layout.projectDirectory.file("gradle/locks/${project.name}.lockfile")
}
}
With slf4j-api as a dependency of the engine, resolveAndLockAll --write-locks wrote
tidepool-engine/gradle/locks/tidepool-engine.lockfile (and the same for the other two projects), and that file listed the three
Commons modules but no slf4j-api; the build then passed without any entry for it. (The first gradle.lockfile files from the
default location were still on disk from the earlier runs and were simply no longer used.) Ignoring dependencies defeats
reproducibility for them by design; the manual suggests separate lock modes or separate lock files instead for anything that
matters.
The build script and the settings
The plugins that a build applies are dependencies too, and lockAllConfigurations() does not cover the configuration that holds
them. For the buildscript classpath, activate locking there explicitly:
// build.gradle.kts
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.apache.commons:commons-lang3:3.+")
}
configurations.classpath {
resolutionStrategy.activateDependencyLocking()
}
}
tasks.register("hello") {
doLast { println("hello") }
}
// build.gradle
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.apache.commons:commons-lang3:3.+'
}
configurations.classpath {
resolutionStrategy.activateDependencyLocking()
}
}
tasks.register('hello') {
doLast { println 'hello' }
}
(A small separate project, so that it does not disturb the tidepool lock files; a real build would apply a plugin from the
Plugin Portal in the same way, see
Plugins Overview.) ./gradlew hello --write-locks
wrote buildscript-gradle.lockfile, in both DSLs:
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
org.apache.commons:commons-lang3:3.21.0=classpath
empty=
The third file is settings-gradle.lockfile, already seen above: it locks the dependencies of the settings (the plugins
applied in settings.gradle(.kts), and the catalogs). Locking settings dependencies arrived in Gradle 6.8.
Limits and the classic mistakes
-
Do not lock changing versions. Locking is for dynamic versions and ranges, where coordinates identify content. A
-SNAPSHOTkeeps its coordinates while the content changes, so a lock gives a false sense of reproducibility; the manual says Gradle emits a warning when it persists lock state with changing dependencies present (that warning was not reproduced here). Verification skips snapshots for the same reason, see below. -
Source dependencies are not locked: the manual lists dependency locking as not applicable to source dependencies.
-
A forgotten
--write-locks. The build file is changed, the lock is not, and the failure of the previous section appears on the next machine. Commit the lock file in the same commit as the dependency change; a CI step that runs the lock-writing task and fails on a dirty working tree detects drift. -
Locking without verification. The lock says "commons-csv 1.14.1", not "these bytes". Use both, see the next section.
-
Hand edits. The header says they can break the build; the only edits that are safe are the ones Gradle makes.
-
Disabling. To stop locking a configuration, remove the activation and run the build once with
--write-locks: Gradle drops the stale lock state of every configuration that it resolves and finds no longer locked.
Equivalents in Ant and Maven
Locking has no built-in equivalent in either of the other tools. Ivy has fixed revisions and the deliver task that writes a
concrete ivy file for publication (see
Ivy: Conflicts & Versions); Maven’s answer is not to use ranges
and to pin versions in the POM (see
Maven Versions, SNAPSHOTs & Ranges), with the
enforcer rules of
Maven Dependency Analysis & Convergence
to police it. The comparison table is in
Dependency Management Fundamentals.
Dependency verification
Dependency verification checks every artifact that Gradle resolves against checksums and signatures that you keep in
gradle/verification-metadata.xml. It protects against the three things that locking cannot: a repository that serves different
bytes than before, a man in the middle, and a cache that was tampered with. Gradle has had it since 6.2 (February 2020).
Two mechanisms: checksums and signatures
A checksum (SHA-256, say) fingerprints the content: it proves the file is the one whose hash you recorded, and says nothing
about who produced it. A signature (a PGP .asc file published next to the artifact) proves who signed it, provided you
trust the key; the manual points out that signatures alone may rest on weak hashes, so the strongest setup uses both. Gradle
supports MD5, SHA-1, SHA-256 and SHA-512 checksums, of which only SHA-256 and SHA-512 are considered secure today. Signatures are
verified only if they are ASCII-armoured .asc files on a remote repository.
Gradle verifies everything its dependency management engine downloads: jars and other artifacts, metadata files (POM, Ivy
descriptors and Gradle Module Metadata), plugins (project and settings plugins) and artifacts resolved through advanced
resolution APIs. It does not verify changing dependencies (SNAPSHOT versions) or artifacts the build itself produces, because
their checksums change constantly. The file is global for the build: one file serves the root project, all subprojects and
buildSrc; for a composite build, the file of the current build is used and those of the included builds are ignored.
Bootstrapping with checksums
Verification is enabled the moment gradle/verification-metadata.xml exists. An empty file would make every build fail, so the
file is generated:
./gradlew --write-verification-metadata sha256 build
The flag takes a comma-separated list of algorithms (and pgp), resolves every resolvable configuration of the build, downloads
the artifacts and computes the checksums. Run with a Gradle user home that had never seen these dependencies, it wrote this
file (the schema attribute of the root element and the middle components are shortened):
<?xml version="1.0" encoding="UTF-8"?>
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" ...>
<configuration>
<verify-metadata>true</verify-metadata>
<verify-signatures>false</verify-signatures>
</configuration>
<components>
<component group="commons-codec" name="commons-codec" version="1.19.0">
<artifact name="commons-codec-1.19.0.jar">
<sha256 value="5c3881e4f556855e9c532927ee0c9dfde94cc66760d5805c031a59887070af5f" origin="Generated by Gradle"/>
</artifact>
<artifact name="commons-codec-1.19.0.pom">
<sha256 value="e0f3269fa23de0c83130c5659f5f9514cc5422c0bcdf45f2eae004a78b9fca34" origin="Generated by Gradle"/>
</artifact>
</component>
...
<component group="org.apache.commons" name="commons-csv" version="1.14.1">
<artifact name="commons-csv-1.14.1.jar">
<sha256 value="32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe" origin="Generated by Gradle"/>
</artifact>
<artifact name="commons-csv-1.14.1.pom">
<sha256 value="95df735302c0ab92cec05a2b51e2270758e15f0a64186de65662eb148ebd97fd" origin="Generated by Gradle"/>
</artifact>
</component>
...
<component group="org.junit" name="junit-bom" version="5.13.1">
<artifact name="junit-bom-5.13.1.module">
<sha256 value="33c07ab9724790a6e5859ba07d69117ac530439724545a81c4179e3272c75de8" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-bom-5.13.1.pom">
<sha256 value="fa68451ea830572ed43ffe51d75b6a05f7a5e665a602a51f49d6be02063a65f3" origin="Generated by Gradle"/>
</artifact>
</component>
</components>
</verification-metadata>
There are eight components for three declared dependencies: Commons CSV with its two transitive modules, picocli, and the
parents and BOMs that appear only because their POMs are part of the metadata (org.apache:apache, commons-parent,
junit-bom). Because metadata verification is on, every .pom and .module file has its own checksum; with
<verify-metadata>false</verify-metadata> the file is much shorter but compromised metadata could add malicious transitive
dependencies, which is why the manual advises against it. The origin="Generated by Gradle" marker means "recorded, not
reviewed": bootstrapping trusts whatever the repositories serve right now, so the file must be reviewed, ideally against
checksums published elsewhere than in the repository (the project’s own website), and the attribute changed to
origin="Verified" or a note on where the value came from.
Bootstrap with a clean cache. The first bootstrap of this page ran against the shared Gradle user home, which already held these
dependencies, and wrote seven components: the junit-bom-5.13.1.module entry above was missing. A later build with a clean
Gradle user home then failed with One artifact failed verification: junit-bom-5.13.1.module, because that file had never been
verified before. Gradle documents that artifacts that are only resolved at execution time or through custom resolution code can
be missed; this was a different cause (the warm cache), and the practical rule is the same: generate the file with a fresh
GRADLE_USER_HOME (or --refresh-dependencies), and prove it by building once more with another fresh one.
Bootstrapping with signatures
Most Maven Central artifacts are signed, so the file can say "trust this key" instead of "trust these bytes":
./gradlew --write-verification-metadata sha256,pgp --export-keys build
Gradle downloads the signature file of each artifact, fetches the public key from a key server, verifies the signature, and writes the key as trusted. The algorithm list must still contain a checksum, because not every artifact is signed: those get the fallback checksum. With a clean Gradle user home, the run wrote this:
<?xml version="1.0" encoding="UTF-8"?>
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" ...>
<configuration>
<verify-metadata>true</verify-metadata>
<verify-signatures>true</verify-signatures>
<ignored-keys>
<ignored-key id="9EB80E92EB2135B1" reason="Key couldn't be downloaded from any key server"/>
</ignored-keys>
<trusted-keys>
<trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
<trusting group="commons-codec"/>
<trusting group="commons-io"/>
<trusting group="org.apache.commons"/>
</trusted-key>
<trusted-key id="AA417737BD805456DB3CBDDE6601E5C08DCCBB96" group="info.picocli" name="picocli" version="4.7.7"/>
<trusted-key id="FF6E2C001948C5F2F38B0CC385911F425EC61B51" group="org.junit" name="junit-bom" version="5.13.1"/>
</trusted-keys>
</configuration>
<components>
<component group="org.apache" name="apache" version="35">
<artifact name="apache-35.pom">
<sha256 value="ea297dcd114136e8b8e8b630230d52a76c2fc69f6c5db25d672b1857000728b8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
</components>
</verification-metadata>
Read it from the top. Eight components shrank to one, because everything signed by a key that is now trusted needs no entry of
its own. The Apache Commons key is trusted for three groups together, grouped under one trusted-key with nested trusting
elements. Picocli’s and the JUnit BOM’s keys were written as trusted only for exactly that module and version. The Apache parent POM was signed with a key that no key server had (it was recorded as an
ignored key), so the artifact fell back to a checksum, with a reason saying why. Both the ignored key and the fallback
checksum are things to review by hand: ideally find the key and add it to the keyring (see below). Gradle takes an optimistic
view while bootstrapping, which is why the documentation recommends checksums first if integrity matters, then signatures.
The key identifiers are full 40-character fingerprints; Gradle requires full fingerprints for pgp and trusted-key entries,
not short or long key IDs, to make collision attacks harder (ignored keys may use long IDs, as above). Never trust a key because
of the name in its user ID: anyone can create a key with any name, and the right check is that the fingerprint is listed on the
project’s own site (Apache projects publish a KEYS file).
The file the flag wrote with --export-keys is the local keyring, below. The elements of <configuration> are:
| Element | Meaning |
|---|---|
|
Verify POMs, Ivy files and Gradle Module Metadata too. Default behaviour of generated files: |
|
Check PGP signatures where they exist. |
|
Keys trusted for a group, module, version or file (attributes |
|
Artifacts that are not verified at all ( |
|
Keys that are never used for verification (for example, unavailable ones); an artifact whose keys are all ignored needs a checksum. |
|
Key servers to use instead of the defaults, or |
|
|
Key servers, keyrings and the offline case
Fetching keys from key servers on every clean checkout is slow and makes CI depend on someone else’s availability. The answer
is a keyring in the repository. --export-keys writes the public keys used for verification to two files in gradle/:
gradle/verification-keyring.gpg
gradle/verification-keyring.keys
The .gpg file is binary (compact and editable with gpg), the .keys file is ASCII-armoured (readable in a pull request,
mergeable by hand). Commit one: without a keyring-format element Gradle reads whichever exists, and the binary one takes
precedence when both are present; with <keyring-format>armored</keyring-format> (or binary) only that format is read and
written (since 8.5). Since Gradle 8.0 ./gradlew --export-keys alone exports the keys of an already trusted set, without
writing the metadata; and it only stores public key packets and a single user ID per key. A binary keyring should be marked in
.gitattributes (*.gpg binary). A key that Gradle cannot download but that you can find can be appended to the
.keys file or imported into the .gpg one with gpg.
To make a build refuse the network completely for keys, disable the key servers:
<configuration>
<key-servers enabled="false"/>
</configuration>
With that in the metadata and the exported .keys file in gradle/, a build in a clean Gradle user home passed (14 s,
because it downloaded the dependencies). Without the keyring file, the same build failed with 9 artifacts failed
verification for the Commons modules (the list starts with commons-codec-1.19.0.jar): the keys were needed and could not be
fetched. When a flag ignores enabled="false": if you ask Gradle to generate metadata and the existing file disables key
servers, the setting is ignored so that missing keys can be downloaded.
Gradle caches keys it could not find for 24 hours; ./gradlew build --refresh-keys retries immediately. --refresh-keys
was accepted and the build passed (nothing needed refreshing, so nothing visible changed).
The build script and the plugins are verified too
Plugins are dependencies, so they are covered by the same file. The buildscript classpath project of the locking section (with
commons-lang3) was bootstrapped with ./gradlew --write-verification-metadata sha256 hello and listed five components:
org.apache:apache, commons-lang3, commons-parent, junit-bom and mockito-bom, the last two being BOMs imported by the
parent POMs. Changing one checksum of the jar to zeros, and running the build, failed during configuration of the project, before any
task:
FAILURE: Build failed with an exception.
* What went wrong:
A problem occurred configuring root project 'buildscript-locking'.
> Dependency verification failed for configuration 'classpath'
One artifact failed verification: commons-lang3-3.21.0.jar (org.apache.commons:commons-lang3:3.21.0) from repository MavenRepo
This can indicate that a dependency has been compromised. Please carefully verify the checksums.
Open this report for more details: file:///work/buildscript-locking/build/reports/dependency-verification/at-1792177244105/dependency-verification-report.html
That is the point of verifying plugins: a build file is code (see Supply-Chain Security), and a plugin artifact runs with the privileges of the build.
Reading a verification failure
Gradle’s failure output has two layers: a summary on the console and an HTML report, linked in the message, with the full
detail. The summary of a changed checksum on the tidepool engine (the metadata had the SHA-256 of commons-csv-1.14.1.jar
replaced by zeros) is:
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
One artifact failed verification: commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
This can indicate that a dependency has been compromised. Please carefully verify the checksums.
Open this report for more details: file:///work/tidepool/build/reports/dependency-verification/at-1792176397510/dependency-verification-report.html
That is short, and in CI you cannot open a file in the workspace of a finished job. The property
org.gradle.dependency.verification.console=verbose (in gradle.properties) prints the full text, including the expected
and the actual value, which is what you need to decide whether this is a typo, a different binary in another repository or an attack:
> Dependency verification failed for configuration ':tidepool-engine:compileClasspath':
- On artifact commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) in repository 'MavenRepo': expected a 'sha256' checksum of '0000000000000000000000000000000000000000000000000000000000000000' but was '32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe'
This can indicate that a dependency has been compromised. Please carefully verify the checksums.
The other failures that matter, all produced here:
Untrusted signature. The trusted-key of picocli was removed from the metadata:
> Dependency verification failed for configuration ':tidepool-cli:compileClasspath':
- On artifact picocli-4.7.7.jar (info.picocli:picocli:4.7.7) in repository 'MavenRepo': Artifact was signed with key 'AA417737BD805456DB3CBDDE6601E5C08DCCBB96' (...) and passed verification but the key isn't in your trusted keys list.
- On artifact picocli-4.7.7.pom (info.picocli:picocli:4.7.7) in repository 'MavenRepo': Artifact was signed with key 'AA417737BD805456DB3CBDDE6601E5C08DCCBB96' (...) and passed verification but the key isn't in your trusted keys list.
(The name and e-mail address in the key’s user ID are replaced by (...).) Note the meaning: the signature is valid; Gradle
simply does not know whether you trust the signer. Missing metadata. A new direct dependency was added to the engine without
updating the file:
> Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
4 artifacts failed verification:
- slf4j-api-2.0.17.jar (org.slf4j:slf4j-api:2.0.17) from repository MavenRepo
- slf4j-api-2.0.17.pom (org.slf4j:slf4j-api:2.0.17) from repository MavenRepo
- slf4j-bom-2.0.17.pom (org.slf4j:slf4j-bom:2.0.17) from repository MavenRepo
- slf4j-parent-2.0.17.pom (org.slf4j:slf4j-parent:2.0.17) from repository MavenRepo
If the artifacts are trustworthy, you will need to update the gradle/verification-metadata.xml file. For more on how to do this, please refer to https://docs.gradle.org/9.8.1/userguide/dependency_verification.html#sec:troubleshooting-verification in the Gradle documentation.
The advice in that message points to the anchor sec:troubleshooting-verification, which does not exist on the 9.8.1 page;
the section on failures is sec:dealing-verification-failure (the same slip is noted on
Supply-Chain Security). The same message shows why one new
dependency means four artifacts: the jar, its POM, and the BOM and parent POM that the POM imports.
What to do depends on the failure. A missing entry is fixed by bootstrapping again (incremental, below). An untrusted key: check
the fingerprint against the project’s website, then add the key. A checksum mismatch is the serious one: the options are a
damaged or tampered file in the local cache (delete it and let Gradle download again), the same artifact published slightly
differently to two repositories (accept both with also-trust, below), or a compromised artifact (inform the maintainers and the
repository). Watch also for name squatting and repository shadowing, where look-alike coordinates, or the official ones in a repository
that comes first, give the wrong file; content filtering (see
Repositories & Content Filtering) and verification
together close that gap. The HTML report explains each failure kind (checksum mismatch, key not trusted, signature did not match,
key ignored, key not found, checksum missing, deleted artifact) and what to do about it.
Since Gradle 9.7, the console and the HTML report add a note when a signing key cannot be found on any key server: they report how many other keys are already trusted for that module or group. A non-zero count suggests that the key was rotated, a zero count that you are trusting something for the first time (not run here; described in the 9.7.0 release notes).
Verification modes
Verification is expensive for some workflows (frequent dependency upgrades, for example) and not for others (CI). There are three modes:
| Mode | Behaviour |
|---|---|
|
Fails as early as possible, so no compromised dependency is used. |
|
Prints the failures but lets the build continue. |
|
No verification. |
They are chosen with --dependency-verification (-F), the system property org.gradle.dependency.verification, or in
gradle.properties. With the tampered checksum from above, --dependency-verification lenient printed the verification failure
as plain text and the build ended in BUILD SUCCESSFUL:
> Task :tidepool-engine:compileJava
Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
One artifact failed verification: commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
This can indicate that a dependency has been compromised. Please carefully verify the checksums.
and -F off printed nothing and ran the build normally. A sensible policy: strict everywhere that matters, lenient on a
developer’s machine while upgrading, never off in CI. (To turn off verification for one configuration, for example one that a plugin uses to look
up newer versions, call resolutionStrategy.disableDependencyVerification() on it; Gradle prints a warning when it is used.)
The related <trusted-artifacts> entries below do the same job declaratively.
Updating the metadata
When a dependency changes, the build fails (as above) and the file needs more entries. Re-running the bootstrap adds them and keeps everything else, including hand-written entries, comments and trusted keys:
./gradlew --write-verification-metadata sha256,pgp compileJava
With the slf4j dependency added, the only change to the signature-based file was one line: the key of the SLF4J group was trusted.
<trusted-key id="60200AC4AE761F1614D6C46766D68DAA073BE985" group="org.slf4j"/>
and the build then passed. Always use the same list of algorithms as the first time. Gradle never deletes entries (it cannot know
that a version is no longer used), so the file only grows; review the diff in every pull request, remove entries for old versions
and change the origin="Generated by Gradle" markers.
Dry-run mode. To see what bootstrapping would write without touching the file, combine the flag with --dry-run:
./gradlew --write-verification-metadata sha256 --dry-run compileJava
:tidepool-engine:compileJava SKIPPED
:tidepool-cli:compileJava SKIPPED
BUILD SUCCESSFUL in 349ms
(Tasks are not executed, so it is fast, but it misses dependencies that are resolved at execution time.) The proposal went to
gradle/verification-metadata.dryrun.xml. With the checksum of Commons CSV still wrong in the real file, the difference between the
two was a new also-trust element:
< <sha256 value="0000000000000000000000000000000000000000000000000000000000000000" origin="Generated by Gradle"/>
---
> <sha256 value="0000000000000000000000000000000000000000000000000000000000000000" origin="Generated by Gradle">
> <also-trust value="32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe"/>
> </sha256>
That is the second use of dry runs: Gradle’s bootstrapping accepts whatever it sees, including a changed checksum, by adding
the new value as an alternative. A reviewer would stop here and ask why the artifact changed. The manual also suggests dry-run
as a way to clean up the file: move the old file away, run with --dry-run and compare, remembering that both modes are
incremental and copy existing keys.
Source: Gradle User Manual: Dry-run mode
Writing and tuning the file by hand
You can write entries yourself, and for checksums taken from the project’s website you should. The attributes origin and reason
are free text that Gradle preserves but never evaluates; since 9.7 they also exist on pgp entries and trusted-key elements, and
the files Gradle 9.7 and later writes use the schema dependency-verification-1.4.xsd (older files are read unchanged). The
constructs not used in the generated files above:
<configuration>
<verify-metadata>true</verify-metadata>
<verify-signatures>true</verify-signatures>
<key-servers>
<key-server uri="https://keyserver.ubuntu.com"/>
</key-servers>
<trusted-artifacts>
<trust group="org.junit" name="junit-bom"/>
<trust file=".*-sources[.]jar" regex="true" reason="IDE downloads"/>
</trusted-artifacts>
<trusted-keys>
<trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
<trusting group="commons-codec"/>
<trusting group="commons-io"/>
<trusting group="org.apache.commons"/>
</trusted-key>
</trusted-keys>
</configuration>
trusted-artifacts means no verification at all for the matching artifacts: use it for artifacts produced inside your
organisation, for sources and javadoc jars that an IDE downloads (otherwise importing a project fails), and nothing else. The
fragment above ran: with the org.junit:junit-bom trust element in place the build passed with no component entry for the BOM,
and with that line removed it failed with One artifact failed verification: junit-bom-5.13.1.pom. For one artifact, the
two individual forms:
<component group="org.apache.pdfbox" name="pdfbox" version="2.0.17">
<artifact name="pdfbox-2.0.17.jar">
<sha512 value="7e11e54a21c395d461e59552e88b0de0ebaf1bf9d9bcacadf17b240d9bbc29bf6beb8e36896c186fe405d287f5d517b02c89381aa0fcc5e0aa5814e44f0ab331" origin="PDFBox official site"/>
</artifact>
</component>
<component group="org.apache.pdfbox" name="pdfbox" version="2.0.17">
<artifact name="pdfbox-2.0.17.pom">
<sha256 value="a2023504cfd611332177f96358b6f6db26e43d96e8ef4cff59b0f5a2bee3c1e1">
<also-trust value="ff513db0361fd41237bef4784968bc15aae478d4ec0a9496f811072ccaf3841d"/>
</sha256>
</artifact>
</component>
(Two excerpts shown together; the values come from the Gradle manual’s examples, they are illustrative and were not used in a build.)
Several checksums for one artifact are all checked, so a second weaker one adds a little assurance when a site publishes only
MD5 or SHA-1. also-trust accepts a second legitimate checksum, which happens when the same artifact is published to two
repositories by two builds. An ignored-key can be given at artifact level too, when an artifact’s signature is known to be wrong
but its checksum has been checked by hand (a checksum must be given too, otherwise all of the artifact’s keys are ignored and
Gradle still fails).
Verification and the configuration cache
The configuration cache has supported dependency verification since Gradle 8.1: changes to the verification file and to the keyring
files invalidate the cache entry. In the 9.8.1 runs here, the verification-enabled builds ran with --configuration-cache without
problems. When a verification error occurs while Gradle is writing a configuration cache entry (because the classpath of a task is
serialized), it comes wrapped in a "configuration cache state could not be cached" failure whose cause is the verification message;
the same applies to lock errors. Look for the Dependency verification failed line in the stack of causes, or rerun with
--no-configuration-cache.
The dependency cache
When Gradle resolves a module for the first time it queries a repository, downloads the descriptor and the artifacts, and stores them below the Gradle user home. Everything later in this section is about when Gradle uses that store and when it asks the repository again.
What is in the cache
The dependency cache lives in $GRADLE_USER_HOME/caches (by default ~/.gradle/caches). Run with an empty Gradle user home to
see it:
$ ls $GRADLE_USER_HOME/caches
9.8.1
CACHEDIR.TAG
jars-9
journal-1
modules-2
$ ls $GRADLE_USER_HOME/caches/modules-2
files-2.1
metadata-2.107
modules-2.lock
resources-2.1
The directory modules-2 is the module cache; 9.8.1 and jars-9 belong to the running Gradle version (generated build
logic, compiled build scripts). Inside the module cache, files-2.1 is the file store: downloaded artifacts and raw
metadata files, kept under the SHA-1 of their content. The listing for one cached module:
modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/60344e7d7b3a95a867634b435253215a877968ee/demo-lib-1.0.0.jar
modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/64e0e2d2b386cadc83f53a97c4cf1b55762077c5/demo-lib-1.0.0.pom
modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/a132021bc68cb3606aba144291c12094648a315/demo-lib-1.0.0.module
(demo-lib is the small library used in the demonstrations below.) The directory names are the SHA-1 of the files, which was
checked here with shasum -a 1 for the jar and the POM. Storing by checksum means two repositories that serve different
files for the same coordinates (a re-published release, or a SNAPSHOT) never overwrite each other, and the same bytes
are stored once. metadata-2.107 is a set of binary files (module-metadata.bin, module-versions.bin,
module-artifact.bin, resource-at-url.bin and the descriptors directory) that record the results of resolution: which
concrete version a dynamic version resolved to, the parsed metadata of each module, the pointer from each artifact to its file, and
the absence of a module or artifact in a repository, so that a missing resource is not asked for again and again. Each entry
records the repository it came from and a timestamp, which is what the expiry rules below use.
Two consequences matter in practice. First, the metadata is stored per repository (identified by URL, type and layout), and an
artifact that was resolved from one repository stays tied to it: Gradle will not silently resolve it from another, and a build
that works on one machine can fail on another where the repository differs. Second, the format version in the name, metadata-2.107,
changes with the Gradle version: that value is used by Gradle 8.11 and later (checked here on 9.8.1), metadata-2.106 by 8.2
to 8.10.2, metadata-2.105 by 8.1 and metadata-2.100 by 8.0 (Gradle 7.x and older used other numbers). The table matters when you
seed a cache for another Gradle version, see the CI section.
Gradle also tries to reuse artifacts from the local Maven repository (~/.m2/repository), but only if the file matches the
checksum published by the remote repository; mavenLocal() as a declared repository is a different thing and is discouraged,
see Repositories & Content Filtering.
The cache is also safe for concurrent builds: access to the binary store is guarded by file locks (modules-2.lock), released
during slow operations such as downloads. That works across Gradle processes that can see each other’s locks; it does not work for
containers that share the directory through different mounts.
Unused entries are cleaned up: Gradle records which files were accessed, scans the cache at most once every 24 hours and removes what has not been used for more than 30 days. The retention can be configured with an init script in the Gradle user home (see Directories & Logging and Init Scripts & the Tooling API):
// $GRADLE_USER_HOME/init.d/cache-retention.init.gradle.kts
beforeSettings {
caches {
downloadedResources.setRemoveUnusedEntriesAfterDays(45)
cleanup = Cleanup.DEFAULT
}
}
// $GRADLE_USER_HOME/init.d/cache-retention.init.gradle
beforeSettings { settings ->
settings.caches {
downloadedResources.setRemoveUnusedEntriesAfterDays(45)
cleanup = Cleanup.DEFAULT
}
}
Both scripts were accepted by Gradle 9.8.1 (./gradlew help --task build ran; the cleanup itself takes 24 hours of cache age and
was not observed). In Groovy, the closure of beforeSettings receives the settings object, so the configuration goes through
settings.caches; written like the Kotlin version, it failed with Could not find method caches() on the build.
A repository to experiment with
To watch caching behaviour you need a repository you control. The examples use a tiny library, io.tidepool:demo-lib, published
with the maven-publish plugin to a directory, and served from localhost:
// producer/build.gradle.kts
plugins {
`java-library`
`maven-publish`
}
group = "io.tidepool"
version = providers.gradleProperty("libVersion").get()
publishing {
publications {
register<MavenPublication>("maven") {
from(components["java"])
}
}
repositories {
maven {
name = "local"
url = uri(layout.projectDirectory.dir("../repo"))
}
}
}
// producer/build.gradle
plugins {
id 'java-library'
id 'maven-publish'
}
group = 'io.tidepool'
version = providers.gradleProperty('libVersion').get()
publishing {
publications {
maven(MavenPublication) {
from components.java
}
}
repositories {
maven {
name = 'local'
url = layout.projectDirectory.dir('../repo')
}
}
}
./gradlew publish -PlibVersion=1.0.0 writes io/tidepool/demo-lib/1.0.0/ (jar, POM, Gradle Module Metadata and checksums) and a
maven-metadata.xml listing under repo/; publishing is the subject of
Publishing. The directory was served with
python3 -m http.server 8099 --bind 127.0.0.1 --directory repo, whose log lines are quoted below as plain request paths. The
consumer declares the repository over HTTP (hence the explicit opt-in, since plain HTTP is refused by default) and has a task
that prints the jar it resolved. Both DSLs were run:
// consumer/settings.gradle.kts
rootProject.name = "consumer"
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
maven {
url = uri("http://localhost:8099/")
isAllowInsecureProtocol = true
}
}
}
// consumer/build.gradle.kts
plugins {
`java-library`
}
dependencies {
implementation("io.tidepool:demo-lib:1.+")
}
tasks.register("showVersion") {
val names = configurations.runtimeClasspath.flatMap { c ->
c.incoming.artifacts.resolvedArtifacts.map { arts -> arts.map { it.file.name } }
}
doLast { println("resolved: " + names.get()) }
}
// consumer/settings.gradle
rootProject.name = 'consumer'
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
maven {
url = uri('http://localhost:8099/')
allowInsecureProtocol = true
}
}
}
// consumer/build.gradle
plugins {
id 'java-library'
}
dependencies {
implementation 'io.tidepool:demo-lib:1.+'
}
tasks.register('showVersion') {
def names = configurations.named('runtimeClasspath').flatMap { c ->
c.incoming.artifacts.resolvedArtifacts.map { arts -> arts.collect { it.file.name } }
}
doLast { println 'resolved: ' + names.get() }
}
The task reads the resolved artifacts through providers, which keeps it compatible with the configuration cache; the same task in
the first Kotlin version captured a top-level val and the configuration cache refused it (cannot serialize Gradle script object
references), which is why the val lives inside the task registration. In the Groovy build the configuration must be looked up
with configurations.named('runtimeClasspath') to get a provider to flatMap: configurations.runtimeClasspath is the
configuration itself there, and calling flatMap on it failed with No signature of method … flatMap() is applicable. The first
run, with a Gradle user home that had never seen the library:
./gradlew --configuration-cache showVersion
Calculating task graph as no cached configuration is available for tasks: showVersion
> Task :showVersion
resolved: [demo-lib-1.0.0.jar]
BUILD SUCCESSFUL in 9s
1 actionable task: 1 executed
Configuration cache entry stored.
and the repository saw four requests:
GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.pom
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.module
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.jar
First the version listing (to resolve 1.+), then the POM, the Gradle Module Metadata and the jar of the selected version. The
9s includes unpacking the wrapper distribution of the new Gradle user home.
These files are this page’s own setup for the examples, not an example taken from the manual.
Dynamic versions are cached for 24 hours
A new version, 1.1.0, was published to the repository. The same build, twice, with and without the configuration cache:
$ ./gradlew --configuration-cache showVersion
Reusing configuration cache.
resolved: [demo-lib-1.0.0.jar]
Configuration cache entry reused.
$ ./gradlew showVersion
resolved: [demo-lib-1.0.0.jar]
Both still resolved 1.0.0 although 1.1.0 existed. The first is the configuration cache doing its job (the resolution is part of
the cached task graph); the second shows the dependency cache: Gradle caches the resolution of a dynamic version, and
the resolution of a changing module (a SNAPSHOT, or a dependency declared with isChanging = true), for 24 hours by default, and
does not contact the repository during that time (the log of the server for the second run was empty). The knobs are on the
resolution strategy of a configuration, and take a time and a unit:
configurations.all {
resolutionStrategy {
cacheDynamicVersionsFor(10, "minutes")
cacheChangingModulesFor(4, "hours")
}
}
configurations.all {
resolutionStrategy {
cacheDynamicVersionsFor 10, 'minutes'
cacheChangingModulesFor 4, 'hours'
}
}
cacheDynamicVersionsFor controls how long the answer to "what is the newest 1.x?" is trusted; cacheChangingModulesFor controls how
long the metadata and artifacts of a changing module are. A short window means more requests to the repository on every build,
which costs time; a value of 0 means "ask every time". With both set to zero seconds in the consumer (the block above with
0, "seconds"), and 1.2.0 published, the next run resolved the new version at once (the build file had changed, so the
configuration cache entry was recalculated), with the requests
GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.pom
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.module
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.jar
and resolved: [demo-lib-1.2.0.jar]. A SNAPSHOT or a locked build never needs this: a lock removes the question, and a release
needs no refresh. Treat a short TTL as a development setting for the modules that change, not as a global default.
--refresh-dependencies
When the cache and the repositories disagree (a repository was misconfigured, a "release" was republished, a new dynamic
version is wanted now), --refresh-dependencies (-U) tells Gradle to ignore all cached entries for modules and artifacts for this
one build. Starting again from 1.0.0 cached with 1.1.0 published, the 24-hour window above holds, and:
./gradlew --configuration-cache --refresh-dependencies showVersion
Calculating task graph as configuration cache cannot be reused due to --refresh-dependencies
resolved: [demo-lib-1.1.0.jar]
Configuration cache entry stored.
GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.pom
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.module
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.jar
Two things in that output are worth noting. The option makes the configuration cache entry unusable (the message says so), because
the resolution has to run again. And the requests are for the new version only: the already cached 1.0.0 files were not asked
for again. The manual is explicit that --refresh-dependencies is widely misunderstood as "download everything again". Gradle
recalculates dynamic versions, invalidates listing caches, refreshes metadata and downloads artifacts, but it checks cached
ones with HEAD requests and compares published checksums, and downloads again only what differs. So refreshing is cheap when
nothing changed, and it has an effect only for dynamic or changing dependencies, or a repository that now serves different content.
If a SNAPSHOT or a dynamic dependency is not moving, the lasting fix is the TTL, not a habit of typing -U.
Offline mode
--offline tells Gradle to use the cache for everything, regardless of expiry, and never touch the network. With 1.3.0 published
(and the TTLs at zero, which would normally have forced a request) the build ran with the server still up:
$ ./gradlew --offline showVersion
> Task :showVersion
resolved: [demo-lib-1.2.0.jar]
and the server log stayed empty: it used the cached answer (1.2.0) instead of asking for 1.3.0. After stopping the server, a normal build fails with the connection error of every network failure (the part of the message that matters):
> Task :showVersion FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':showVersion' (registered in build file 'build.gradle.kts').
> Could not resolve all artifacts for configuration ':runtimeClasspath'.
> Could not resolve io.tidepool:demo-lib:1.+.
Required by:
root project 'consumer'
> Failed to list versions for io.tidepool:demo-lib.
> Unable to load Maven meta-data from http://localhost:8099/io/tidepool/demo-lib/maven-metadata.xml.
> Could not HEAD 'http://localhost:8099/io/tidepool/demo-lib/maven-metadata.xml'.
> Got socket exception during request. It might be caused by SSL misconfiguration
> Connect to localhost:8099 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused
(The "SSL misconfiguration" hint is generic; this repository is plain HTTP.) The same build with --offline printed
resolved: [demo-lib-1.2.0.jar] and BUILD SUCCESSFUL. And a module that was never cached cannot be found offline: with an
extra dependency io.tidepool:never-cached:2.1.0,
> Task :showVersion FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':showVersion' (registered in build file 'build.gradle.kts').
> Could not resolve all artifacts for configuration ':runtimeClasspath'.
> Could not resolve io.tidepool:never-cached:2.1.0.
Required by:
root project 'consumer'
> No cached version of io.tidepool:never-cached:2.1.0 available for offline mode.
Offline mode is the cheapest way to prove that a build’s dependencies are all in the cache, which is what a container with a
pre-filled cache needs. The same switch exists as a property (org.gradle.offline, see
Build Environment & Properties) and as a
command-line option in
Command-Line Interface. Offline mode with the dependency
verification of this page works as long as the metadata and keyring are complete: verification needs no network when the keys are
in the keyring.
Seeding and sharing the cache in CI
CI builds in ephemeral containers start with an empty Gradle user home, so each one downloads everything. The manual describes two remedies. They work because the module cache is stored with relative paths.
Copying a cache. Copy $GRADLE_USER_HOME/caches/modules-2 (not the *.lock and gc.properties files) into the same place of
another Gradle user home. The Gradle versions that created and consume the cache must have the same format numbers, otherwise Gradle
still contacts the repositories for what it lacks; the formats are:
| Module cache | File cache | Metadata cache | Gradle versions |
|---|---|---|---|
|
|
|
6.8 to 7.4 |
|
|
|
7.5 to 7.6.1 |
|
|
|
7.6.2 |
|
|
|
8.0 |
|
|
|
8.1 |
|
|
|
8.2 to 8.10.2 |
|
|
|
8.11 and above (9.8.1 verified here) |
A shared read-only cache. Instead of copying, mount one cache read-only into all containers and point the environment variable
GRADLE_RO_DEP_CACHE at the directory that contains modules-2. Gradle then looks in both the writable cache in its own user home
and the shared one without locking (so many builds may read it at the same time), takes what the shared cache has, and downloads and
writes only what is missing. The seeding build should produce the shared cache in a job of its own, copy it away (never use a live
Gradle user home, which may be locked or modified) and publish it as a volume. The feature is marked incubating in the manual. Demonstration:
a copy of the cache that contained demo-lib 1.1.0 (without lock and gc.properties files, made read-only with chmod -R a-w) as
/work/ro/modules-2, a consumer with a fixed version 1.1.0, a brand-new Gradle user home, and the server stopped:
$ GRADLE_RO_DEP_CACHE=/work/ro ./gradlew --configuration-cache showVersion
resolved: [demo-lib-1.1.0.jar]
BUILD SUCCESSFUL in 9s
The writable cache of that new home contained metadata-2.107 and modules-2.lock but no files-2.1 at all: the jar, POM and
module file were served from the shared cache. The control, an equally fresh Gradle user home without the variable, failed on
the first file it needed:
> Could not GET 'http://localhost:8099/io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.pom'.
> Connect to localhost:8099 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused
BUILD FAILED in 13s
The consumer in this demonstration used a fixed version, because a dynamic one would still ask the (stopped) repository for the
version listing. Combining the shared cache with dependency verification was not run here. In principle a seeded cache plus
--offline, a committed lock file and the verification file give a CI build that is repeatable and does not need the internet.
Putting the three together
A repeatable and defensible dependency setup for a Gradle build, in order of effort:
| What | How | Committed files |
|---|---|---|
Same versions everywhere |
|
|
Same bytes everywhere |
|
|
Fewer downloads |
Gradle user home cached between CI jobs, or |
Nothing (the cache is not committed) |
Fast failure when the network is gone |
|
Nothing |
Order of commands in a pull request that updates a dependency: change the catalog, run the lock-writing task, run the
verification bootstrap (with the same algorithms as before), read the diff of gradle.lockfile and
verification-metadata.xml, and commit all three. A CI check that runs the lock and verification tasks and fails if the working
tree changed catches a forgotten file before it reaches a colleague. Keep dependency resolution out of tasks if you can: the new
configuration-cache-safe APIs that arrived in 9.7 make resolving at execution time possible without breaking the cache (see
Configuration Cache & Isolated Projects).
Release history and Gradle 8.14
| Release | Change |
|---|---|
4.8 |
Dependency locking for dynamic versions and ranges, with one lock file per configuration (the format has since been replaced by the single |
6.2 (2020-02-17) |
Dependency verification (checksums and signatures) and the shared read-only dependency cache ( |
6.8 |
Locking of the dependencies of Gradle settings ( |
8.0 (2023-02-13) |
|
8.1 (2023-04-12) |
The configuration cache fully supports dependency verification and detects changes to the metadata and keyring files. |
8.5 |
|
9.7 (2026-08-06) |
|
The features were checked in the release notes cited in the references; the dates of all releases are in the history table of
Versions, Compatibility & Release History.
The formats and flags used in this page are the same in Gradle 8.14 for locking and for the cache; the verification file written by
8.14 uses an older schema version (the 8.5 notes show 1.3) and has no origin on keys. Differences of the 8.14 line from 9.8.1
for these features beyond what the release notes list were not checked, and none of this page was run on 8.14 (not run here).
The one visible source-level difference met here is in the Groovy DSL: configurations.findAll { ... } on a collection of
configurations works on 8.14 and is deprecated on 9.8.1 (use matching), scheduled for removal in Gradle 10.
Where the books are outdated
Gradle in Action (Gradle 1.7) covers the dependency cache and its commands (--offline, --refresh-dependencies) in its dependency
management chapter. Those two switches still exist with the same meaning (the second as --refresh-dependencies or -U). Everything
else on this page postdates the book: dependency locking arrived in Gradle 4.8 (2018) and dependency verification in 6.2 (2020),
so neither appears in the book, and Gradle 6.2 was also the release of the shared read-only cache. The layout of the cache has
changed since 1.7 as well: the directories are today modules-2/files-2.1 and metadata-2.107 (the number changes with Gradle versions),
so a path copied from the book will not exist. Treat anything the book says about the cache directories and their default timeouts as history, and use the current user manual;
resolutionStrategy.cacheDynamicVersionsFor and cacheChangingModulesFor themselves are current API, used in the caching section above.
Equivalents in Ant and Maven
| Need | Ant with Ivy | Maven | Gradle |
|---|---|---|---|
Pin dynamic versions |
Fixed revisions; |
No ranges; versions fixed in the POM |
Dependency locking ( |
Check downloaded bytes |
Checksum algorithms of Ivy resolvers; |
Repository checksums, and trusted checksums of Resolver (see Maven Resolver) |
|
Cache of artifacts |
Ivy cache ( |
Local repository ( |
|
Refresh |
|
|
|
Offline |
No switch; a property of the build file (see Ivy Overview) |
|
|
Shared cache for CI |
A shared cache directory (lock strategy) |
No equivalent; a mirror or repository manager near the build |
|
The other tools' pages are
Ivy: Settings & Resolvers (caches, lock strategies and
TTL),
Maven Resolver (trusted checksums and repository filtering),
Running Maven (CLI) (-o, -U) and
Maven Repositories, Mirrors & Repository Managers
(offline builds and cached failures); the tool-neutral treatment is in
Repositories & Publishing Across Tools and
Comparing Ant, Maven & Gradle. The next Gradle pages
continue with
Dependency Resolution, Conflicts & Capabilities
and the build cache, which is a different cache (task outputs, not
dependencies).
Common problems
| Symptom | Cause and fix |
|---|---|
Resolved '…' which is not part of the dependency lock state |
A dependency was added and the lock not updated. Run the lock-writing task with |
Did not resolve '…' which is part of the dependency lock state |
A dependency was removed and the lock still lists it. Same fix. |
Cannot find a version … {strictly …} … enforced by Dependency Locking |
A declared version is higher than the locked one. Update the lock. |
Locking strict mode: Configuration … is locked but does not have lock state |
|
|
Unqualified |
A configuration cache serialization error instead of the lock or verification message |
Gradle wraps resolution errors that occur while storing the classpath of a task. Look for the cause in the report, or rerun with |
Dependency verification failed after a bootstrap from a warm cache |
The shared Gradle user home had artifacts the bootstrap never saw. Re-bootstrap with a fresh |
IDE import fails verification |
Sources and javadoc jars are downloaded and verified too. Add |
CI cannot fetch keys |
Export the keyring with |
A SNAPSHOT or |
The 24-hour TTL. |
|
It re-checks; it does not force a download. If nothing changed in the repository, nothing is downloaded. |
The |
Use |