关于 kotlin-tooling-gradle-to-kotlin-toolchain-project
这项迁移技能指导将 Gradle 项目迁移到 Kotlin Toolchain,涵盖依赖与配置转换,以及 Gradle 插件的替换。它提供分阶段的盘点、插件处理、CI 改写和端到端验证流程,并强调保留源代码。
主要功能
- 01盘点 Gradle 插件、任务与配置
- 02将共享配置转换为模块模板
- 03为插件选择原生替代或本地重实现
- 04将依赖统一迁移到版本目录
- 05改写 CI 构建与检查流程
- 06验证迁移后的项目和任务
适用场景
- 01规划现有 JVM 项目的迁移
- 02处理没有原生替代方案的 Gradle 插件
- 03迁移版本目录和共享构建配置
- 04更新 CI 并验证构建产物
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kotlin/ kotlin-agent-skills --skill kotlin-tooling-gradle-to-kotlin-toolchain-project安装后在 Claude Code 中输入/kotlin-tooling-gradle-to-kotlin-toolchain-project确认已加载。
附带文件references/examples.md
详细介绍
Gradle → Kotlin Toolchain 项目迁移
同时完成两项工作:机械地转换依赖和配置,并为 Toolchain 没有原生方案的每个 Gradle 插件提供替代实现。可改编的模板见 references/examples.md,这些模板源自一次真实迁移。
针对插件相关的子问题,请借助配套技能:kotlin-tooling-kotlin-toolchain 负责语法,
kotlin-tooling-gradle-to-kotlin-toolchain-plugin 负责逐个移植插件的工作流(对于每个没有原生等效方案的 Gradle 插件,都要运行一次),kotlin-tooling-kotlin-toolchain-plugin-authoring 负责从头编写插件。
原则
- 保留源代码。 如果迁移迫使你修改业务逻辑,本地插件很可能就能填补这个缺口。读取类路径中
release.properties的代码应该继续正常工作——原样发布该文件,不要改写使用它的代码。 - 每个第三方 Gradle 插件都必须舍弃、采用原生方案或重新实现——绝不能“保留 Gradle 插件”。
你自己的约定插件则不同:
buildSrc/build-logic预编译脚本属于共享的配置,因此它们应转换为模块模板,而不是本地插件——参见 没有buildSrc/ 约定插件。 - 编写本地插件前先搜索 GitHub。 很可能已经有人实现过;引入一个可用实现,总是胜过从头移植。在哪里查找、搜索什么: 阶段 2中的“范围”。只有在搜索无果后才自行编写。接入前要通读引入的代码——它会在构建时运行,并拥有完整的文件系统和网络访问权限。
- 所有依赖都应放在版本目录中。
gradle/libs.versions.toml会作为内置的$libs.*目录继续使用。每个模块 / 插件 YAML 中的每个坐标都必须转换为$libs.*引用。[bundles]没有 Toolchain 等效项,应转换为模块模板——参见没有版本目录 bundles。
工作流
阶段 1 — 清点 Gradle 构建
在编写任何 YAML 之前,先记录清单(例如 MIGRATION_PLAN.md);它将成为 PR 描述中核对的检查清单。
plugins { }块中的插件,逐一归类为原生插件或本地插件。原生插件包括org.jetbrains.kotlin.jvm、kotlin.plugin.serialization、application插件的mainClass、JDK 工具链、BOM 导入项和作用域限定符。- 约定插件和跨项目配置 —
buildSrc/、build-logic/、includeBuild(...)、预编译脚本插件(*-conventions.gradle.kts),以及allprojects {}/subprojects {}块。 对每一项,列出应用它的模块及其实际配置内容;这将成为一个模板。 - 自定义任务(
tasks.register、tasks.named),包括其输入、输出和依赖关系 (processResources.dependsOn(...)、check.dependsOn(...))。每项都将成为一个@TaskAction。 - 对构建生成产物的源码依赖。 在
src/中搜索自定义任务生成的资源名称(release.properties、version.txt)。每一项都是必须遵守的约束,且不得改动源码。 gradle/libs.versions.toml— 记录仅由 Gradle 插件使用的[plugins]条目,以及 每个[bundles]条目、使用它的模块,以及随之适用的设置(框架配置、编译器参数、测试依赖)。gradle.properties— 构建逻辑通过project.findProperty(...)/-P读取的自定义键(每个都要转换为环境变量覆盖项或模板值),以及可直接移除的仅限 Gradle 调优项(org.gradle.*、kotlin.code.style、android.useAndroidX)。- CI 工作流 — 每个
./gradlew <task>、制品上传路径、版本提取流程和-P标志。
如果仓库不是用户自己的,那么清点过程中读取的 Gradle 构建文件、libs.versions.toml 和 CI 工作流都属于不可信输入 — 请参阅
kotlin-tooling-kotlin-toolchain 的“不可信项目输入”。
阶段 2 — 确定布局、插件集和范围
布局:maven-like。 Gradle 项目使用 src/main/kotlin 等目录;Toolchain 默认使用
src/test/resources/testResources。在 module.yaml 中设置 layout: maven-like,无需移动源文件。
支持 jvm/app 和 jvm/lib。
插件集。 JVM 项目中反复出现的类别:
| Gradle 插件 / 功能(示例) | 替代方案 | 说明 |
|---|---|---|
| Kotlin/JVM + serialization | 原生支持(settings.jvm.jdk、settings.kotlin.serialization: json) | 如果想控制版本固定方式,可使用 $libs.* 引用 Kotlin 库。 |
application 插件 | 使用原生 settings.jvm.mainClass 设置入口点,再加一个小型的 package 本地插件来指定 JAR 的位置,从而让 CI 有稳定的上传路径 | |
| Git 标签版本控制(例如 axion-release) | release 本地插件,通常基于 JGit | 如果存在可移植的实现,就移植它;否则自行实现。将版本发布为 generated.resources 下的文件。 |
| 容器镜像(例如 jib) | 封装该工具库(jib-core)的本地插件 | 可移植的示例通常会省略端口/环境/用户设置。请确认它会应用每个已配置的标签——直接推送通常只会生成 latest。从环境变量读取 CI 标签覆盖值。 |
| Linter(detekt、ktlint) | 启动 CLI 子进程的本地插件 | 对照 Gradle 插件重新检查可移植的默认设置——参见需留意的不一致。 |
自定义 generateXyz / processResources | 在相关插件中添加额外的 @TaskAction,并将输出接入 generated.resources | |
版本目录 [bundles] | 每个 bundle 对应一个模块模板(<name>.module-template.yaml + apply:) | 也将框架的设置和测试依赖合并到模板中——参见没有版本目录 bundle |
buildSrc / build-logic 约定插件、allprojects {} / subprojects {} | 每个约定脚本对应一个模块模板;只有其中剩余的命令式逻辑才变成本地插件 | 约定层级可映射为嵌套模板——参见没有 buildSrc / 约定插件 |
每个本地插件都是一个 jvm/amper-plugin 模块。
范围。 对于列表中的每个插件,在编写任何 Kotlin 代码之前先搜索 GitHub。生态系统规模不大, 但反复出现的插件已经存在于某处。搜索顺序:
JetBrains/kotlin-toolchain→build-sources/(detekt、dokka、binary-compatibility-validator、protobuf、generate-build-properties、project-commands)——Toolchain 自身使用构建的本地插件,也就是事实上的参考实现。同一仓库中还有plugin-samples/和docs/。- 按插件标记而非工具名称搜索 GitHub 代码:
"product: jvm/amper-plugin"、path:plugin.yaml "@TaskAction"、"jvm/amper-plugin" jib。 - 搜索工具自己的仓库——大多数 Linter/打包工具都会发布
-cli或-core制品,而薄封装插件所需的全部内容往往就是这些制品,因此“没有插件”通常仍意味着“存在一个 50 行的封装插件”。
然后为每个插件选择:移植(原样复制找到的实现;保留其许可证头,并添加包含来源 URL + commit 的注释,以便重新同步)、扩展(移植 + 额外的 Settings 字段)或自行实现(没有找到实现,或需求是定制的)。一个 PR 的合理默认做法是:移植已有实现,自行实现小型定制插件(package 插件、薄封装 Linter 的插件),其余的推迟处理。在信任移植的插件之前,务必将其行为与它所替代的 Gradle 插件进行差异比较——参见
移植的 Linter 插件可能比 Gradle 插件更严格。
第 3 阶段 — 实现
- 从参考工具链项目(例如这个项目)复制
kotlin/kotlin.bat,或在临时目录中运行kotlin init。在.sdkmanrc中固定kotlintoolchain=<version>,使其与包装器保持一致。 - 编写
project.yaml,列出所有插件模块路径。 - 对每个插件:先在 GitHub 上搜索(参见第 2 阶段中的“范围”),然后将其引入或编写到
plugins/<name>/下;每次操作后运行./kotlin show modules,确认模型仍可加载。 - 在项目根目录编写模板:为每个约定脚本,以及每个有两个或更多使用者的
[bundles]条目各编写一个<name>.module-template.yaml,其中包含相关依赖项,以及随之使用的设置、测试依赖项和仓库;嵌套方式应与 Gradle 约定一致。 - 编写根目录的
module.yaml:设置product: jvm/app、layout: maven-like、$libs.*依赖项、用于应用模板的apply:列表,以及启用每个本地插件并配置其非默认设置的plugins:块。 - 单独验证每个插件:运行
./kotlin task :<module>:<task>@<plugin>或./kotlin do <command>。 - 重写 CI(参见第 4 阶段)。
- 删除
build.gradle.kts、gradlew、gradlew.bat、gradle/wrapper/——但保留gradle/libs.versions.toml。 - 清理目录:删除整个
[plugins]块、任何仅供其使用的[versions]键,以及所有使用者都应用模板后删除[bundles]。 - 检查每个
module.yaml/plugin.yaml,将其中的 Maven 字面坐标替换为$libs.<key>。
第 4 阶段 — 重写 CI
- 设置
KOTLIN_CLI_NO_WELCOME_BANNER: "1"。 - 将
./gradlew build && ./gradlew check替换为./kotlin build && ./kotlin check(这会运行每个插件的checks:注册项和测试)。 - 将
./gradlew jib -Djib.to.tags=…替换为./kotlin do jib,并通过插件读取的环境变量传递标签。 通常,引入的 jib 风格插件没有这个钩子——引入时请添加。 - 构件上传路径会发生变化:Gradle 的
build/libs/<name>.jar已不存在,而jarJvm会写入 Toolchain 内部路径。编写一个package插件,将 JAR 暂存到${module.rootDir}/build/libs/${module.name}.jar,以便继续简单地上传构件。
第 5 阶段 — 端到端验证
在本地运行每个面向用户的命令,并在 PR 的测试计划中记录输出:
./kotlin show modules # project model loads, all expected modules listed
./kotlin clean && ./kotlin build
./kotlin test
./kotlin check # linters + tests; expect zero violations after Phase 3
./kotlin do currentVersion # if applicable
./kotlin do jib # or jibBuildTar to avoid pushing locally (if applicable)
./kotlin do package # verify build/libs/<name>.jar and its Main-Class manifest (if applicable)
./kotlin do ktlintFormat # if applicable
需要注意的差异
module.yaml 中不支持 ${...} 插值
configFile: ${module.rootDir}/detekt.yml 会被按字面值处理。请使用相对于模块的路径
(configFile: detekt.yml)。插值仅在 plugin.yaml 中有效。
模块名称来自目录名称
没有 name: 字段。actions/checkout 会将仓库克隆到以 GitHub 仓库命名的目录中,而本地工作树解析出的 ${module.name} 可能是其他值。切勿在插件任务的输出路径中硬编码模块名称——请使用 ${module.name}。
未设置 enabled: true 的插件设置会被忽略
plugins:
release: enabled # shorthand — only valid with no other settings
jib: # long form — required as soon as any setting is present
enabled: true
container:
mainClass: com.example.App
未设置 enabled: true 的设置只会产生警告(“Plugin X is not enabled, but has some explicit
configuration”),随后跳过该插件。
引入的代码检查插件可能比 Gradle 插件更严格
对比引入的插件与 Gradle 插件默认任务所传递的标志,并将任何更严格的检查设为可选项,使默认行为与旧行为一致。
典型案例:上游 detekt 插件(amper/build-sources/detekt/)总会向
detekt-cli 传递 --classpath,以启用类型解析,而 Gradle 的默认 detekt 任务不会。直接使用该插件会暴露 Gradle 从未报告过的违规项(尤其是带 return 的 Elvis 表达式中的 UnreachableCode)。修复方法:在插件的 Settings 中添加 useTypeResolution: Boolean get() = false,并通过该设置控制此标志——补丁见
references/examples.md。
没有版本目录 bundle
只有 [libraries] 键可以解析($libs.<key>)。$libs.bundles.<name> 不存在,目录中的 [bundles] 是 Toolchain 永远不会读取的无效配置。官方答复(KTC-4759)是为每个 bundle 创建一个模块模板,并在 bundle 原先使用的位置应用它:
# gradle/libs.versions.toml — before
[bundles]
ktor-server = ["ktor-server-core", "ktor-server-netty", "ktor-server-content-negotiation"]
# ktor-server.module-template.yaml — at the project root; templates cannot declare product:
dependencies:
- $libs.ktor.server.core
- $libs.ktor.server.netty
- $libs.ktor.server.content.negotiation
settings:
kotlin:
serialization: json
test-dependencies:
- $libs.ktor.server.test.host
# module.yaml
product: jvm/app
apply:
- //ktor-server.module-template.yaml
模板是更好的解决方案,而不只是权宜之计:bundle 只携带坐标,而它所启用的框架通常还需要 settings(kotlin.serialization、springBoot、freeCompilerArgs)、test-dependencies,有时还需要 repositories。将这些内容全部放进模板,这样一行 apply: 就能启用可用的框架,而不只是一个裸类路径。
注意以下规则:
- 路径格式为
//<name>.module-template.yaml,相对于项目根目录(即project.yaml所在目录)。 - 模板中不能有
product:。模板可以apply:其他模板;即使通过两条路径引用,每个模板也只会应用一次,因此依赖列表只会出现一次。 - 合并语义:列表追加,标量覆盖,并且无论
module.yaml中apply:所在的位置如何,module.yaml始终具有最高优先级。 - 如果两个同级模板设置了相同的标量(例如
settings.jvm.release),就会报错(“Conflicting values for property”)。可以在使用方的module.yaml中设置该值,或创建一个同时应用这两个模板的第三个模板来解决。 - 不要转换只有一个使用方的 bundle。模板从第二个模块开始才有收益;少于两个模块时,直接内联
$libs.*列表。 - 模板也可以表示按平台限定的部分的并集(如
dependencies@jvm、settings@android),因此,即使 KMP bundle 被拆分到多个源集,也仍可使用一个模板。
没有 buildSrc / convention 插件——共享配置放入模板
buildSrc/、build-logic/ 和 includeBuild(...) 都没有对应形式,而 project.yaml 只包含 modules: 和 plugins:——没有根项目继承机制,也没有地方放置命令式共享构建逻辑。本地插件也不是合适的目标:插件贡献的是任务,不会注入模块配置。预编译脚本插件大多是声明式的,因此可将其转换为一个 <name>.module-template.yaml,并由原先使用 plugins { id("<name>") } 的模块应用:
// buildSrc/src/main/kotlin/service-conventions.gradle.kts
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
}
kotlin { jvmToolchain(21) }
repositories { maven("https://jitpack.io") }
dependencies {
implementation(libs.ktor.server.core)
testImplementation(libs.kotest.runner.junit5)
}
# service-conventions.module-template.yaml
settings:
jvm:
jdk:
version: 21
kotlin:
serialization: json
repositories:
- id: jitpack
url: https://jitpack.io
dependencies:
- $libs.ktor.server.core
test-dependencies:
- $libs.kotest.runner.junit5
| Convention 脚本结构 | 模板对应项 |
|---|---|
plugins { kotlin("jvm"), kotlin("plugin.serialization"), id("org.springframework.boot") } | 原生 settings:(settings.kotlin.*、settings.springBoot 等) |
kotlin { jvmToolchain(21) }、java { targetCompatibility } | settings.jvm.jdk.version、settings.jvm.release |
dependencies { implementation / api / testImplementation } | dependencies:(api 使用 : exported)和 test-dependencies: |
repositories { } | repositories: |
tasks.withType<Test> { useJUnitPlatform() } | 内置设置 / settings.junit |
| 一个 convention 脚本应用另一个 convention 脚本 | 嵌套模板(模板中的 apply:) |
根脚本中的 allprojects {} / subprojects {} | 每个 module.yaml 都应用的一个模板 |
tasks.register(…)、doLast { } 以及任何命令式逻辑 | 剩余部分——每种行为使用一个本地插件 |
此外:
- 直接删除
buildSrc/。其libs目录访问器由模板中直接使用的$libs.*替代。 - 通常,将一个臃肿的 convention 脚本拆成多个小型模板(
jvm、ktor-server、testing)会更合适;bundle 模板也可以并入同一层级结构——合并和冲突规则请参见没有版本目录 bundle,此处同样适用。 - 尚未验证能否在模板内部启用本地插件(即在
*.module-template.yaml中使用plugins:块)。如果 convention 脚本启用了你重新实现的插件,在确认模板形式可以加载之前,请将plugins:块保留在每个module.yaml中(运行./kotlin show modules,并实际运行一个任务确认)。
无依赖排除功能
没有与 Gradle 的 exclude(group, module) 等效的功能。任何传递依赖排除都会被静默忽略,相关库也会进入运行时类路径。请在 PR 中注明这一权衡(通常会多出几百 KB 未使用的内容)。
不支持插件间依赖
插件彼此隔离。如果两个插件在逻辑上属于一组,请将两个任务操作放在同一个插件模块中,或抽取出库。
不支持 -P / -D 命令行覆盖
请在 @TaskAction 中通过环境变量读取临时覆盖值(强制版本、跳过检查、动态镜像标签);也不要依赖 --setting 标志(固定版本的 CLI 可能会拒绝它)。模式见 kotlin-tooling-gradle-to-kotlin-toolchain-plugin 技能。
获取命令的输出值
@TaskAction 中的 println 不是机器可读的通道。Toolchain 会将其包装为
<ts> INFO :<module>:<task>@<plugin> <value>,并追加 <task> successful 标语,因此
./kotlin do currentVersion | tail -n1 会得到该标语;而将 --log-level 降至 error/off 会完全丢弃值所在的行,因为它是在 INFO 级别输出的。请改用以下方式:
- 让任务将值写入由环境变量指定名称的文件,然后用
cat读取。此外,其他任务也可以在后续步骤中复用此值 - 或读取命令生成的 git 状态:
git describe --tags --exact-match HEAD。 - 如果必须解析标准输出,请匹配任务坐标:
awk '/<task>@<plugin>/ { v = $NF } END { print v }'。
测试资源会覆盖 generated.resources
当插件生成 release.properties,且 src/test/resources/release.properties 存在时,类路径顺序会优先使用测试 fixture。通常这能正常工作,但不够稳妥——如果测试要断言特定值,请注入 stub 服务,而不要依赖优先级。
Dependabot 需要 stub build.gradle.kts
Dependabot 的 Gradle 文件获取器在扫描 gradle/libs.versions.toml 之前,要求配置目录中存在 build.gradle(.kts);如果没有,package-ecosystem: "gradle" 会静默地不执行任何操作。请在根目录保留一个空的 build.gradle.kts,并添加注释说明原因。Toolchain 会忽略它。
Apple Silicon 上仅支持 amd64 的容器镜像
像 mongo:3.2 这样的镜像在 M 系列 Mac 上通过 Rosetta/QEMU 运行时会崩溃
(runtime: failed to create new OS thread (have 2 already; errno=22))。迁移不会改变这一点——同一镜像在 ./gradlew check 下也会失败。请标记为既有问题;在 ubuntu-latest 上运行的 CI 不受影响。
iOS(KMP):迁移后的 Info.plist 会丢失其 CFBundle* 键
Gradle/KMP iOS 应用会将 bundle 元数据保存在手动维护的 iosApp.xcodeproj
(GENERATE_INFOPLIST_FILE = YES)中,因此 Xcode 会合成 CFBundle* 键,而已提交的 Info.plist 有意保持不完整。Toolchain 会忽略该 .xcodeproj,自行生成项目,并原样使用你的 plist——不会有任何内容合成这些键,且 .app 没有 bundle id:
Simulator device failed to install the application. Missing bundle ID.
修复方法:让 plist 包含所有必需内容,并在其中保留你的应用专属键,同时添加以下键:
<key>CFBundleDevelopmentRegion</key><string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
PRODUCT_BUNDLE_IDENTIFIER 会在生成的目标上设置。有关底层行为,请参阅
kotlin-tooling-kotlin-toolchain 技能的“iOS 应用”部分。
KMP:保留每个源集的全部依赖
将每个 Gradle 源集(commonMain、jvmMain、androidMain、iosMain、commonTest、jvmTest、
……)转换为对应的 Amper 配置项:dependencies、dependencies@jvm/@android/@ios、test-dependencies、
test-dependencies@<platform>。不要只挑看起来显而易见的库依赖:
-
*Main中的依赖也会进入对应目标的测试类路径(jvmTest扩展自jvmMain),因此删掉某项依赖可能导致测试失败,却不会引发编译错误。 -
有些依赖看起来属于另一个模块,却仍可能不可或缺。典型情况是:共享库中的
jvmMain { implementation(compose.desktop.currentOs) }看起来像是桌面应用依赖,但它提供了 Skiko 原生运行时(skiko-awt-runtime-<os>,包含libskiko-<os>.dylib和.sha256),供模块自身的 JVM Compose UI 测试(compose.uiTest/runComposeUiTest)在运行时加载。compose.ui:ui-test只会引入 Skiko 的类,不会引入原生库。删掉该依赖后,编译仍会成功,但运行时会报错:org.jetbrains.skiko.LibraryLoadException: Cannot find libskiko-macos-arm64.dylib.sha256, proper native dependency missing.这不是 Toolchain 的问题:不添加该依赖时,Gradle 也会以相同方式失败。将其恢复到
dependencies@jvm,或者将其限定在test-dependencies@jvm,避免 Skiko 原生库进入使用方的类路径。
防止遗漏:将每个 Gradle 源集的依赖列表与其 Kotlin Toolchain 配置部分进行对比(检查名称和数量),然后运行 ./kotlin show dependencies -m <module>,并与 Gradle 构建进行比较。
常见问题
- 没有
package插件。 CI 最终会上传build/artifacts/CompiledJvmArtifact/(内部类文件目录),而不是 JAR。更改上传路径前,先添加该插件。 - 提交
build/。 尽早将其添加到.gitignore;删除旧的.gradle/条目。 - Maven 坐标中的字符串插值。
plugin.yaml中的com.example:foo:${pluginSettings.version}会报错:“Value of type 'ShadowDependency' doesn't support string interpolation”。请使用$libs.foo。
Kotlin Toolchain 文档:https://kotlin-toolchain.org/dev/