把 KMP 库发上 Maven Central:从零到 push tag 自动发版的完整流程

这周把 kmp-webview(一个 Android + iOS 的 KMP WebView SDK)的 0.1.0 首版发上了 Maven Central,并在同一天把发版流程完全自动化。最终的状态是:

git tag 0.1.1 && git push origin 0.1.1

约十几分钟后,Maven Central 上 5 个坐标可用,GitHub Release 带自动生成的变更说明,中间零人工操作。

这篇按流程阶段拆解整个过程:前置准备 → 配置发布插件 → 本地手动首发 → 自动化 workflow。每个阶段给出要做什么、怎么验证做对了,以及我在这一步实际撞过的坑——KMP 库的发布有几个只有实操才会撞到的细节,文档里查不到或者很难查。如果你正带着某个报错搜到这里,文末有坑速查表。

全局:发版链路与技术选型

自动化之后的完整链路:

git tag 0.1.1 + push
    ↓ 触发 GitHub Actions(macos runner)
./gradlew publishAndReleaseToMavenCentral
    ↓ 构建 5 个坐标 + GPG 签名 → 打包成一个 deployment 上传
Central Portal 自动校验(9 个组件)
    ↓ 校验通过自动 release
repo1.maven.org 同步(实测约 4 分钟可用)

gh release create --generate-notes(自动生成 GitHub Release)

三个绕不开的选型结论:

阶段〇:前置准备

发第一个包之前,三件一次性的事要先办好:

  1. Central Portal 账号 + namespace 认证。在 central.sonatype.com 注册,然后认证你的 groupId 对应的 namespace:自有域名(如 wang.harlon)走 DNS TXT 记录验证;没有域名可以用 io.github.<用户名>,走 GitHub 仓库验证。没认证过的 namespace 无法发布。
  2. 生成 GPG 密钥并上传公钥。Central 强制要求所有构件 GPG 签名,公钥要上传到公开 keyserver(如 keyserver.ubuntu.com),校验时会去查。私钥导出为 armored 文本,后面 CI 要用。
  3. 生成 Portal 发布 token。Portal 网页上生成 username/password 形式的 token,这是上传 deployment 的凭证。

凭证管理的做法:GPG 私钥(base64 后的 armored 文本)、passphrase、Sonatype token 集中存一个私有 repo,配 CI secret 时用 gh api ... | gh secret set 管道直传,全程不落盘不回显。

阶段一:配置发布插件

这一阶段的目标:让 build.gradle.kts 里的发布配置在三种环境(本地日常构建、本地验证发布、CI 发版)下都行为正确。两个坑都埋在这里。

1. 版本号只留一个来源:git tag

tag 驱动发版的常见写法,是 CI 里把 tag 名注入为版本号:

env:
  ORG_GRADLE_PROJECT_VERSION_NAME: ${{ github.ref_name }}

vanniktech 插件会读 VERSION_NAME 这个 Gradle property 来设置 project.version。但如果你的 build.gradle.kts 里写了:

version = "0.1.0"   // 显式赋值,优先级高于 property

CI 注入的版本号会静默失效——你打了 0.2.0 的 tag,发出去的还是 0.1.0。这种错版事故没有任何报错,只有发完才发现。

解法是版本号只留一个来源。我们选了最彻底的方案:build 脚本里完全不写 version,coordinates 只声明 groupId/artifactId:

mavenPublishing {
    coordinates(groupId = "wang.harlon", artifactId = "kmp-webview")
}

本地日常构建 version 是 unspecified——无所谓,build/test/sample 运行走项目依赖不看版本号;需要本地验证发布配置时临时传 -PVERSION_NAME=0.0.0-local 即可。版本号的唯一来源就是 tag,不存在「忘了改版本号」或「tag 和版本不一致」这两种事故。

2. 签名按密钥是否存在条件启用

发 Central 必须 GPG 签名,于是 build 脚本里加:

mavenPublishing {
    publishToMavenCentral()
    signAllPublications()
}

然后本地跑 publishToMavenLocal 验证时直接报错:

Cannot perform signing task ':library:signMavenPublication'
because it has no configured signatory

文档里的 RELEASE_SIGNING_ENABLED=false 开关?实测在 0.36.0 显式调用 signAllPublications() 的路径下不生效,照样报错。

解法是把签名改成条件启用,判断依据正好用 CI 注入的密钥 property 本身:

// CI 注入 signingInMemoryKey 时启用签名;本地无密钥跳过
if (providers.gradleProperty("signingInMemoryKey").isPresent) {
    signAllPublications()
}

CI 通过 ORG_GRADLE_PROJECT_signingInMemoryKey 环境变量注入私钥(Gradle 自动映射为同名 property)→ 签名启用;本地没有这个 property → 跳过签名,publishToMavenLocal 畅通。顺带消除了「本地误发未签名包」的可能性。注意用 providers.gradleProperty() 而不是 findProperty(),前者是配置缓存兼容的写法。

担心「CI secret 配漏了会不会发出未签名包」?不会:Central Portal 校验会拒绝未签名构件,这条线有双保险。

✅ 本阶段验证:publishToMavenLocal

./gradlew publishToMavenLocal -PVERSION_NAME=0.0.0-local,检查 ~/.m2/repository 下 5 个坐标的产物齐全、POM 内容正确。有密钥的环境下再带上签名 property 跑一遍,确认 .asc 签名文件生成。

阶段二:本地手动首发

首版强烈建议先在本地手动走一遍完整链路,验证通了再自动化——发版 workflow 无法演习,触发即真发。

手动首发的步骤:本地执行发布任务上传 → Portal 网页确认 deployment 状态变为 VALIDATED(9 个组件全部通过校验)→ 手动点 Publish → 等 repo1.maven.org 同步后用一个空项目拉依赖验证。

这一遍走通,意味着 namespace、GPG 签名、POM 元数据、产物完整性全部被 Portal 校验过——之后自动化 workflow 里唯一的新变量就只剩「版本号从 tag 来」这一件事,出问题的排查面会小很多。

同步速度的实测数据:Portal 点完 publish(或自动 release)后,repo1.maven.org 约 4 分钟可拉取;search.maven.org 的搜索索引会慢几小时,但不影响依赖解析。

阶段三:自动化 workflow

这一阶段的目标:把手动验证过的链路搬进 GitHub Actions,让 push tag 成为唯一的发版动作。

tag 触发条件:不是正则,也不是普通 glob

发版 workflow 的触发条件想限定「长得像版本号的 tag」:

on:
  push:
    tags:
      - '[0-9]+.[0-9]+.[0-9]+*'

这串写法乍看像正则,但 GitHub filter pattern 其实是它自家的 glob 方言:+ 表示「前一个字符一个或多个」,[0-9] 字符类合法,. 是字面量。实测匹配结果:1.2.3 ✅、0.1.10 ✅、1.2.3-rc1 ✅(尾部 * 吸收后缀)、v1.0.0 ❌、0.1 ❌——正好是想要的语义。误打的非版本 tag(比如 test)不会触发发版。

workflow 里几个值得抄的细节

支线:给 PR 配日常 CI 单测

这条和发版正交,但发库总要配 PR 检查。想在 ubuntu 上跑单测,照经典 AGP 的习惯写 :library:testDebugUnitTest,报错任务不存在。

原因:library 用的是新版 AGP KMP 插件(com.android.kotlin.multiplatform.library),它默认不创建 Android host 单测任务——commonTest 里的测试只能跑 iOS 模拟器(这又回到了「必须 macOS」)。需要显式启用:

kotlin {
    android {
        withHostTestBuilder {}   // 启用后获得 testAndroidHostTest 任务
    }
}

启用后 61 个 commonTest 用例在 JVM host 上直接跑。另外实测确认:KMP 的 Apple target 在 ubuntu 上配置阶段没问题(target 注册、XCFramework DSL 都是纯配置),只是不能执行编译任务——所以 ubuntu CI 跑 Android 任务完全可行,不必为每个 PR 烧 macOS 时长。

发版前 Checklist

坑速查表

症状根因修复章节
打了新 tag,发出去还是旧版本号build 脚本里的字面 version = 优先级高于 CI 注入的 property删掉显式赋值,版本号只从 tag 来阶段一·1
本地 publishToMavenLocalno configured signatorysignAllPublications() 在无密钥环境必失败,RELEASE_SIGNING_ENABLED=false 不生效按密钥 property 是否存在条件启用签名阶段一·2
:library:testDebugUnitTest 任务不存在新版 AGP KMP 插件默认不创建 Android host 单测任务withHostTestBuilder {}支线
tag 推上去 workflow 没触发 / 误触发GitHub tag filter 是自家 glob 方言,不是正则'[0-9]+.[0-9]+.[0-9]+*',实测匹配范围阶段三

最后

回头看,这套链路里真正花时间的不是写 workflow(30 行 YAML),而是把「版本号从哪来、签名什么时候启用、测试在哪个 runner 跑」这三件事的边界想清楚——它们各自都有一个文档查不到的坑等着。先手动走通一遍、再让 tag 接管一切,是绕过这些坑成本最低的路径。