⚙️

マルチプロジェクトビルドのための Gradle lockfile

に公開

Gradle は、依存ライブラリのバージョンを範囲で指定して管理できます。この仕組みは動的バージョンdynamic version)と呼ばれています。

// build.gradle.kts

dependencies {
    // Use spring-boot-starter with its version prefixed with "3."
    implementation("org.springframework.boot:spring-boot-starter:3.+")
}

このときビルドを再現可能にするには、範囲指定されたバージョンを特定のバージョンに固定(ロック)する必要があります。ロックの仕組みとして、npm の package-lock.json や Rust の Cargo.lockなどが知られていますが、Gradle も同様にロックファイル gradle.lockfile を生成するための依存ロックdependency locking)という機能を提供しています。

依存ロックは Gradle 4.8 からサポートされていますが、まだ広く使われているとは言えません。理由の一つとして、依存ロックをマルチプロジェクトビルドbuildSrcバージョンカタログlibs.versions.toml)などと組み合わせて使う方法が明確でない点が挙げられます。

One does not simply generate a Gradle.lockfile for multi-project builds

-- Gradle: Generating Multi-Project Lockfiles[1]

本記事では、Gradle の依存ロックをマルチプロジェクトビルドや buildSrc で構成されたプロジェクトに導入する方法を紹介します。

準備

検証には Gradle のバージョン 8.14.2を使用しました。

検証用のプロジェクト ajalab/gradle-lockfile-for-multi-projects (初期コミット:5e67156)は、2つの Kotlin サブプロジェクト app1app2 およびビルドスクリプト buildSrc で構成されています。buildSrc は、app1app2がビルドロジックを共有するためのコンベンションプラグイン kotlin-jvm.gradle.kts を提供します。設定ファイルは Gradle Kotlin DSL(*.kts)で記述されており、そのほとんどは IntelliJ IDEA の "Generate multi-module build" オプションによって生成されたものを流用しています。

プロジェクトで最初に指定した Kotlin のバージョンは 2.1.0 です。後ほど動的バージョンで記述し直し、アップグレードされることを検証します。

依存ライブラリの導入

コミット:43ae29f

最初に、依存ライブラリとして Spring Boot Gradle Pluginorg.springframework.boot)および Spring Boot Starterorg.springframework.boot:spring-boot-starter)を app1app2 それぞれに導入します。

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3ff2f2c..320b016 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,11 @@

 [versions]
 kotlin = "2.1.0"
+springBoot = "3.3.13"

 [libraries]
 kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+springBootStarter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springBoot" }
+
+[plugins]
+springBootGradlePlugin = { id = "org.springframework.boot", version.ref = "springBoot" }
diff --git a/app1/build.gradle.kts b/app1/build.gradle.kts
index b0fd2d0..c0ace6f 100644
--- a/app1/build.gradle.kts
+++ b/app1/build.gradle.kts
@@ -1,6 +1,8 @@
 plugins {
     id("buildsrc.convention.kotlin-jvm")
+    alias(libs.plugins.springBootGradlePlugin)
 }

 dependencies {
+    implementation(libs.springBootStarter)
 }
diff --git a/app2/build.gradle.kts b/app2/build.gradle.kts
index b0fd2d0..c0ace6f 100644
--- a/app2/build.gradle.kts
+++ b/app2/build.gradle.kts
@@ -1,6 +1,8 @@
 plugins {
     id("buildsrc.convention.kotlin-jvm")
+    alias(libs.plugins.springBootGradlePlugin)
 }

 dependencies {
+    implementation(libs.springBootStarter)
 }

ここでは、Spring Boot のバージョンとして 3.3.13 を指定しています。後ほど動的バージョンで記述し直し、アップグレードされることを検証します。

依存ロックの有効化

コミット:8ff1725

依存ロックを有効化するには、ビルドスクリプトの dependencyLocking セクション中で lockAllConfigurations [2]を呼び出します。

dependencyLocking {
    lockAllConfigurations()
}

重要なポイントとして、依存ロックの設定は各サブプロジェクトおよび buildSrc に対して記述する必要があります。そのため、設定は以下の2箇所に記述します。

  • サブプロジェクトがビルドロジックを共有するコンベンションプラグインの中(例:kotlin-jvm.gradle.kts
  • buildSrc そのものに対するビルドスクリプト(例:buildSrc/build.gradle.kts

ロックファイルの生成

コミット:4aff070

ロックファイル gradle.lockfile には、固定された依存ライブラリ(推移的に依存するものを含む)のバージョンが記述されます。

app1/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.
ch.qos.logback:logback-classic:1.5.18=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.18=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation
...

ロックファイルを生成するには、Gradle タスクの実行時に --write-locks オプションを指定します。

マルチプロジェクト構成の Gradle プロジェクトでは、ロックファイルも各サブプロジェクトおよび buildSrc に対して生成されます。そして陥りやすい点として、 公式ドキュメントではロックファイルの生成に gradle dependencies --write-locks コマンドを実行していますが、このように dependencies タスクをルートプロジェクトに対して実行しても、サブプロジェクトのロックファイルは生成されません[3]

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

-- Locking Versions

代替手段として、各サブプロジェクトに対して個別に dependencies タスクを実行する方法が考えられます。

gradle :buildSrc:dependencies --write-locks
gradle :app1:dependencies --write-locks
gradle :app2:dependencies --write-locks

しかし、個別にサブプロジェクトを指定して実行するのは煩雑です。回避する手段として、以下の2つが挙げられます。

build タスクを使う

プロジェクトパスを指定せずに dependencies タスクを実行すると、ルートプロジェクトのみが実行対象になるためロックファイルが生成されません。代わりに build タスクのような、すべてのサブプロジェクトを対象とするタスクを利用することで、一度のコマンド実行ですべてのサブプロジェクトのロックファイルを生成できます。

gradle build --write-locks

すべてのサブプロジェクトについて dependencies タスクを実行するタスクを定義する

コミット:3b290af

build タスクの利用には、ロックファイルの生成には本来不要なビルドを走らせてしまう問題があります。

別の手段として、すべてのサブプロジェクトの dependencies タスクに依存する新しいカスタムタスクを定義する方法があげられます。以下はそのようなタスクを allDependencies として定義する例です[4]

build.gradle.kts

tasks.register("allDependencies") {
    dependsOn(
        subprojects.flatMap { subproject ->
            subproject.tasks.matching { it.name == "dependencies" }
        }
    )
}

この allDependencies タスクを --write-locks オプションとともに実行することで、すべてのサブプロジェクトのロックファイルを一度に生成することができます。

gradle allDependencies --write-locks

バージョンの範囲指定

コミット:d8df924

ロックファイルが生成されたので、Kotlin と Spring Boot のバージョンを動的バージョンを用いて範囲指定してみます。範囲指定はバージョンカタログの versions セクションに直接書くことができます。

gradle/libs.versions.toml

[versions]
kotlin = "2.+"
springBoot = "3.+"

+ はバージョンを接頭辞(prefix)で指定するための記述子です。

この時点ではまだロックファイルが更新されていないので、ビルドを実行しても古いバージョン(kotlin = "2.1.0"springBoot = "3.3.13")が利用されます。

ロックファイルの更新

ロックファイルを更新するには、生成したときと同様に --write-locks オプションを指定して dependencies タスクなどを実行します。

実行後、ロックファイルに記述された依存ライブラリのバージョンが更新された(kotlin = "2.2.20"springBoot = "3.5.4")ことが確認できます。

diff --git i/buildSrc/gradle.lockfile w/buildSrc/gradle.lockfile
index fc38189..1f0a988 100644
--- i/buildSrc/gradle.lockfile
+++ w/buildSrc/gradle.lockfile
@@ -1,29 +1,32 @@
 # 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.jetbrains.kotlin:kotlin-compiler-runner:2.2.20-Beta2=buildScriptClasspath,runtimeClasspath
 org.jetbrains.kotlin:kotlin-daemon-client:2.0.21=kotlinBuildToolsApiClasspath
-org.jetbrains.kotlin:kotlin-daemon-client:2.1.0=buildScriptClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-daemon-client:2.2.20-Beta2=buildScriptClasspath,runtimeClasspath
 org.jetbrains.kotlin:kotlin-daemon-embeddable:2.0.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.1.0=buildScriptClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.1.0=buildScriptClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin-model:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.1.0=buildScriptClasspath,runtimeClasspath
-org.jetbrains.kotlin:kotlin-native-utils:2.1.0=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.2.20-Beta2=buildScriptClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.2.20-Beta2=buildScriptClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin-model:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.2.20-Beta2=buildScriptClasspath,runtimeClasspath
+org.jetbrains.kotlin:kotlin-native-utils:2.2.20-Beta2=buildScriptClasspath,compileClasspath,runtimeClasspath

...
diff --git i/app1/gradle.lockfile w/app1/gradle.lockfile
index 1e2875b..81d8baa 100644
--- i/app1/gradle.lockfile
+++ w/app1/gradle.lockfile
@@ -3,49 +3,55 @@
 # This file is expected to be part of source control.

...

 org.slf4j:slf4j-api:2.0.17=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework.boot:spring-boot-autoconfigure:3.3.13=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework.boot:spring-boot-starter-logging:3.3.13=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework.boot:spring-boot-starter:3.3.13=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework.boot:spring-boot:3.3.13=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-aop:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-beans:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-context:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-core:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-expression:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.springframework:spring-jcl:6.1.21=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
-org.yaml:snakeyaml:2.2=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework.boot:spring-boot-autoconfigure:3.5.4=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter-logging:3.5.4=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter:3.5.4=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework.boot:spring-boot:3.5.4=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-aop:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-beans:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-context:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-core:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-expression:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.springframework:spring-jcl:6.2.9=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.yaml:snakeyaml:2.4=compileClasspath,implementationDependenciesMetadata,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,test
ImplementationDependenciesMetadata,testRuntimeClasspath

まとめ

本記事では、マルチプロジェクトビルドで構成された Gradle プロジェクトに依存ロックを導入する方法を紹介しました。

余談:Dependabot によるロックファイルの自動更新

2025年6月に、Dependabot によって gradle.lockfile を自動的に更新する機能が GA になりました(参考:Dependabot support for Gradle lockfiles is now generally available - GitHub Changelog)。

検証用のリポジトリでも試してみましたが、まだうまく動作しない点があるようです。

脚注
  1. Elasticsearch(elastic/elasticsearch)に Gradle 依存ロックを導入した事例が紹介されています。 ↩︎

  2. compileClasspath のような特定の依存コンフィギュレーション(dependency configuration)に対して依存ロックを有効化する場合は、configurations セクションの中で対象のコンフィギュレーションに対して activateDependencyLocking を呼び出します(参考:Activate locking for specific configurations)。 ↩︎

  3. dependencies タスクがサブプロジェクトのロックファイルを生成しない問題は gradle/gradle#9373 で議論されていますが、残念ながら修正は not planned になっています↩︎

  4. アイディアの元は gradle/gradle#9373コメントです。 ↩︎

Discussion