Using dynamic dependency versions (e.g., 1.+ or [1.0,2.0)) can cause builds to break unexpectedly because the exact version of a dependency that gets resolved can change over time:

build.gradle.kts
dependencies {
    // Depend on the latest 5.x release of Spring available in the searched repositories
    implementation("org.springframework:spring-web:5.+")
}
build.gradle
dependencies {
    // Depend on the latest 5.x release of Spring available in the searched repositories
    implementation 'org.springframework:spring-web:5.+'
}

To ensure reproducible builds, it’s necessary to lock versions of dependencies and their transitive dependencies. This guarantees that a build with the same inputs will always resolve to the same module versions, a process known as dependency locking.

Dependency locking is a process where Gradle saves the resolved versions of dependencies to a lock file, ensuring that subsequent builds use the same dependency versions. This lock state is stored in a file and helps to prevent unexpected changes in the dependency graph.

Dependency locking offers several key advantages:

  • Avoiding Cascading Failures: Teams managing multiple repositories no longer need to rely on -SNAPSHOT or changing dependencies, which can lead to unexpected failures if a dependency introduces a bug or incompatibility.

  • Dynamic Version Flexibility with Stability: Teams that use the latest versions of dependencies can rely on dynamic versions during development and testing phases, locking them only for releases.

  • Publishing Resolved Versions: By combining dependency locking with the practice of publishing resolved versions, dynamic versions are replaced with the actual resolved versions at the time of publication.

  • Optimizing Build Cache Usage: Since dynamic or changing dependencies violate the principle of stable task inputs, locking dependencies ensures that tasks have consistent inputs.

  • Enhanced Development Workflow: Developers can lock dependencies locally for stability while working on a feature or debugging an issue, while CI environments can test the latest SNAPSHOT or nightly versions to provide early feedback on integration issues. This allows teams to balance stability and early feedback during development.

Activate locking for specific configurations

Locking is enabled per dependency configuration.

Once enabled, you must create an initial lock state, causing Gradle to verify that resolution results do not change. This ensures that if the selected dependencies differ from the locked ones (due to newer versions being available), the build will fail, preventing unexpected version changes.

Dependency locking is effective with dynamic versions, but it should not be used with changing versions (e.g., -SNAPSHOT), where the coordinates remain the same, but the content may change.

Using dependency locking with changing versions indicates a misunderstanding of these features and can lead to unpredictable results.

Gradle will emit a warning when persisting the lock state if changing dependencies are present in the resolution result.

Locking of a configuration happens through the ResolutionStrategy API:

build.gradle.kts
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
}
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
}

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

Activate locking for all configurations

The following locks all configurations:

build.gradle.kts
dependencyLocking {
    lockAllConfigurations()
}
build.gradle
dependencyLocking {
    lockAllConfigurations()
}

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

Disable locking for specific configurations

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:

build.gradle.kts
configurations.compileClasspath {
    resolutionStrategy.deactivateDependencyLocking()
}
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.deactivateDependencyLocking()
    }
}

Activate locking for a buildscript classpath configuration

If you apply plugins to your build, you may want to leverage dependency locking there as well.

To lock the classpath configuration used for script plugins:

build.gradle.kts
buildscript {
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}
build.gradle
buildscript {
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}

Generating and updating dependency locks

To generate or update the lock state, add the --write-locks argument while invoking whatever tasks that would trigger the locked configurations to be resolved:

$ ./gradlew dependencies --write-locks

This will create or update the lock state for each resolved configuration during that build execution. If a lock state already exists, it will be overwritten.

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.
com.google.code.findbugs:jsr305:3.0.2=classpath
com.google.errorprone:error_prone_annotations:2.3.2=classpath
com.google.gradle:osdetector-gradle-plugin:1.7.1=classpath
com.google.guava:failureaccess:1.0.1=classpath
com.google.guava:guava:28.1-jre=classpath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath
com.google.j2objc:j2objc-annotations:1.3=classpath
empty=
Gradle won’t write the lock state to disk if the build fails, preventing the persistence of potentially invalid states.

Lock all configurations in a single build execution

When working with multiple configurations, you may want to lock them all at once in a single build execution. You have two options for this:

  1. Run gradle dependencies --write-locks:

    • This command will lock all resolvable configurations that have locking enabled.

    • In a multi-project setup, note that dependencies is executed only on one project, typically the root project.

  2. Declare a Custom Task to Resolve All Configurations:

    • This approach is particularly useful if you need more control over which configurations are locked.

This custom task resolves all configurations, locking them in the process:

build.gradle.kts
tasks.register("resolveAndLockAll") {
    notCompatibleWithConfigurationCache("Filters configurations at execution time")
    doFirst {
        require(gradle.startParameter.isWriteDependencyLocks) { "$path must be run from the command line with the `--write-locks` flag" }
    }
    doLast {
        configurations.filter {
            // Add any custom filtering on the configurations to be resolved
            it.isCanBeResolved
        }.forEach { it.resolve() }
    }
}
build.gradle
tasks.register('resolveAndLockAll') {
    notCompatibleWithConfigurationCache("Filters configurations at execution time")
    doFirst {
        assert gradle.startParameter.writeDependencyLocks : "$path must be run from the command line with the `--write-locks` flag"
    }
    doLast {
        configurations.findAll {
            // Add any custom filtering on the configurations to be resolved
            it.canBeResolved
        }.each { it.resolve() }
    }
}

By filtering and resolving specific configurations, you ensure that only the relevant ones are locked, tailoring the locking process to your project’s needs. This is especially useful in environments like native builds, where not all configurations can be resolved on a single platform.

Understanding lock state location and format

A lockfile is a critical component that records the exact versions of dependencies used in a project, allowing for verification during builds to ensure consistent results across different environments and over time. It helps identify discrepancies in dependencies when a project is built on different machines or at different times.

Lockfiles should be checked in to source control.

Location of lock files

  • The lock state is preserved in a file named gradle.lockfile, located at the root of each project or subproject directory.

  • The exception is the lockfile for the buildscript itself, which is named buildscript-gradle.lockfile.

Structure of lock files

Consider the following dependency declaration:

build.gradle.kts
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    runtimeClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
    annotationProcessor {
        resolutionStrategy.activateDependencyLocking()
    }
}

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

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

With the above configuration, the generated gradle.lockfile will look like this:

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

Where:

  • Each line represents a single dependency in the group:artifact:version format.

  • Configurations: After the version, the configurations that include the dependency are listed.

  • Ordering: Dependencies and configurations are listed alphabetically to make version control diffs easier to manage.

  • Empty Configurations: The last line lists configurations that are empty, meaning they contain no dependencies.

This lockfile should be included in source control to ensure that all team members and environments use the exact same dependency versions.

Migrating your legacy lockfile

If your project uses the legacy lock file format of a file per locked configuration, follow these instructions to migrate to the new format:

  1. Follow the documentation for writing or updating dependency lock state.

  2. Upon writing the single lock file per project, Gradle will also delete all lock files per configuration for which the state was transferred.

Migration can be done one configuration at a time. Gradle will keep sourcing the lock state from the per configuration files as long as there is no information for that configuration in the single lock file.

Configuring the lock file name and location

When using a single lock file per project, you can configure its name and location.

This capability allows you to specify a file name based on project properties, enabling a single project to store different lock states for different execution contexts.

For example, in the JVM ecosystem, the Scala version is often included in artifact coordinates:

build.gradle.kts
val scalaVersion = "2.12"
dependencyLocking {
    lockFile = file("$projectDir/locking/gradle-${scalaVersion}.lockfile")
}
build.gradle
def scalaVersion = "2.12"
dependencyLocking {
    lockFile = file("$projectDir/locking/gradle-${scalaVersion}.lockfile")
}

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 by your build as stored in the lock state, regardless if new versions matching the dynamic selector are available in any of the repositories your build uses.

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. Other changes include:

  • Adding or removing dependencies, even if they are strictly versioned, without causing a build failure.

  • Allowing transitive dependencies to shift, as long as dynamic versions are still pinned.

This mode offers flexibility for situations where you might want to explore or test new dependencies or changes in versions without breaking the build, making it useful for testing nightly or snapshot builds.

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

build.gradle.kts
dependencyLocking {
    lockMode = LockMode.STRICT
}
build.gradle
dependencyLocking {
    lockMode = LockMode.STRICT
}

Updating lock state entries selectively

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:

$ ./gradlew dependencies --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

To disable dependency locking for a configuration:

  1. Remove Locking Configuration: Ensure that the configuration you no longer want to lock is not configured with dependency locking. This means removing or commenting out any activateDependencyLocking() calls for that configuration.

  2. Update Lock State: The next time you update and save the lock state (using the --write-locks option), Gradle will automatically clean up any stale lock state associated with the configurations that are no longer locked.

Gradle must resolve a configuration that is no longer marked as locked to detect and drop the associated lock state. Without resolving the configuration, Gradle cannot identify which lock state should be cleaned up.

Ignoring specific dependencies from the lock state

In some scenarios, you may want to use dependency locking for other reasons than build reproducibility.

As a build author, you might want certain dependencies to update more frequently than others. For example, internal dependencies within an organization might always use the latest version, while third-party dependencies follow a different update cycle.

This approach can compromise reproducibility. Consider using different lock modes or separate lock files for specific cases.

You can configure dependencies to be ignored in the dependencyLocking project extension:

build.gradle.kts
dependencyLocking {
    ignoredDependencies.add("com.example:*")
}
build.gradle
dependencyLocking {
    ignoredDependencies.add('com.example:*')
}

The notation <group>:<name> is used to specify dependencies, where * acts as a trailing wildcard. Note that *:* is not accepted, as it effectively disables locking. See the description on updating lock files for more details.

Ignoring dependencies will have the following effects:

  • Ignored dependencies apply across all locked configurations, and the setting is project scoped.

  • Ignoring a dependency does not exclude its transitive dependencies from the lock state.

  • No validation ensures that an ignored dependency is present in any configuration resolution.

  • If the dependency is present in lock state, loading it will filter out the dependency.

  • If the dependency is present in the resolution result, it will be ignored when validating the resolution against the lock state.

  • When the lock state is updated and persisted, any ignored dependency will be omitted from the written lock state.

Understanding locking limitations

  • Dependency locking does not currently apply to source dependencies.