发出去的一个版本号,是对依赖你的人许下的承诺。最容易犯的错,是把它当成工作量的流水账——干一轮就升一位,合一个 PR 就在 crates.io 上多一个号。可依赖方不在乎你多辛苦,它在乎的是:它链接的那堆字节,变没变。在 gm-crypto-rs 里,有几个没改输出的周期合进了主干,却没有单独的版本号。原因按周期记在项目记录里;第一次是 0.14,记录说得很直白:把字节完全相同的密码库再发一遍,只是发版噪音。

这些周期,体现成版本线上的缺口。crates.io 上没有 0.14.0。那一轮的活儿是真做了的——一次 cargo-fuzz,十六个 target 扫过不可信输入上的解码 / 解密路径,跑完,零崩溃。它让我对解析器更有底,却没改一个输出字节。要是把它发成 0.14.0,等于跟每个下游说「有东西动了,重测、重审」——可他们能观察到的,什么都没变。于是它作为保障合了进去,没有发布。

更大的缺口是 0.17 到 0.23。那一段是 1.0 前的就绪工作:用 cargo-public-api 做公开 API 漂移检查把接口冻结,把 crypto-bigint 从常开接口里解耦出去,在定稿 API 稳定性之前再请两个 AI 模型做一轮对抗式评审(不是独立审计)。整整七个版本号那么多、又是项目里最要紧的活儿——crates.io 一个都没收。这些改动攒在一起,一次性进了 1.0.0。后来的缺口也各有记录:v1.5 是 TLCP 的分解设计轮次,没有改代码;v1.10 是把 GmSSL 互操作套件钉在固定对照实现(oracle)上、接进 CI 的保障轮次,没有改动任何已发布 crate 的运行时输出;v1.14 和 v1.15 这两轮计时研究性质的保障轮次,则根本没动 crate。

底下的想法,说起来简单、做起来容易破:在别人消费的那个产物变了的时候才发版,而不是在你忙过一阵之后。保障性的活儿——什么都没扫出来的 fuzzing、印证了你预期的审计、为了安心补的测试——恰恰是你最想拿出来给人看的。诱惑就是把它打包成一个 release,好让它显眼。可一个 release 是一道请求:重新解析依赖、重测、重新部署。在字节根本没变的时候提这道请求,等于拿用户的注意力,去给你自己的记账买单。

1.0.0 是这一切收口的地方:三个 crate 一起锁在 =1.0.0 发布,运行时的协议输出(wire)——SM2 的签名和密文、SM4 各模式的字节——和 0.16.0 逐字节一致,并和 GmSSL 在 11 个互操作向量上 11 比 11 对上。1.0 里的破坏性改动只是 API 的形状,不是行为;一个 0.16 的使用方升上来,产出的字节一样。从这儿往后,cargo-semver-checks 把着向前不兼容的变更,下一个号才说话算话。

这是有代价的,而且和常量时间门禁的代价同源:一条带着刻意缺口的版本线,需要解释。一个扫一眼 crates.io 的人,看到 0.13 之后是 0.15、0.16 之后是 1.0,得有人告诉他:缺的那几个号不是弃坑——每一个都是有记录的周期,记录里写明了它为什么没有单独发版。我宁可欠这句解释,也不想把一个 release 说大。完整的历史,每个跳过的周期都点了名、讲清楚了,都写在版本线和公开仓库里。