Use of dynamic dependency versions (e.g. 1.+ or [1.0,2.0)) makes builds non-deterministic. This causes builds to break without any obvious change, and worse, can be caused by a transitive dependency that the build author has no control over.

To achieve reproducible builds, it is necessary to lock versions of dependencies and transitive dependencies such that a build with the same inputs will always resolve the same module versions. This is called dependency locking.

It enables, amongst others, the following scenarios:

  • Companies dealing with multi repositories no longer need to rely on -SNAPSHOT or changing dependencies, which sometimes result in cascading failures when a dependency introduces a bug or incompatibility. Now dependencies can be declared against major or minor version range, enabling to test with the latest versions on CI while leveraging locking for stable developer builds.

  • Teams that want to always use the latest of their dependencies can use dynamic versions, locking their dependencies only for releases. The release tag will contain the lock states, allowing that build to be fully reproducible when bug fixes need to be developed.

Combined with publishing resolved versions, you can also replace the declared dynamic version part at publication time. Consumers will instead see the versions that your release resolved.

Locking is enabled per dependency configuration. Once enabled, you must create an initial lock state. It will cause Gradle to verify that resolution results do not change, resulting in the same selected dependencies even if newer versions are produced. Modifications to your build that would impact the resolved set of dependencies will cause it to fail. This makes sure that changes, either in published dependencies or build definitions, do not alter resolution without adapting the lock state.

Dependency locking makes sense only with dynamic versions. It will have no impact on changing versions (like -SNAPSHOT) whose coordinates remain the same, though the content may change. Gradle will even emit a warning when persisting lock state and changing dependencies are present in the resolution result.

Enabling locking on configurations

Locking of a configuration happens through the ResolutionStrategy:

Example 1. Locking a specific configuration
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
}
build.gradle.kts
configurations.compileClasspath {
    resolutionStrategy.activateDependencyLocking()
}

Or the following, as a way to lock all configurations:

Example 2. Locking all configurations
build.gradle
dependencyLocking {
    lockAllConfigurations()
}
build.gradle.kts
dependencyLocking {
    lockAllConfigurations()
}

Only configurations that can be resolved will have lock state attached to them. Applying locking on non resolvable-configurations is simply a no-op.

The above will lock all project configurations, but not the buildscript ones.

You can also disable locking on a specific configuration. This can be useful if a plugin configured locking on all configurations but you happen to add one that should not be locked.

Example 3. Unlocking a specific configuration
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.deactivateDependencyLocking()
    }
}
build.gradle.kts
configurations.compileClasspath {
    resolutionStrategy.deactivateDependencyLocking()
}

Locking buildscript classpath configuration

If you apply plugins to your build, you may want to leverage dependency locking there as well. In order to lock the classpath configuration used for script plugins, do the following:

Example 4. Locking buildscript classpath configuration
build.gradle
buildscript {
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}
build.gradle.kts
buildscript {
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}

Generating and updating dependency locks

In order to generate or update lock state, you specify the --write-locks command line argument in addition to the normal tasks that would trigger configurations to be resolved. This will cause the creation of lock state for each resolved configuration in that build execution. Note that if lock state existed previously, it is overwritten.

Lock all configurations in one build execution

When locking multiple configurations, you may want to lock them all at once, during a single build execution.

For this, you have two options:

  • Run gradle dependencies --write-locks. This will effectively lock all resolvable configurations that have locking enabled. Note that in a multi project setup, dependencies only is executed on one project, the root one in this case.

  • Declare a custom task that will resolve all configurations

Example 5. Resolving all configurations
build.gradle
task resolveAndLockAll {
    doFirst {
        assert gradle.startParameter.writeDependencyLocks
    }
    doLast {
        configurations.findAll {
            // Add any custom filtering on the configurations to be resolved
            it.canBeResolved
        }.each { it.resolve() }
    }
}
build.gradle.kts
tasks.register("resolveAndLockAll") {
    doFirst {
        require(gradle.startParameter.isWriteDependencyLocks)
    }
    doLast {
        configurations.filter {
            // Add any custom filtering on the configurations to be resolved
            it.isCanBeResolved
        }.forEach { it.resolve() }
    }
}

That second option, with proper choosing of configurations, can be the only option in the native world, where not all configurations can be resolved on a single platform.

Lock state location and format

Lock state will be preserved in a file located in the folder gradle/dependency-locks inside the project or subproject directory. Each file is named by the configuration it locks and has the lockfile extension. The one exception to this rule is for configurations for the buildscript itself. In that case the configuration name will be prefixed with buildscript-.

The content of the file is a module notation per line, with a header giving some context. Module notations are ordered alphabetically, to ease diffs.

gradle/dependency-locks/compileClasspath.lockfile
# 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.springframework:spring-beans:5.0.5.RELEASE
org.springframework:spring-core:5.0.5.RELEASE
org.springframework:spring-jcl:5.0.5.RELEASE

which matches the following dependency declaration:

Example 6. Dynamic dependency declaration
build.gradle
dependencies {
    implementation 'org.springframework:spring-beans:[5.0,6.0)'
}
build.gradle.kts
dependencies {
    implementation("org.springframework:spring-beans:[5.0,6.0)")
}

Running a build with lock state present

The moment a build needs to resolve a configuration that has locking enabled and it finds a matching lock state, it will use it to verify that the given configuration still resolves the same versions.

A successful build indicates that the same dependencies are used as stored in the lock state, regardless if new versions matching the dynamic selector have been produced.

The complete validation is as follows:

  • Existing entries in the lock state must be matched in the build

    • A version mismatch or missing resolved module causes a build failure

  • Resolution result must not contain extra dependencies compared to the lock state

Fine tuning dependency locking behaviour with lock mode

While the default lock mode behaves as described above, two other modes are available:

Strict mode

In this mode, in addition to the validations above, dependency locking will fail if a configuration marked as locked does not have lock state associated with it.

Lenient mode

In this mode, dependency locking will still pin dynamic versions but otherwise changes to the dependency resolution are no longer errors.

The lock mode can be controlled from the dependencyLocking block as shown below:

Example 7. Setting the lock mode
build.gradle
dependencyLocking {
    lockMode = LockMode.STRICT
}
build.gradle.kts
dependencyLocking {
    lockMode.set(LockMode.STRICT)
}

Selectively updating lock state entries

In order to update only specific modules of a configuration, you can use the --update-locks command line flag. It takes a comma (,) separated list of module notations. In this mode, the existing lock state is still used as input to resolution, filtering out the modules targeted by the update.

❯ gradle classes --update-locks org.apache.commons:commons-lang3,org.slf4j:slf4j-api

Wildcards, indicated with *, can be used in the group or module name. They can be the only character or appear at the end of the group or module respectively. The following wildcard notation examples are valid:

  • org.apache.commons:*: will let all modules belonging to group org.apache.commons update

  • *:guava: will let all modules named guava, whatever their group, update

  • org.springframework.spring*:spring*: will let all modules having their group starting with org.springframework.spring and name starting with spring update

The resolution may cause other module versions to update, as dictated by the Gradle resolution rules.

Disabling dependency locking

  1. Make sure that the configuration for which you no longer want locking is not configured with locking.

  2. Remove the file matching the configurations where you no longer want locking.

If you only perform the second step above, then locking will effectively no longer be applied. However, if that configuration happens to be resolved in the future at a time where lock state is persisted, it will once again be locked.

Single lock file per project

Gradle supports an improved lock file format. The goal is to have only a single lock file per project, which contains the lock state for all configurations of said project. By default, the file is named gradle.lockfile and is located inside the project directory. The lock state for the buildscript itself is found in a file named buildscript-gradle.lockfile inside the project directory.

The main benefit is a substantial reduction in the number of lock files compared to the format requiring one lockfile per locked configuration.

This format requires a migration for existing locking users and is thus opt-in.

The objective is to default to this single lock file per project in Gradle 7.0.

The format can be activated by enabling the matching feature preview:

Example 8. Single lock file per project activation
settings.gradle
rootProject.name = 'locking-single-file'

enableFeaturePreview('ONE_LOCKFILE_PER_PROJECT')
settings.gradle.kts
rootProject.name = "locking-single-file"

enableFeaturePreview("ONE_LOCKFILE_PER_PROJECT")

Then with the following dependency declaration and locked configurations:

Example 9. Explicit locking
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    runtimeClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    annotationProcessor {
        resolutionStrategy.activateDependencyLocking()
    }
}

dependencies {
    implementation 'org.springframework:spring-beans:[5.0,6.0)'
}
build.gradle.kts
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    runtimeClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    annotationProcessor {
        resolutionStrategy.activateDependencyLocking()
    }
}

dependencies {
    implementation("org.springframework:spring-beans:[5.0,6.0)")
}

The lockfile will have the following content:

gradle.lockfile
# 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.springframework:spring-beans:5.0.5.RELEASE=compileClasspath, runtimeClasspath
org.springframework:spring-core:5.0.5.RELEASE=compileClasspath, runtimeClasspath
org.springframework:spring-jcl:5.0.5.RELEASE=compileClasspath, runtimeClasspath
empty=annotationProcessor
  • Each line still represents a single dependency in the group:artifact:version notation

  • It then lists all configurations that contain the given dependency

  • The last line of the file lists all empty configurations, that is configurations known to have no dependencies

Migrating to the single lockfile per project format

Once you have activated the feature preview (see above), you can simply follow the documentation for writing or updating dependency lock state.

Then after confirming the single lock file per project contains the lock state for a given configuration, the matching per configuration lock file can be removed from gradle/dependency-locks.

Configuring the per project lock file name and location

When using the single lock file per project, you can configure its name and location. The main reason for providing this is to enable having a file name that is determined by some project properties, effectively allowing a single project to store different lock state for different execution contexts. One trivial example in the JVM ecosystem is the Scala version that is often found in artifact coordinates.

Example 10. Changing the lock file name
build.gradle
def scalaVersion = "2.12"
dependencyLocking {
    lockFile = file("$projectDir/locking/gradle-${scalaVersion}.lockfile")
}
build.gradle.kts
val scalaVersion = "2.12"
dependencyLocking {
    lockFile.set(file("$projectDir/locking/gradle-${scalaVersion}.lockfile"))
}

Locking limitations

  • Locking cannot yet be applied to source dependencies.