跳到正文

发布与版本号 ​

代码天天在改,但对外只在一组离散的时刻换样子。版本号就是给这些时刻起名字,让别人能指着某个名字说「我要这个」。

开发与发布 ​

开发环境里改坏了可以直接重来,生产环境里改坏了要赔钱。发布流程把两者之间的门关上,通常包括:跑测试、冻结依赖、定版本号、改配置、部署、对外公告。

changelog 面向使用者,写「升级后行为哪里变了」;git log 面向维护者,写「代码怎么改的」。两者不能互相替代。

环境之间常插一个 staging,配置尽量贴近生产、数据是假的。它的价值只有一个:让「上线才发现」提前成「预发就发现」。

版本号要落到一个具体 commit 上,否则「发的是哪个版本」永远说不清:

bash
git tag -a v1.2.0 -m "release 1.2.0"
git push origin v1.2.0

CI 看到 tag 触发构建,产物名里的版本号与 tag 一致。手工打 tag 和手工改 pyproject.toml 是两处容易不同步的地方,用脚本从 tag 生成版本号能省掉这类事故。

语义化版本 ​

SemVer 规定版本形如 MAJOR.MINOR.PATCH,三段都是整数。规则只有一条核心:不兼容的变更必须动 MAJOR,其余两段允许用户闭眼升级。

变更类型版本位用户该怎么反应
修 bugPATCH可以直接升
加功能MINOR可以直接升
破坏向后兼容MAJOR要读迁移说明再升
bash
1.4.2  ->  1.4.3    # 修 bug
1.4.3  ->  1.5.0    # 加功能,旧调用仍可用
1.5.0  ->  2.0.0    # 接口变了,旧调用可能崩

0.y.z 是初始开发阶段,规范明确说公共接口不保证稳定,所以 0.x 之间的 MINOR 升级也可能破坏兼容。预发布版本写成 1.0.0-rc.1,在排序上小于 1.0.0,包管理器默认不会替你装上。

SemVer 还有一段 build metadata,形如 1.0.0+build.5,它只用于标记构建,不参与版本比较。两个只差 build metadata 的版本被视为同一版本。

破坏性变更不等于立刻删除。健康的做法是先标记废弃、运行时给警告、文档里写替代写法,撑过一个 MINOR 周期再在下一个 MAJOR 删掉。这样 MAJOR 才会罕见,用户也才愿意跟着升。

约束写法 ​

依赖声明写的是允许的集合,不是某个点。Python 的语法由 PEP 440 定义,各工具实现细节略有差异。

写法含义
requests==2.32.3只接受这一个版本
click>=8.08.0 及以上
numpy>=1.24,<2.0区间,两端都要满足
pandas~=2.1.0>=2.1.0 且 <2.2.0
pandas~=2.1>=2.1 且 <3.0
urllib3!=2.0.0排除某个已知有问题的版本

多个包同时给出区间时,解算器要做的是求交集。区间越窄,解出来的可能性越少;约束叠加到几十个包时,会遇到「单个都能满足、合起来无解」的情况,这时工具会报回溯失败,只能放宽某一个约束。

~= 只放开最低位,段数越少放得越宽。npm 和 cargo 的 ^ 语义是「升到下一个 MAJOR 之前」,所以 ^2.1.0 等于 >=2.1.0,<3.0.0,比 ~=2.1.0 宽一圈,跨生态抄配置时容易踩。

另一套常见方案是 CalVer,用发布日期当版本号。Ubuntu 的 24.04 表示 2024 年 4 月发布,一眼能看出新旧,代价是完全不表达兼容性,24.10 相对 24.04 是激进还是保守,只能读发布说明。

预发布版本默认不进候选集。约束写成 >=1.0.0rc1 或者加 --pre,安装器才会考虑它们;否则候选里只有 0.9.8 这种稳定版。

版本号不是保证 ​

维护者也会误判:把一个破坏性改动塞进 PATCH,或者悄悄抬高了某个传递依赖的下限。

bash
uv tree                 # 看清整棵依赖树
uv add 'requests>=2.32' # 改约束后重新解算

依赖树是图不是链,同一个包可能被多个父节点要求不同区间。升级前看树,比在报错栈里猜有效。

每个上线过的版本都要能退回去。回滚预案包括:旧镜像还留着、数据库迁移可逆、配置有版本。没有回滚手段的发布不叫发布,叫赌博。

升级前做三件事:读 changelog,看被改的那几个文件的 diff,在 CI 上跑一遍完整测试。三件都做完再改的版本号,命运通常比 pip install -U 好得多。