一套 KMP 多仓库版本治理工作流:用 Version Catalog + CI 把发版流水线自动化
本文分享我在一个 Kotlin Multiplatform(KMP)项目里实践的一套「版本控制 + 代码管理」工作流。 项目规模:一个主工程 App + 十几个独立仓库的 KMP 业务/基础库。文中所有仓库地址、包名、 制品库、机器人等均为脱敏后的示意信息(如
com.acme.kmp.*、"内网 GitLab"、"私有 Nexus 制品库")。
一、背景:多仓库 KMP 项目的版本地狱
先交代场景。我们的 KMP 项目不是一个大单体仓库,而是拆成了很多仓库:
- 主工程:iOS / Android 双端 App,最终产物。
- KMP 业务/基础库:十几个独立仓库,例如我的、账户、资讯、通用基础、设计资源等,各自发布成 Maven 制品。
- 主工程通过依赖坐标引用这些库。
拆分带来了模块解耦,但也带来了一个经典难题------版本对不齐:
- 一次发版可能同时改动七八个库,每个库都要发一个新版本;
- 主工程要在依赖里把这七八个库的版本号一个个改对;
- 库之间还有相互依赖,版本错配轻则编译失败,重则线上诡异 bug;
- 手工维护一长串
x.y.z-SNAPSHOT,改错、漏改、复制粘贴翻车是家常便饭。
我们需要的是:一个统一的地方描述"这次发版用哪些库的哪些版本",并且让它尽量自动维护。
二、核心武器:Gradle Version Catalog + 一个独立的 catalog 仓库
Gradle 的 Version Catalog 正好解决"统一描述依赖版本"这件事:用一份 libs.versions.toml 把「库别名 → 版本号」集中管理。
我们更进一步,把 catalog 单独做成一个仓库、发布成一个制品:
makefile
com.acme.kmp.catalog:catalog:<版本号>
主工程只需在 settings.gradle.kts 里引用某个 catalog 版本:
kotlin
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from("com.acme.kmp.catalog:catalog:1.6.1")
}
}
}
于是「这次发版用哪些库版本」被收敛成了一个 catalog 版本号。主工程升级依赖,从"改一堆版本号"变成"换一个 catalog 版本号"。
catalog 仓库里维护两样东西:
| 文件 | 作用 |
|---|---|
catalog/libs.versions.toml |
库别名 → 版本号 |
catalog/build.gradle 里的 CATALOG_VERSION |
catalog 自身的大版本号 |
libs.versions.toml 里长这样(示意):
toml
[versions]
account = "1.0.28"
common-base = "1.0.0.0-SNAPSHOT"
[libraries]
acme-account = { module = "com.acme.kmp.biz:account", version.ref = "account" }
acme-common = { group = "com.acme.kmp.libs", name = "common-base", version.ref = "common-base" }
三、分支策略:为什么要"按版本分支"
这套工作流里最值得讲的是分支模型。它分两类分支,各司其职。
3.1 发版列车分支 release/vX.Y.Z_yyyymmdd
我们把一次发版看成一列"发版列车",用一个分支名贯穿三方:
主工程、每个 KMP 库、catalog 仓库,都用同一个 release 分支名对齐 , 例如
release/v3.3.0_20260601。
这样做的好处:
- 天然隔离 :不同发版列车(不同 release 分支)互不影响。A 列车的 catalog 演进到
1.5.x,B 列车可以并行在1.6.x,各自发布、各自被对应主工程分支引用,互不串味。 - 对齐简单:三方靠"同名分支"约定对齐,不需要额外的映射表去记"主工程哪个版本对应库的哪个分支"。
- 可追溯:分支名里带日期,一眼知道是哪条列车。
约定:分支由人工创建并推送,自动化脚本不代为建分支(避免脚本手滑建出一堆分支)。
3.2 CI 脚本专用分支 ci/jenkins(这是个关键设计)
这是我踩过坑之后的一个决定。CI 用到的一堆脚本(升版本、发布、检测变更、发通知的 Python 脚本 + Jenkinsfile)本质是基础设施代码,它不应该散落在每一条 release 分支里。否则:
- 每新建一条发版列车,都得把脚本合并过去;
- 各分支的脚本版本容易不一致,改一处要改 N 处;
- 脚本的实验性改动会直接混进业务发版分支。
所以我把 CI 脚本单独放到一个专用分支 ci/jenkins ,作为 CI 逻辑的唯一真源:
- Jenkins 永远从
ci/jenkins拉脚本; - master / release 上脚本有更新时,由人工合并进
ci/jenkins(一道手动闸门,避免未经检视的脚本直接上线)。
它能成立,靠的是一个小巧思 :升版本脚本运行后会自己执行 git checkout <目标 release 分支>。于是:
- Jenkins 从
ci/jenkins拉哪份脚本,只决定**"用哪份代码来跑"**(脚本进程启动时已把代码载入内存); - 脚本跑起来后自己切到目标发版分支 ,真正被修改 / 发布 / 提交的
libs.versions.toml、版本号,始终是目标分支的内容; - 附带好处:CI 脚本不会泄漏进业务发版分支 (切过去后
git add -A只会提交版本号的预期改动)。
一句话:
ci/jenkins管"用哪份脚本",release/*管"改哪份内容",两者彻底解耦。
四、自动化:两个 CI 任务把流水线跑起来
我用 Jenkins 落地,但思路对任何 CI(GitLab CI / GitHub Actions 等)都通用。两个任务:
4.1 自动任务:库一 push,catalog 自动升版本发布
- 触发 :Git webhook。当某个 KMP 库 push 到
release/*分支时触发。 - 流程 :
- checkout 被推送的库代码 + 从
ci/jenkins拉 CI 脚本; - 检测本次变更涉及哪些"可发布模块" :从变更文件路径反推落在哪些模块目录,再从这些模块的
build.gradle.kts里解析出group:artifactId与version(支持字面量与变量两种写法)。一个仓库可能有多个可发布模块,只取本次实际改动的那些,避免误改/降级没动过的模块; - 更新 catalog 对应分支里这些库的版本号,catalog 大版本末位 +1;
- 重新生成并发布 catalog 制品,把改动 push 回该 release 分支。
- checkout 被推送的库代码 + 从
- 幂等与安全 :本次没有任何库版本实际变化时直接退出,不升版本、不发布、不提交。
开发者的体感是:把库发布到发版分支,几分钟后 catalog 自己升好版本发好了,全程无感。
4.2 手动任务:确立大版本 / 指定版本 / 打补丁
有些操作需要人来决定,做成一个带参数的手动任务,页面上点几下即可:
- 选择目标 release 分支(下拉列最近的几个分支,也可手填);
- 选择更新模式(见下一节);
- 可选地填一批「库坐标 版本号」,顺带更新若干库。
4.3 并发控制
同一条 catalog 发版分支的更新必须串行 (否则并发改同一份 toml 会 git 冲突)。用 CI 的资源锁按分支名加锁即可(Jenkins 的 Lockable Resources / 类似机制),锁资源名用 catalog-<分支名>。
五、版本号规则:四种模式,一套 3 段版本
catalog 大版本固定 3 段 (x.y.z,非 3 段直接报错,不做兼容)。四种更新模式:
| 模式 | 触发方 | 大版本怎么变 | 基线来源 |
|---|---|---|---|
auto |
webhook 自动 | 末位 +1 (1.5.0 → 1.5.1) |
分支→版本号映射文档 |
establish |
手动 | 中间位 +1,末位归零 (1.4.3 → 1.5.0),或直接指定 |
当前 CATALOG_VERSION |
manual |
手动 | 直接采用指定版本(必填) | ---(指定) |
patch |
手动 | 末位 +1 (1.5.3 → 1.5.4) |
分支代码里的 CATALOG_VERSION |
follow |
手动 | 跟随发版分支号 :X.Y.Z.n,第 4 段自增(2.1.0.0 → 2.1.0.1) |
X.Y.Z 取自分支名,n 基于当前版本 |
其中
follow是一个可选的 4 段样式:发版分支叫release/v2.1.0_...时,catalog 版本就取2.1.0.n, 前三段与发版列车号完全一致、第 4 段做迭代计数。好处是版本号本身就映射了发版列车,一眼能对上是哪条线的第几次迭代。 它是"增加一种样式",不改动其它四种模式的行为。
配套一份映射文档 (分支名 → catalog 版本号)跟随各自 release 分支存放,作为:
- 是否执行自动更新的判定依据 :新分支要先经
establish确立大版本、写入映射;映射里没有记录的分支,auto直接跳过,避免误升; - 末位自增的基线来源。
设计取舍:
establish走"中间位 +1"是因为一条新发版列车通常意味着一个新的功能版本;日常库更新只需要"末位 +1"表示同一版本内的迭代。语义清晰,版本号一眼能读出"这是第几列车的第几次迭代"。
六、依赖治理的一道"前置校验":发布前先查库是否真的发布了
这是很实用的一个防呆设计。手动更新 catalog 时,人可能填了某个库的版本号,但那个库其实还没发布到制品库------结果 catalog 发出去了,主工程一拉直接找不到依赖。
于是我加了一道发布前置校验:更新前,把用户填写的库版本逐个到私有制品库核对是否存在。
- 只查自研库(包名前缀是我们自己的 group),三方库不查;
- 校验方式:优先读构件级
maven-metadata.xml的版本列表(release 与 SNAPSHOT 都覆盖),缺失时兜底探测版本目录下的.pom/ 版本级maven-metadata.xml; - 处理策略很关键,分三档:
- 全部没查到 → 阻断本次更新,提醒去检查是否漏发了库;
- 只有部分没查到 ,或因网络/鉴权无法判定 → 不阻断 ,照常发布,但在通知里注明这些存疑的库版本;
- 可用开关一键跳过(应急用)。
"部分缺失不阻断、只告警"这个尺度很重要:既拦住了"整体漏发"的低级错误,又不会因为一个 SNAPSHOT 时序问题把整条流水线卡死。
七、可观测性:每次结果都进 IM 群
流程结束会往团队 IM 群(飞书/钉钉/企业微信皆可,用群机器人 webhook)发一张卡片:
- 成功卡片:分支、版本变化(
1.5.3 → 1.5.4)、库更新列表;不同模式用不同颜色区分; - 有告警(如上一节的"部分库存疑")时,卡片底部追加 ⚠️ 说明;
- 自动任务失败时发红色失败卡片。
一个原则:通知失败绝不影响主流程------发不出通知只记日志,不改变构建结果。别让"发消息"这种边角功能把核心流水线拖挂。
八、把整套流程串起来看
一次典型的发版,时间线大致是:
arduino
1. 建列车:三方仓库各自建同名分支 release/v3.3.0_20260601(人工)
2. 确立版本:手动任务对 catalog 该分支执行 establish
→ catalog 版本 1.4.3 → 1.5.0,写入映射文档
3. 日常迭代:库开发者把库发布到 release 分支
→ webhook 触发自动任务
→ 更新 catalog 里该库版本,catalog 末位 +1(1.5.0 → 1.5.1 → 1.5.2 ...)
→ 发布 catalog + push 回分支 + IM 通知
4. 需要人工干预时:手动任务 patch / manual 指定版本、顺带更新若干库
→ 发布前对自研库做制品存在性校验
5. 主工程只改一个 catalog 版本号,即可拿到这条列车对齐好的全部依赖
一图胜千言:
perl
KMP 库仓库 CI(脚本来自 ci/jenkins) catalog 仓库 / 制品库
┌───────────────┐ push ┌─────────────────────────┐ 改 toml/升版本 ┌──────────────────┐
│ release/* │ ────────▶ │ 检测变更模块 → 升版本 │ ───────────────▶ │ release/* 分支 │
│ 发布库制品 │ webhook │ 发布 catalog → push 回 │ │ 发布 catalog 制品 │
└───────────────┘ │ 前置校验 + IM 通知 │ └──────────────────┘
└─────────────────────────┘
九、几条经验总结
- 把"用哪些版本"收敛成一个可版本化的制品(catalog),是多仓库依赖治理的关键一步。主工程从此只认一个版本号。
- 用同名分支对齐多方,比维护一张跨仓库版本映射表省心得多------约定优于配置。
- CI 脚本单独放一个分支当唯一真源,配合"脚本内部切目标分支"的技巧,既让脚本版本可控,又不污染业务分支。这是我最满意的一处设计。
- 发布前置校验 + 分档处理(阻断/告警),能挡住绝大多数"漏发库"的低级事故,同时不过度僵硬。
- 自动化要幂等:无变化即退出、失败可重试、通知不影响主流程------这些"不出错"的细节,才是流水线能长期稳定跑的原因。
- 版本号规则要有语义:3 段各代表什么、什么时候进哪一位,团队达成共识后,版本号本身就是一份最简洁的发版日志。
十、写在最后
这套工作流不依赖任何特定的 CI 或 IM 平台,核心就三件事:一个可发布的 version catalog、一套清晰的分支/版本约定、一组把约定自动执行的脚本。如果你也在维护多仓库的 KMP(或任意多模块)项目,希望这篇能给你一点思路。
有问题欢迎评论区交流 👋
附录:核心脚本(脱敏摘录)
下面是这套流程里与业务无关的核心逻辑,已从工程里摘出并脱敏(去掉了真实包名前缀、内网地址、IM 机器人凭证、模块清单等)。都是纯标准库 Python,可直接改改常量拿去用。
PS:AI时代了,下面的代码其实没有任何用。直接用AI写吧,有上面的想法+规则,其他的都可以交给AI,调试适合自己项目的自动化方式。
⚠️ 提醒:真正接入时,IM 机器人的
webhook / secret、制品库地址与账号密码等务必走环境变量或密钥管理,不要硬编码进仓库(我踩过这个坑)。
1. 版本号自增(固定 3 段)
python
def parse_three_segments(v: str) -> list[int]:
parts = v.strip().split(".")
if len(parts) != 3:
raise ValueError(f"版本号必须为 3 段:{v!r}")
return [int(p) for p in parts] # 非数字自然抛错
def bump_patch(v: str) -> str: # 1.5.0 -> 1.5.1(日常迭代)
a, b, c = parse_three_segments(v)
return f"{a}.{b}.{c + 1}"
def bump_minor(v: str) -> str: # 1.4.3 -> 1.5.0(确立新列车)
a, b, _ = parse_three_segments(v)
return f"{a}.{b + 1}.0"
2. 库坐标 → catalog 里的版本 key
libs.versions.toml 的 [libraries] 支持 module = "group:artifactId" 或 group+name 两种写法,解析成「坐标 → version.ref」的索引;输入既可以是坐标,也可以直接是 toml key。
python
import toml
def build_module_index(versions_file) -> tuple[dict, set]:
data = toml.load(versions_file)
index = {}
for _alias, entry in data.get("libraries", {}).items():
if not isinstance(entry, dict):
continue
ref = entry.get("version", {}).get("ref") if isinstance(entry.get("version"), dict) else None
if not ref:
continue
if "module" in entry:
coord = entry["module"]
elif "group" in entry and "name" in entry:
coord = f"{entry['group']}:{entry['name']}"
else:
continue
index[coord] = ref
return index, set(data.get("versions", {}).keys())
def resolve_version_key(index, version_keys, coordinate):
if coordinate in index: # 1) group:artifactId
return index[coordinate]
if coordinate in version_keys: # 2) 直接是 toml key(回退)
return coordinate
return None
3. 就地更新 toml 版本号(保留注释与格式)
用正则锚定行首「只允许空白、不允许 #」,天然跳过被注释的同名行------避免误改注释、也不破坏文件格式。
python
import re
def update_versions_in_toml(versions_file, updates: dict) -> int:
with open(versions_file, encoding="utf-8") as f:
content = f.read()
changed = 0
for key, new_version in updates.items():
# ^(\s*) 行首允许空白但不允许 #,自动排除注释行
pattern = rf'^(\s*)({re.escape(key)}\s*=\s*["\'])(.*?)(["\'])'
m = re.search(pattern, content, re.MULTILINE)
if not m or m.group(3) == new_version:
continue
content = content.replace(m.group(0),
f"{m.group(1)}{m.group(2)}{new_version}{m.group(4)}", 1)
changed += 1
if changed:
with open(versions_file, "w", encoding="utf-8") as f:
f.write(content)
return changed
4. 发布前置校验:库版本是否已在制品库
只查自研库(改 SELF_GROUP_PREFIX 为你自己的前缀),三方库不查。优先读构件级 maven-metadata.xml 的版本列表,缺失时兜底探测版本目录。返回 FOUND / MISSING / UNKNOWN 三态------明确不存在才拦,网络/鉴权异常只当"无法判定"不阻断。
python
import base64, urllib.request, urllib.error
import xml.etree.ElementTree as ET
SELF_GROUP_PREFIX = "com.acme" # 换成你自己的 group 前缀
FOUND, MISSING, UNKNOWN = "FOUND", "MISSING", "UNKNOWN"
def is_self_lib(coordinate: str) -> bool:
group = coordinate.split(":", 1)[0]
return group == SELF_GROUP_PREFIX or group.startswith(SELF_GROUP_PREFIX + ".")
def _get(url, user, pw, method="GET"):
headers = {}
if user and pw:
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
headers["Authorization"] = f"Basic {token}"
req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return resp.status, (resp.read() if method != "HEAD" else b"")
except urllib.error.HTTPError as e:
return e.code, None
except Exception:
return None, None # 网络/超时 → 交给上层判 UNKNOWN
def version_exists(repo, group, artifact, version, user=None, pw=None) -> str:
gp = group.replace(".", "/")
status, body = _get(f"{repo.rstrip('/')}/{gp}/{artifact}/maven-metadata.xml", user, pw)
if status and 200 <= status < 300 and body:
versions = [e.text for e in ET.fromstring(body).findall("./versioning/versions/version")]
if version in versions:
return FOUND
# 兜底:release 探 .pom,SNAPSHOT 探版本级 maven-metadata.xml
tail = "maven-metadata.xml" if version.endswith("-SNAPSHOT") else f"{artifact}-{version}.pom"
st, _ = _get(f"{repo.rstrip('/')}/{gp}/{artifact}/{version}/{tail}", user, pw, method="HEAD")
if st and 200 <= st < 300:
return FOUND
if st == 404 and status in (404, 200):
return MISSING
return UNKNOWN
批量校验后按「全部缺失→阻断 / 部分缺失或无法判定→告警」处理即可。
5. IM 通知骨架(凭证走环境变量)
python
import os, json, time, hmac, hashlib, base64, urllib.request
def _sign(ts: int, secret: str) -> str: # 以飞书自定义机器人签名为例
mac = hmac.new(f"{ts}\n{secret}".encode(), digestmod=hashlib.sha256).digest()
return base64.b64encode(mac).decode()
def notify(text: str) -> bool:
url = os.environ.get("IM_WEBHOOK_URL") # 绝不硬编码
secret = os.environ.get("IM_WEBHOOK_SECRET", "")
if not url:
return False # 没配就静默跳过
ts = int(time.time())
body = {"timestamp": str(ts), "sign": _sign(ts, secret),
"msg_type": "text", "content": {"text": text}}
try:
req = urllib.request.Request(url, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception:
return False # 通知失败绝不影响主流程
以上是简化后的示意实现,去掉了工程里的重试、多模式卡片、映射文档读写等细节,但足以说明整套流程的骨架。 真实工程里还叠了:push 前
git pull --rebase重试、按分支加锁串行、多种版本模式、失败告警卡片等。