发出去的一个版本号,是对依赖你的人许下的承诺。最容易犯的错,是把它当成工作量的流水账——干一轮就升一位,合一个 PR 就在 crates.io 上多一个号。可依赖方不在乎你多辛苦,它在乎的是:它链接的那堆字节,变没变。所以 gm-crypto-rs 有条规矩:版本是对输出的承诺,一个没改输出的周期,不配得到一个版本号。

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

更大的缺口是 0.170.23。那一段是 1.0 前的就绪工作:用 cargo-public-api 漂移检查把公开 API 冻住,把 crypto-bigint 从常驻接口里解耦出去,定稿稳定性之前再做一轮多模型对抗式复审。整整七个版本号那么多、又是项目里最要紧的活儿——crates.io 一个都没收。这些改动攒在一起,一次性进了 1.0.0

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

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

这是有代价的,而且和常量时间门禁的代价同源:一条带着刻意缺口的版本线,需要解释。一个扫一眼 crates.io 的人,看到 0.13、然后 0.16、然后 1.0,得有人告诉他:缺的那几个号不是弃坑——是那些拒绝把「只做保障」的变动当成改字节的版本来发的周期。我宁可欠这句解释,也不想把一个 release 说大。完整的历史,每个跳过的周期都点了名、讲清楚了,都写在项目页和公开仓库里。