一套 KMP 多仓库版本治理工作流:用 Version Catalog + CI 把发版流水线自动化

一套 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/* 分支时触发。
  • 流程
    1. checkout 被推送的库代码 + 从 ci/jenkins 拉 CI 脚本;
    2. 检测本次变更涉及哪些"可发布模块" :从变更文件路径反推落在哪些模块目录,再从这些模块的 build.gradle.kts 里解析出 group:artifactIdversion(支持字面量与变量两种写法)。一个仓库可能有多个可发布模块,只取本次实际改动的那些,避免误改/降级没动过的模块;
    3. 更新 catalog 对应分支里这些库的版本号,catalog 大版本末位 +1
    4. 重新生成并发布 catalog 制品,把改动 push 回该 release 分支。
  • 幂等与安全 :本次没有任何库版本实际变化时直接退出,不升版本、不发布、不提交。

开发者的体感是:把库发布到发版分支,几分钟后 catalog 自己升好版本发好了,全程无感。

4.2 手动任务:确立大版本 / 指定版本 / 打补丁

有些操作需要人来决定,做成一个带参数的手动任务,页面上点几下即可:

  • 选择目标 release 分支(下拉列最近的几个分支,也可手填);
  • 选择更新模式(见下一节);
  • 可选地填一批「库坐标 版本号」,顺带更新若干库。

4.3 并发控制

同一条 catalog 发版分支的更新必须串行 (否则并发改同一份 toml 会 git 冲突)。用 CI 的资源锁按分支名加锁即可(Jenkins 的 Lockable Resources / 类似机制),锁资源名用 catalog-<分支名>


五、版本号规则:四种模式,一套 3 段版本

catalog 大版本固定 3 段x.y.z,非 3 段直接报错,不做兼容)。四种更新模式:

模式 触发方 大版本怎么变 基线来源
auto webhook 自动 末位 +11.5.0 → 1.5.1 分支→版本号映射文档
establish 手动 中间位 +1,末位归零1.4.3 → 1.5.0),或直接指定 当前 CATALOG_VERSION
manual 手动 直接采用指定版本(必填) ---(指定)
patch 手动 末位 +11.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 通知        │                  └──────────────────┘
                             └─────────────────────────┘

九、几条经验总结

  1. 把"用哪些版本"收敛成一个可版本化的制品(catalog),是多仓库依赖治理的关键一步。主工程从此只认一个版本号。
  2. 用同名分支对齐多方,比维护一张跨仓库版本映射表省心得多------约定优于配置。
  3. CI 脚本单独放一个分支当唯一真源,配合"脚本内部切目标分支"的技巧,既让脚本版本可控,又不污染业务分支。这是我最满意的一处设计。
  4. 发布前置校验 + 分档处理(阻断/告警),能挡住绝大多数"漏发库"的低级事故,同时不过度僵硬。
  5. 自动化要幂等:无变化即退出、失败可重试、通知不影响主流程------这些"不出错"的细节,才是流水线能长期稳定跑的原因。
  6. 版本号规则要有语义: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 重试、按分支加锁串行、多种版本模式、失败告警卡片等。

相关推荐
码艺-Alimjan2 小时前
Vue项目源码备份最佳实践:无视node_modules,打包体积仅2MB
前端·javascript·vue.js
隔窗听雨眠2 小时前
esbuild构建工具简介:重新定义前端构建速度的极速打包器
前端
蔓越莓2 小时前
Webpack 常用 Loader +手写Loader
前端·面试
潍坊老登3 小时前
写了一个某音自动刷视频,通过大模型分析视频主题的Android应用
前端
蔓越莓3 小时前
性能优化:webpack打包层面优化
前端·面试
TJHHH.3 小时前
XSS-labs-master
前端·web安全·xss
风月说与山鬼3 小时前
十二、Vue插件
前端·vue.js
spter。3 小时前
一次 Redis SETNX 的小插曲,我重新理解了原子性
前端·redis·bootstrap