Python 项目怎么做版本管理和发布?语义化版本要注意什么?
简化版
版本号是给使用者的「兼容性承诺」——语义化版本(SemVer)MAJOR.MINOR.PATCH 的含义是:MAJOR 变了表示有破坏性变更(使用者升级需要改代码)、MINOR 表示新增了向后兼容的功能、PATCH 表示只是修 bug。所以决定升哪一位的依据不是「改动量大小」,而是「对使用者的兼容性影响」——改了一行导致行为不兼容也要升 MAJOR,重构了一万行但接口没变只是 PATCH。Python 有自己的版本规范 PEP 440,和 SemVer 大体兼容但语法不同:预发布是 1.0.0rc1 而不是 1.0.0-rc.1,还多了 .post1(发布后的元数据修正)和 .dev0(开发版)。版本号存在哪也有讲究:现代做法是单一数据源——要么写在 pyproject.toml 里用 importlib.metadata.version() 读取,要么用 setuptools-scm/hatch-vcs 从 git tag 自动推导(连文件都不用改)。发布流程的自动化程度决定了发布频率:手工 python -m build && twine upload 容易出错也容易忘;成熟做法是 git tag 触发 CI → 构建 → 发布到 PyPI,而且现在应该用 Trusted Publishing(OIDC)而不是长期有效的 API token——它不需要在 CI 里存任何密钥。CHANGELOG 是版本号的说明书:只写版本号「1.2.0 → 2.0.0」而不说明「什么变了、怎么迁移」,使用者依然不敢升级。内部服务和公开库的策略不同——公开库必须严格遵守 SemVer,内部服务可以用 CalVer(日期版本)或直接用 commit hash。核心记忆:版本号是兼容性承诺,看影响不看改动量;PEP 440 的语法和 SemVer 不同;版本号单一数据源,最好从 git tag 推导;用 Trusted Publishing 代替 API token。
详细版
版本号变更规则:
| 变更类型 | 升哪一位 | 例子 |
|---|---|---|
| 删除/重命名公开 API | MAJOR | 删函数、改参数名 |
| 改变默认行为 | MAJOR | 默认值变了、返回结构变了 |
| 新增必填参数 | MAJOR | 老代码调用会报错 |
| 新增功能/可选参数 | MINOR | 加新函数、加默认参数 |
| 标记弃用 | MINOR | 加 DeprecationWarning |
| 修 bug | PATCH | 行为修正为「本来该有的」 |
| 重构/性能优化 | PATCH | 接口没变 |
# ① ★★版本号的单一数据源(三种方案)★★
# ★方案 A:写在 pyproject.toml(最简单)★
[project]
name = "mypackage"
★version = "1.2.3"★
# 代码里读取
# from importlib.metadata import version
# ★__version__ = version("mypackage")★ # ★★不要手写第二份★★
# ★方案 B:从 __init__.py 动态读取★
[project]
★dynamic = ["version"]★
[tool.setuptools.dynamic]
★version = {attr = "mypackage.__version__"}★
# ★★方案 C:从 git tag 自动推导(推荐)★★
[build-system]
requires = ["hatchling", ★"hatch-vcs"★]
[project]
★dynamic = ["version"]★
[tool.hatch.version]
★source = "vcs"★ # ★★版本号 = git tag★★
# ★或 setuptools-scm★
[tool.setuptools_scm]
★version_file = "src/mypackage/_version.py"★
# ★效果:打了 v1.2.3 的 tag → 版本就是 1.2.3★
# ★★未打 tag 的提交 → 1.2.4.dev5+g1a2b3c(自动带 dev 标记)★★
# ② ★★PEP 440 的版本语法(和 SemVer 不同)★★
"1.0.0" # 正式版
"1.0.0★a1★" # alpha(SemVer 是 1.0.0-alpha.1)
"1.0.0★b2★" # beta
"1.0.0★rc1★" # ★release candidate★
"1.0.0★.post1★" # ★★发布后修正(只改元数据,代码没变)★★
"1.0.0★.dev3★" # ★开发版(排在正式版之前)★
"1.0.0★+local★" # ★本地版本标识(PyPI 不接受)★
# ★★排序:1.0.0.dev1 < 1.0.0a1 < 1.0.0b1 < 1.0.0rc1 < 1.0.0 < 1.0.1★★
# ③ ★★弃用流程(不能说删就删)★★
import warnings
def old_function(x):
★warnings.warn(★
"old_function 已弃用,请改用 new_function(),"
"★将在 3.0 移除★",
★DeprecationWarning★,
★stacklevel=2★, # ★★指向调用方而不是这里★★
)
return new_function(x)
# ★★流程:1.5 标记弃用 → 1.6/1.7 保留 → 2.0 才移除★★
# ★★至少给使用者一个 MINOR 版本的过渡期★★
# ④ ★发布检查(本地)★
python -m build # ★生成 sdist + wheel★
★twine check dist/*★ # ★★检查元数据和 README 渲染★★
pip install ★dist/mypackage-1.2.3-py3-none-any.whl★ # ★装一遍试试★
# ★先发到 TestPyPI 验证★
★twine upload --repository testpypi dist/*★
# ⑤ ★★GitHub Actions + Trusted Publishing(★推荐★)★★
name: publish
on:
push:
★tags: ["v*"]★ # ★★打 tag 触发★★
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: ★fetch-depth: 0★ # ★★setuptools-scm 需要完整历史★★
- run: pipx run build
- uses: actions/upload-artifact@v4
with: {name: dist, path: dist/}
publish:
needs: build
runs-on: ubuntu-latest
★environment: release★ # ★可加人工审批★
★permissions: {id-token: write}★ # ★★OIDC,不需要 token★★
steps:
- uses: actions/download-artifact@v4
with: {name: dist, path: dist/}
- uses: ★pypa/gh-action-pypi-publish@release/v1★
# ★★不需要配置任何 secret!★★
⚠️ 三个必须记住的点:① 决定升哪一位版本的依据是「对使用者的兼容性影响」,不是「改动量大小」。改一行代码把某个函数的默认参数从
True改成False——这是破坏性变更,必须升 MAJOR,因为所有依赖默认行为的使用者升级后行为都会变;反过来,你重构了整个内部实现、删掉了几千行代码,只要公开接口和行为没变,那就是 PATCH。判断标准很实用:「使用者原封不动升级,代码还能跑吗、行为还一样吗」——不能就是 MAJOR。② Python 用的是 PEP 440 而不是标准 SemVer,语法有实质差别:预发布版本是 **1.0.0rc1(没有连字符和点)**而不是 SemVer 的1.0.0-rc.1;此外 PEP 440 独有.postN(发布后修正元数据,比如 README 写错了但代码没变,不占用新版本号)和.devN(开发版,排序上早于同版本号的正式版和预发布版)。写错语法会导致 pip 的版本比较和依赖解析出问题。③ 版本号必须只有一个数据源。常见的错误是同时写在pyproject.toml、__init__.py的__version__、setup.py、docs/conf.py里——发版时忘了改其中一处就会不一致,而且这类问题往往在很久之后才被发现。现代做法有两种:在pyproject.toml里写一份,代码里用importlib.metadata.version("包名")读;或者更彻底——用setuptools-scm/hatch-vcs从 git tag 推导,连版本号文件都不需要维护,打 tag 即发版。
完整版教学
一、语义化版本的实质
★ ★★SemVer 的核心:版本号是一份契约★★:
★MAJOR★:★破坏性变更——使用者升级需要改代码★
★MINOR★:★新增功能,向后兼容★
★PATCH★:★修 bug,向后兼容★
★ ★它回答的问题是:★
★"我从 1.2.3 升到 X,需要做什么?"★
- ★1.2.4 → 什么都不用做★
- ★1.3.0 → 什么都不用做(可以用新功能)★
- ★2.0.0 → ★★读 CHANGELOG,可能要改代码★★★
★ ★★判断标准:影响而非工作量★★:
┌────────────────────────────────────────────────────┐
│ ★改一行 → MAJOR★:把默认参数 True 改成 False │
│ ★重构一万行 → PATCH★:内部实现全换,接口不变 │
│ ★删一个"没人用"的函数 → 仍然是 MAJOR★ │
│ (★你不知道谁在用★) │
└────────────────────────────────────────────────────┘
★ ★问自己:使用者原封不动升级,还能跑吗、行为一样吗?★
★ ★★哪些变更容易被误判★★:
✗ ★"只是改了返回的字典多加一个 key" → 可能是 MAJOR★
(如果有人做了严格的结构断言,或者用 ** 解包传给别的函数)
✗ ★"只是修了个 bug" → 如果有人依赖了这个 bug 的行为★
(Hyrum's Law:★★接口的所有可观察行为都会被人依赖★★)
✗ ★"只是提高了最低 Python 版本" → 是 MAJOR★
✗ ★"只是收紧了输入校验" → 原本能过的输入现在报错 = MAJOR★
✗ ★"只是改了异常类型" → MAJOR★
✓ ★新增可选参数(有默认值)→ MINOR★
✓ ★新增函数/类 → MINOR★
★ ★0.x 的特殊约定★:
★0.y.z★:★SemVer 规定"任何版本都可能不兼容"★
★ 实践中的常见做法(★不是标准但很普遍★):
★0.y.z 里把 y 当 MAJOR、z 当 MINOR/PATCH★
→ ★0.5.0 → 0.6.0 可能不兼容★
★ ✗ ★所以依赖 0.x 的包要谨慎锁版本★
★ ★1.0 的含义:你愿意为兼容性负责了★
★ ★★"公开 API"的边界要明确★★:
★ ★SemVer 只对"公开 API"负责★——但什么算公开?
✓ ★用 __all__ 显式声明★
✓ ★下划线开头的视为私有★
✓ ★文档里写清楚哪些是公开的★
✓ ★内部模块放 _internal 包★
★ ✗ ★没有明确边界 → 用户会依赖内部实现 → 你寸步难行★
★ ★SemVer 的现实局限(★诚实的看法★)★:
✗ ★"完全向后兼容"很难保证★(Hyrum's Law)
✗ ★MAJOR 升级的成本让维护者不敢升★
→ ★结果:破坏性变更被塞进 MINOR★
✗ ★依赖解析的地狱★(菱形依赖、版本冲突)
★ ★但它仍然是目前最好的沟通协议★
★ ★关键是配合 CHANGELOG 和弃用流程一起用★
SemVer 的核心是「版本号是一份契约」——它回答「我从 1.2.3 升到 X 需要做什么」。判断标准是「影响而非工作量」:改一行默认参数是 MAJOR、重构一万行只要接口没变就是 PATCH、删一个「没人用」的函数仍然是 MAJOR(因为你不知道谁在用)。几类容易被误判的变更:返回字典多加一个 key(可能有人做了严格断言)、修 bug(如果有人依赖了这个 bug 的行为——这就是 Hyrum’s Law:接口的所有可观察行为都会被人依赖)、提高最低 Python 版本、收紧输入校验、改异常类型——这些都是 MAJOR。0.x 按 SemVer 规定任何版本都可能不兼容,实践中常把 y 当 MAJOR 用。必须明确「公开 API」的边界(__all__、下划线私有、_internal 包),否则用户会依赖内部实现让你寸步难行。
二、PEP 440 与 Python 的特殊性
★ ★★PEP 440 vs SemVer 的语法差异★★:
┌──────────────┬──────────────────┬──────────────────┐
│ 含义 │ ★SemVer★ │ ★PEP 440(Python)★│
├──────────────┼──────────────────┼──────────────────┤
│ alpha │ 1.0.0-alpha.1 │ ★1.0.0a1★ │
│ beta │ 1.0.0-beta.2 │ ★1.0.0b2★ │
│ rc │ 1.0.0-rc.1 │ ★1.0.0rc1★ │
│ 构建元数据 │ 1.0.0+build.1 │ ★1.0.0+local★ │
│ ★发布后修正★ │ ★(没有)★ │ ★★1.0.0.post1★★ │
│ ★开发版★ │ ★(没有)★ │ ★★1.0.0.dev3★★ │
└──────────────┴──────────────────┴──────────────────┘
★ ★写成 SemVer 语法 pip 也能装,但排序可能不符预期★
★ ★★版本排序(★依赖解析的基础★)★★:
★1.0.0.dev1 < 1.0.0a1 < 1.0.0a2 < 1.0.0b1 < 1.0.0rc1
< 1.0.0 < 1.0.0.post1 < 1.0.1★
★ ★注意:★
- ★.dev 排在最前(比 alpha 还早)★
- ★.post 排在正式版之后★
- ★默认 pip 不装预发布版★(要 --pre)
★ ★.post 的正确用法★:
★ 场景:★包已经发布了,但 README 有错别字 / 分类器写错了★
★ ✗ 不能重新上传同一个版本(★PyPI 不允许覆盖★)
★ ✗ 升 PATCH 会让人以为代码变了
✓ ★发 1.2.3.post1★——★明确表示"代码没变,只是元数据修正"★
★ ★.dev 的用法★:
★ ✓ ★CI 上每次提交自动发到内部索引★:
1.3.0.dev42+g1a2b3c
★ ✓ ★setuptools-scm 自动生成★
★ ✗ ★带 + 的本地版本标识 PyPI 不接受★
→ 内部索引可以
★ ★版本约束的写法(给使用者看)★:
★==1.2.3★ 精确
★>=1.2,<2.0★ ★兼容当前 MAJOR★
★~=1.2.3★ ★★等价 >=1.2.3,<1.3.0(PEP 440 的"兼容发布")★★
★~=1.2★ 等价 >=1.2,<2.0
★!=1.5.0★ 排除某个坏版本
★ ★库(library)★:★约束要宽松★(>=1.2,<3.0)
→ ★太严会导致使用者的依赖冲突★
★ ★应用(application)★:★用 lock 文件锁死★
→ uv.lock / poetry.lock / requirements.txt(pip-compile)
★ ★★CalVer:另一种选择★★:
★2026.8.1★ / ★26.8★ / ★2026.08.03★
★ ✓ 适合:
- ★内部服务★(没有外部使用者)
- ★发布节奏固定的项目★(Ubuntu、PyCharm)
- ★"兼容性承诺"没有意义时★
★ ✗ 不适合:★被别人依赖的库★(说不出兼容性信息)
★ 例子:pip 用 CalVer(★23.0、24.0★)、Django 用混合
★ ★Python 版本支持策略★:
★ SPEC 0 / NEP 29(科学计算生态的约定):
★支持最近 3 年内发布的 Python 版本★
★ ★提高最低 Python 版本 = MAJOR 变更★
[project]
★requires-python = ">=3.10"★
★ ✓ ★写清楚,pip 会拒绝在不支持的版本上安装★
PEP 440 和 SemVer 的语法有实质差别:预发布是 1.0.0rc1(没有连字符)、独有 .postN(发布后修正元数据)和 .devN。版本排序是依赖解析的基础:.dev 排在最前(比 alpha 还早)、.post 排在正式版之后、pip 默认不装预发布版(要 --pre)。.post 的正确场景是「代码没变,只是 README 或分类器写错了」——因为 PyPI 不允许覆盖已发布的版本,而升 PATCH 会让人以为代码变了。版本约束的写法要区分库和应用:库的约束要宽松(太严会导致使用者的依赖冲突),应用用 lock 文件锁死。CalVer 适合内部服务和发布节奏固定的项目,但不适合被别人依赖的库(说不出兼容性信息)。
三、发布流程
★ ★★手工发布的问题★★:
python -m build && twine upload dist/*
★ ✗ ★容易忘步骤★(忘了打 tag、忘了更新 CHANGELOG)
★ ✗ ★本地环境不干净★(dist/ 里有旧文件)
★ ✗ ★token 存在本地★
★ ✗ ★"只有某个人能发版"★
★ ★★自动化发布流程(推荐)★★:
┌────────────────────────────────────────────────────┐
│ ① ★更新 CHANGELOG★ │
│ ② ★git tag v1.2.3 && git push --tags★ │
│ ③ ★CI 检测到 tag★ │
│ ④ ★跑完整测试 + lint + 类型检查★ │
│ ⑤ ★build(sdist + wheel)★ │
│ ⑥ ★twine check★ │
│ ⑦ ★发布到 PyPI(★Trusted Publishing★)★ │
│ ⑧ ★创建 GitHub Release(附 CHANGELOG)★ │
└────────────────────────────────────────────────────┘
★ ★★Trusted Publishing(★现在的最佳实践★)★★:
★ 原理:★OIDC——CI 向 PyPI 证明"我是这个仓库的这个 workflow"★
★ ✓ ★不需要在 CI 里存任何 API token★
★ ✓ ★token 泄露的风险归零★
★ ✓ ★短期凭证,自动轮换★
★ 配置:
① PyPI 项目设置里添加「Trusted Publisher」
(填仓库名、workflow 文件名、environment)
② workflow 里:
★permissions: {id-token: write}★
★- uses: pypa/gh-action-pypi-publish@release/v1★
③ ★★不需要任何 secret★★
★ ★构建产物★:
★sdist(.tar.gz)★:★源码分发,需要在用户机器上构建★
★wheel(.whl)★:★★预构建,安装快,纯 Python 包用 py3-none-any★★
★ ✓ ★两个都要发★(sdist 是"最后的兜底")
★ ✗ ★有 C 扩展 → 需要为多平台构建 wheel★
→ ★cibuildwheel★(在 CI 里为各平台/Python 版本构建)
★ ★发布前的检查清单★:
□ ★测试全绿(含目标 Python 版本矩阵)★
□ ★CHANGELOG 更新了★
□ ★版本号正确(或由 tag 自动推导)★
□ ★twine check dist/*(README 能正常渲染)★
□ ★在干净环境里装一遍试试★
□ ★先发 TestPyPI 验证★
□ ★README 里的链接和示例是最新的★
□ ★依赖约束合理(不要过严)★
★ ★★发布后不能撤回(★重要★)★★:
★ ✗ ★PyPI 不允许重新上传同一个版本号★
★ ✗ ★yank 只是"标记为不推荐"(已有的锁文件仍能装)★
★ ✗ ★delete 会让依赖它的构建全挂★(★强烈不建议★)
✓ ★发错了 → 发一个新的 PATCH 修正★
✓ ★发了有严重问题的版本 → yank 它 + 立刻发修复版★
★ ★所以:TestPyPI 和干净环境验证很重要★
★ ★★版本发布的自动化工具★★:
┌──────────────────────┬────────────────────────────┐
│ ★bump2version/bump-my-version★│ ★改版本号 + 打 tag★ │
│ ★commitizen★ │ ★规范化提交 + 自动版本★ │
│ ★python-semantic-release★│ ★★从 commit message 自动★★│
│ │ ★★决定版本号 + 生成 CHANGELOG★★│
│ ★release-please★ │ Google 的,生成 release PR │
│ ★towncrier★ │ ★★从碎片文件生成 CHANGELOG★★│
└──────────────────────┴────────────────────────────┘
★ ★Conventional Commits + semantic-release 的组合:★
★feat: xxx★ → MINOR
★fix: xxx★ → PATCH
★feat!: xxx★ 或 ★BREAKING CHANGE:★ → MAJOR
→ ★★完全自动化:提交规范 → 版本号 → CHANGELOG → 发布★★
手工发布容易忘步骤、环境不干净、token 存本地、且「只有某个人能发版」。自动化流程是「更新 CHANGELOG → 打 tag → CI 跑测试 → build → 发布 → 创建 Release」。Trusted Publishing 是现在的最佳实践——原理是 OIDC:CI 向 PyPI 证明「我是这个仓库的这个 workflow」,不需要在 CI 里存任何 API token,泄露风险归零。发布后不能撤回是重要约束:PyPI 不允许重新上传同一个版本号、yank 只是标记为不推荐、delete 会让依赖它的构建全挂——所以发错了只能发一个新的 PATCH 修正,这也是 TestPyPI 验证重要的原因。自动化工具里 Conventional Commits + semantic-release 能做到「提交规范 → 版本号 → CHANGELOG → 发布」全自动。
四、CHANGELOG 与弃用
★ ★★CHANGELOG 是版本号的说明书★★:
★ ★只有版本号没有说明 → 使用者不敢升级★
★ ★Keep a Changelog 的格式★:
## [2.0.0] - 2026-08-03
### ★Added★(新增)
- 支持异步客户端 (#123)
### ★Changed★(变更)
- ★★BREAKING★★: `connect()` 的 timeout 参数默认值从 30 改为 10
### ★Deprecated★(弃用)
- `old_api()` 将在 3.0 移除,请用 `new_api()`
### ★Removed★(移除)
- ★★BREAKING★★: 移除了 1.5 起弃用的 `legacy_mode`
### ★Fixed★(修复)
- 修复并发下连接池泄漏 (#456)
### ★Security★(安全)
- 升级依赖修复 CVE-2026-xxxx
★ ★★好的 CHANGELOG 条目★★:
✗ ★"修复了一些 bug"★ # ★★毫无信息★★
✗ ★"重构了内部实现"★ # 使用者不关心
✗ ★"更新依赖"★
✓ ★写"对使用者的影响"★:
"★修复了 timeout=0 时会无限等待的问题★ (#456)"
"★BREAKING: parse() 现在对非法输入抛 ValueError 而不是返回 None★
★迁移:把 `if r is None` 改成 try/except ValueError★"
★ ★破坏性变更必须写"怎么迁移"★
★ ★★用 towncrier 避免合并冲突★★:
★ 问题:★所有人都改 CHANGELOG.md 的同一个位置 → 天天冲突★
✓ ★每个 PR 加一个碎片文件★:
changelog.d/★123.feature★.md → "支持异步客户端"
changelog.d/★456.bugfix★.md → "修复连接池泄漏"
✓ ★发版时 towncrier build 合并成 CHANGELOG★
★ ✓ ★零冲突 + 强制每个 PR 说明影响★
★ ★★弃用流程(★不能说删就删★)★★:
┌────────────────────────────────────────────────────┐
│ ★阶段 1(1.5.0)★:加 DeprecationWarning + 文档标注 │
│ ★同时提供替代方案★ │
│ ★阶段 2(1.6、1.7)★:★保留至少一个 MINOR 周期★ │
│ ★CHANGELOG 反复提醒★ │
│ ★阶段 3(2.0.0)★:★才真正移除★ │
└────────────────────────────────────────────────────┘
import warnings
def old_api(x):
warnings.warn(
"old_api 已弃用,请用 new_api(),★将在 3.0 移除★",
★DeprecationWarning★,
★stacklevel=2★, # ★★指向调用方★★
)
★ ★stacklevel=2 很重要★:不加的话警告指向库内部,
★使用者看不出是自己哪行代码触发的★
★ ✗ ★DeprecationWarning 默认不显示★(只在 __main__ 和测试里显示)
→ ★所以还要在 CHANGELOG 和文档里说★
★ ★弃用的沟通渠道(★多管齐下★)★:
□ ★DeprecationWarning(运行时)★
□ ★CHANGELOG★
□ ★文档里划掉 + 标注替代方案★
□ ★类型注解上加 @deprecated(3.13+ 的 warnings.deprecated)★
□ ★发布公告 / issue★
□ ★大版本的迁移指南(MIGRATION.md)★
★ ★★破坏性变更的减痛技巧★★:
✓ ★提供兼容层★:
def new_api(x, *, timeout=10): ...
def old_api(x, timeout=30): # ★保留旧默认值★
warnings.warn(...); return new_api(x, timeout=timeout)
✓ ★提供自动迁移工具★(如 2to3、codemod 脚本)
✓ ★MAJOR 版本发布前先发 rc 让人试★
✓ ★写详细的迁移指南(每个变更 + 前后代码对比)★
CHANGELOG 是版本号的说明书——只有版本号没有说明,使用者依然不敢升级。好条目的标准是「写对使用者的影响」而不是「改了什么代码」:「修复了一些 bug」毫无信息,而「修复了 timeout=0 时会无限等待的问题」才有用;破坏性变更必须写「怎么迁移」。towncrier 用碎片文件避免 CHANGELOG 的合并冲突(每个 PR 加一个小文件、发版时合并),顺便强制每个 PR 说明影响。弃用流程分三阶段:标记弃用(加 DeprecationWarning 并提供替代)→ 保留至少一个 MINOR 周期 → MAJOR 版本才移除。stacklevel=2 很重要——不加的话警告指向库内部,使用者看不出是自己哪行代码触发的;而且 DeprecationWarning 默认不显示,所以还要在 CHANGELOG 和文档里说。
五、内部服务 vs 公开库
★ ★★两者的策略完全不同★★:
┌──────────────────┬──────────────────┬──────────────────┐
│ │ ★公开库★ │ ★内部服务★ │
├──────────────────┼──────────────────┼──────────────────┤
│ ★版本方案★ │ ★SemVer(必须)★ │ ★CalVer / hash★ │
│ ★兼容性承诺★ │ ★★严格★★ │ ★可以不承诺★ │
│ ★依赖约束★ │ ★宽松(>=,<)★ │ ★lock 锁死★ │
│ ★CHANGELOG★ │ ★必须★ │ ★可选(但推荐)★ │
│ ★弃用周期★ │ ★数月~一年★ │ ★可以很短★ │
│ ★发布频率★ │ 低 │ ★高(每天多次)★ │
└──────────────────┴──────────────────┴──────────────────┘
★ ★★库的依赖约束:宽松是义务★★:
# ✗ 库里写死
★dependencies = ["requests==2.31.0"]★
→ ★★使用者如果需要 2.32 就装不上(依赖地狱)★★
# ✓ 库里给范围
★dependencies = ["requests>=2.28,<3.0"]★
★ ★原则:库只声明"我需要什么能力",不替使用者决定具体版本★
★ ★应用才用 lock 文件锁死★
★ ★应用的版本管理★:
✓ ★lock 文件必须提交★(uv.lock / poetry.lock / requirements.txt)
✓ ★镜像 tag 用 git sha 或 CalVer★
✓ ★可重复构建:同一个 commit 装出同样的依赖★
✓ ★依赖更新用 Dependabot/Renovate 自动开 PR★
★ ★★单体仓库(monorepo)的版本★★:
★ ① ★统一版本★:所有包同一个版本号(如 Angular)
✓ 简单
✗ ★没改的包也要发新版★
★ ② ★独立版本★:各包各自的版本
✓ 精确
✗ ★依赖关系管理复杂★
★ 工具:uv workspace / hatch / pants / bazel
★ ★★内部包的私有索引★★:
# ★发布到私有 PyPI(devpi、Artifactory、CodeArtifact)★
★twine upload --repository-url https://pypi.internal/ dist/*★
# ★安装★
★pip install --index-url https://pypi.internal/simple/ mypackage★
# ★或 pyproject.toml★
★[[tool.uv.index]]★
★name = "internal"★
★url = "https://pypi.internal/simple/"★
★ ✗ ★注意依赖混淆攻击(dependency confusion)★:
★如果内部包名在公共 PyPI 上被别人抢注 → 可能装到恶意包★
✓ ★用 --index-url 而不是 --extra-index-url★
✓ ★或者内部包名统一加前缀并在 PyPI 上占位★
★ ★★版本与 API 兼容性的实际保障★★:
✓ ★契约测试★:用旧版本的调用方式测新版本
✓ ★公开 API 的快照测试★:
def test_public_api(snapshot):
★assert sorted(dir(mypackage)) == snapshot★
→ ★★意外删除/改名会被发现★★
✓ ★类型存根的兼容性检查★(griffe、mypy 的 --strict)
✓ ★下游集成测试★:在 CI 里跑主要使用者的测试
★ ★"我以为没有破坏性变更"是最危险的假设★
★ ★★发布节奏的建议★★:
★ ✓ ★小步快跑★:频繁发 PATCH/MINOR,比攒大版本好
★ ✓ ★MAJOR 提前预告★(发 rc、写迁移指南、留足时间)
★ ✓ ★安全修复要快★(yank 有问题的版本 + 立刻发修复版)
★ ✗ ★攒半年发一个大版本★ → ★★升级成本高,使用者不敢升★★
公开库和内部服务的策略完全不同:库必须严格 SemVer、依赖约束要宽松(写死版本会导致使用者的依赖地狱——库只声明「我需要什么能力」,不替使用者决定具体版本)、弃用周期要长;内部服务可以用 CalVer 或 git sha、用 lock 文件锁死依赖、发布频率高。私有索引要注意依赖混淆攻击——如果内部包名在公共 PyPI 上被别人抢注可能装到恶意包,要用 --index-url 而不是 --extra-index-url。保障兼容性的实际手段:契约测试、公开 API 的快照测试(assert sorted(dir(pkg)) == snapshot,意外删除或改名会被发现)、下游集成测试——「我以为没有破坏性变更」是最危险的假设。发布节奏上小步快跑好过攒大版本。
六、实践清单
★ ★推荐的现代配置★:
# pyproject.toml
[build-system]
requires = ["hatchling", ★"hatch-vcs"★]
build-backend = "hatchling.build"
[project]
name = "mypackage"
★dynamic = ["version"]★ # ★★版本来自 git tag★★
★requires-python = ">=3.10"★
dependencies = [★"httpx>=0.27,<1.0"★] # ★宽松约束★
[tool.hatch.version]
★source = "vcs"★
# ★代码里★
from importlib.metadata import version
★__version__ = version("mypackage")★ # ★★单一数据源★★
★ 检查清单:
【版本号】
□ ★升版本看的是"兼容性影响"不是"改动量"★
□ ★版本号只有一个数据源(最好从 git tag)★
□ ★用 PEP 440 语法(1.0.0rc1 不是 1.0.0-rc.1)★
□ ★requires-python 写清楚★
【发布】
□ ★打 tag 触发 CI 自动发布★
□ ★用 Trusted Publishing 而不是 API token★
□ ★sdist 和 wheel 都发★
□ ★twine check 通过★
□ ★先在 TestPyPI 或干净环境验证★
□ ★发布前跑完整测试矩阵★
【沟通】
□ ★CHANGELOG 写"对使用者的影响"★
□ ★破坏性变更写迁移方法★
□ ★弃用给至少一个 MINOR 的过渡期★
□ ★DeprecationWarning 加 stacklevel=2★
□ ★MAJOR 版本有迁移指南★
【依赖】
□ ★库的约束宽松,应用用 lock 锁死★
□ ★私有索引防依赖混淆(用 --index-url)★
★ ★★常见错误速查★★:
┌────────────────────────────────────┬──────────────────┐
│ 版本号不一致 │ ★多处硬编码★ │
│ 发布后发现 README 渲染错 │ ★没跑 twine check★│
│ 想撤回已发布的版本 │ ★★只能 yank+新版★★│
│ 使用者报依赖冲突 │ ★库的约束太严★ │
│ 用户没注意到破坏性变更 │ ★CHANGELOG 没写清★│
│ CI 发布失败:版本已存在 │ ★tag 重复/没删 dist★│
│ setuptools-scm 版本号是 0.1.dev1 │ ★★fetch-depth 不够★★│
└────────────────────────────────────┴──────────────────┘
★ 一句话总结:
★"版本号是给使用者的兼容性承诺——升哪一位取决于『对使用者的影响』
而不是『改动量』,改一行默认值也可能是 MAJOR;
Python 用 PEP 440(1.0.0rc1、独有 .post 和 .dev);
版本号要单一数据源,最好用 setuptools-scm/hatch-vcs 从 git tag 推导;
发布走『打 tag → CI → Trusted Publishing』,不要在 CI 里存 token;
CHANGELOG 要写『对使用者的影响』和迁移方法,
弃用必须留过渡期——因为 PyPI 上发出去的版本撤不回来。"★
推荐配置的核心是 hatch-vcs 从 git tag 推导版本 + importlib.metadata 单一数据源。错误速查表里有个高频坑:setuptools-scm 算出 0.1.dev1 是因为 CI 里 fetch-depth 不够(没拉到 tag),要设 fetch-depth: 0。
记忆钩子:「★版本号是给使用者的『兼容性承诺』★——SemVer 的 MAJOR/MINOR/PATCH 回答的是『我从 1.2.3 升到 X 需要做什么』。★决定升哪一位的依据是『对使用者的兼容性影响』而不是『改动量大小』★:★改一行把默认参数 True 改成 False 是 MAJOR,重构一万行只要接口没变就是 PATCH,删一个『没人用』的函数仍然是 MAJOR(你不知道谁在用)★。★容易被误判的★:返回字典多加一个 key、★修 bug(如果有人依赖了这个 bug——Hyrum’s Law:接口的所有可观察行为都会被人依赖)★、提高最低 Python 版本、收紧输入校验、改异常类型——★这些都是 MAJOR★。★Python 用 PEP 440 而不是标准 SemVer★:★预发布是 1.0.0rc1(没有连字符)★,独有 ★.postN(发布后只改元数据,因为 PyPI 不允许覆盖已发布版本,而升 PATCH 会让人以为代码变了)★ 和 ★.devN★;★排序是 dev < a < b < rc < 正式版 < post★,★pip 默认不装预发布版(要 —pre)★。★版本号必须单一数据源★——常见错误是同时写在 pyproject.toml、init.py、setup.py、docs/conf.py 里,★发版忘改一处就不一致★;现代做法是★在 pyproject 写一份 + 代码用 importlib.metadata.version() 读★,或更彻底地★用 setuptools-scm / hatch-vcs 从 git tag 推导(打 tag 即发版,连文件都不用维护)★——注意 ★CI 里要设 fetch-depth: 0,否则算出 0.1.dev1★。★发布流程:打 tag → CI 跑测试 → build(sdist + wheel 都要)→ twine check → 发布★;★现在应该用 Trusted Publishing(OIDC)而不是长期 API token——CI 向 PyPI 证明『我是这个仓库的这个 workflow』,不需要存任何 secret★。★发布后不能撤回★:★PyPI 不允许重传同一版本号、yank 只是标记不推荐、delete 会让依赖它的构建全挂★ → 发错了只能发新的 PATCH 修正。★CHANGELOG 是版本号的说明书★——★要写『对使用者的影响』而不是『改了什么代码』★,★破坏性变更必须写怎么迁移★;★用 towncrier 的碎片文件避免合并冲突★。★弃用不能说删就删★:标记(加 DeprecationWarning,★stacklevel=2 才能指向调用方★,且★它默认不显示所以还要写进 CHANGELOG★)→ 保留至少一个 MINOR 周期 → MAJOR 才移除。★库和应用策略不同★:★库的依赖约束要宽松(写死会造成使用者的依赖地狱),应用用 lock 文件锁死★;★内部服务可以用 CalVer★。保障兼容性的实用手段:★对公开 API 做快照测试(assert sorted(dir(pkg)) == snapshot),意外删除或改名会被发现★。」
七、常见误区与追问
- 误区:这次改动很大,所以要升 MAJOR 版本。 版本号反映的是「对使用者的兼容性影响」,不是「工作量」。你可能重构了整个内部实现、删掉了几千行代码、把架构完全换了一套——但只要公开的函数签名、返回值结构、异常类型、默认行为都没变,使用者原封不动升级就能跑,那它就是 PATCH。反过来,你只改了一行,把
connect(timeout=30)的默认值改成10——这是破坏性变更,必须升 MAJOR,因为所有依赖默认超时的调用方行为都变了,而且这种变化不会报错、只会在生产环境表现为莫名其妙的超时,比直接报错更危险。判断标准是一个具体的问题:「使用者不改任何代码直接升级,程序还能跑吗、行为还一样吗?」——任何一个答案是「否」,就是 MAJOR。这也意味着**「删除一个我认为没人用的函数」仍然是 MAJOR**,因为你无法验证真的没人用。 - 误区:Python 的版本号就是 SemVer,写
1.0.0-rc.1没问题。 Python 用的是 PEP 440,语法和 SemVer 有实质差别。预发布版本在 PEP 440 里写作1.0.0rc1(没有连字符、没有点分隔),写成 SemVer 风格的1.0.0-rc.1虽然工具可能容忍,但版本排序和依赖解析的行为可能不符合预期。此外 PEP 440 有两个 SemVer 没有的段:.postN——用于「代码完全没变,只是发布元数据有问题」的情况(README 里有错别字、分类器写错了、依赖声明漏了一个),因为 PyPI 不允许重新上传同一个版本号,而升 PATCH 会误导使用者以为代码变了;.devN——开发版,排序上早于同版本的所有预发布版(1.0.0.dev1 < 1.0.0a1),常用于 CI 上的每次提交自动发布到内部索引。完整的排序是dev < a < b < rc < 正式版 < post,而且 pip 默认不会安装预发布版本,除非加--pre或显式指定。 - 误区:版本号写在
__init__.py里,发版时改一下就行。 手工维护多处版本号必然会出现不一致。典型的项目里版本号可能同时出现在:pyproject.toml的version、src/pkg/__init__.py的__version__、docs/conf.py、README的徽章、CI 配置里——发版时改漏一处,就会出现「pip 装的是 1.2.3 但pkg.__version__显示 1.2.2」这类问题,而且往往是用户报障时才发现。两个现代方案:① 单一数据源 + 运行时读取——版本只写在pyproject.toml里,代码里用from importlib.metadata import version; __version__ = version("mypackage")(标准库,Python 3.8+);② 从 git tag 推导——用setuptools-scm或hatch-vcs,版本号完全由 git tag 决定,仓库里根本没有版本号这个字段,打v1.2.3的 tag 就是 1.2.3,未打 tag 的提交自动变成1.2.4.dev5+g1a2b3c。方案 ② 最彻底,但要注意 CI 里必须fetch-depth: 0,否则拉不到 tag 会算出0.1.dev1。 - 误区:发布到 PyPI 发错了可以删掉重发。 PyPI 不允许重新上传同一个版本号,而且删除是极其危险的操作。三点约束:① 不能覆盖——即使你删掉了
1.2.3,也不能再上传一个新的1.2.3(版本号被永久占用),这是为了防止「同一个版本号在不同时间内容不同」造成的供应链安全问题。②yank是推荐的做法——它把版本标记为「不推荐」,新的安装不会选到它,但已有的 lock 文件和明确指定该版本的安装仍然能成功(这是有意设计的,避免破坏别人的构建)。③delete会让所有依赖它的构建立刻失败——包括别人的 CI、别人的 Docker 镜像构建,社区强烈不建议。所以正确的应对是:发现问题后yank有问题的版本,然后立刻发一个新的 PATCH 版本修正。这也是为什么发布前的验证很重要——twine check、TestPyPI 试发、在干净的虚拟环境里装一遍。 - 误区:库的依赖也应该锁死版本,保证稳定。 库锁死依赖会造成使用者的「依赖地狱」。假设你的库写
requests==2.31.0,而使用者的项目里另一个库要求requests>=2.32——两者无法共存,安装直接失败,使用者只能放弃其中一个。库的职责是声明「我需要什么能力」,而不是替使用者决定具体版本:正确写法是范围约束(requests>=2.28,<3.0),下界是你实际用到的特性所需的最低版本,上界通常是下一个 MAJOR(因为那可能有破坏性变更)。应用(application)则相反——必须用 lock 文件锁死(uv.lock、poetry.lock、pip-compile生成的requirements.txt),保证「同一个 commit 在任何时候装出完全相同的依赖树」,这是可重复构建的基础。区分标准很简单:会被别人pip install当作依赖的是库,自己部署运行的是应用。 - 追问:Trusted Publishing 是什么,为什么比 API token 好? 它是 PyPI 提供的基于 OIDC 的免密钥发布机制。传统做法是在 PyPI 生成一个 API token,把它存进 CI 的 secrets,发布时用它认证——问题是:这个 token 长期有效、权限通常很大(能发布整个项目甚至账号下所有项目)、一旦泄露(CI 日志打印、被恶意的第三方 action 窃取、离职员工带走)后果严重、而且轮换很麻烦。Trusted Publishing 的做法是:你在 PyPI 项目设置里声明「我信任 GitHub 上
owner/repo仓库的publish.ymlworkflow」,之后 CI 运行时,GitHub 会签发一个短期的 OIDC token 证明「这次运行确实来自那个仓库的那个 workflow」,PyPI 验证后签发一个只对本次发布有效的临时凭证。好处是:CI 里不需要存任何 secret、凭证是短期的且自动轮换、权限精确到具体的 workflow。配置只需要在 workflow 里加permissions: {id-token: write}并使用pypa/gh-action-pypi-publishaction。这是目前 PyPI 官方推荐的方式,GitLab、Google Cloud Build 等也支持。 - 追问:怎么保证「我以为没有破坏性变更」是真的? 靠自动化验证而不是记忆。四个手段。① 公开 API 的快照测试——
assert sorted(n for n in dir(mypackage) if not n.startswith("_")) == snapshot,任何意外的删除、改名都会在 diff 里出现;进一步可以快照每个公开函数的签名(用inspect.signature),这样连参数改名、默认值变化都能发现。② 契约测试 / 下游集成测试——在 CI 里拉取主要使用者(或你自己的其他项目)的测试套件,用新版本跑一遍。③ 类型层面的检查——如果你的包有完整的类型注解,可以用griffe之类的工具对比两个版本的 API 签名差异,它能直接列出「哪些是破坏性变更」。④ 弃用期的 DeprecationWarning 在自己的测试里当错误处理——filterwarnings = ["error::DeprecationWarning"],确保内部代码没有还在用即将删除的 API。这些手段的共同点是:把「兼容性」变成 CI 里能自动检查的东西,而不是依赖 reviewer 的注意力。 - 追问:CHANGELOG 怎么写才有用? 核心原则是**「写对使用者的影响,而不是写你改了什么代码」。反例:「重构了连接池模块」「更新了依赖」「修复了一些 bug」——这些对使用者毫无信息量,他无法据此判断「我该不该升级、升级后要注意什么」。好的条目应该回答三个问题:① 发生了什么变化(从使用者视角)——「
parse()现在对非法输入抛ValueError而不是返回None」;② 为什么(可选但有帮助);③ 我该怎么办——「迁移:把if result is None:改成try/except ValueError」。破坏性变更必须显著标注(**BREAKING**)并且给出前后代码对比**。格式上推荐 Keep a Changelog 的分类(Added / Changed / Deprecated / Removed / Fixed / Security),按版本倒序排列(最新的在最上面)。工程上有个很实用的技巧:用towncrier——每个 PR 添加一个碎片文件(changelog.d/123.bugfix.md)而不是直接改CHANGELOG.md,既避免了所有人改同一处造成的合并冲突,又强制每个 PR 说明自己的影响,发版时一条命令合并成正式的 CHANGELOG。
八、加强记忆
版本号是给使用者的「兼容性承诺」——SemVer 的 MAJOR/MINOR/PATCH 回答的是「我从 1.2.3 升到 X 需要做什么」。决定升哪一位的依据是「对使用者的兼容性影响」而不是「改动量大小」:改一行把默认参数 True 改成 False 是 MAJOR,重构一万行只要接口没变就是 PATCH,删一个「没人用」的函数仍然是 MAJOR(因为你不知道谁在用)。容易被误判的变更:返回字典多加一个 key、修 bug(如果有人依赖了这个 bug 的行为——Hyrum’s Law:接口的所有可观察行为都会被人依赖)、提高最低 Python 版本、收紧输入校验、改异常类型——这些都是 MAJOR。Python 用 PEP 440 而不是标准 SemVer:预发布是 1.0.0rc1(没有连字符),独有 .postN(发布后只改元数据,因为 PyPI 不允许覆盖已发布的版本,而升 PATCH 会让人以为代码变了)和 .devN;排序是 dev < a < b < rc < 正式版 < post,而且 pip 默认不装预发布版(要 --pre)。版本号必须单一数据源——常见错误是同时写在 pyproject.toml、__init__.py、setup.py、docs/conf.py 里,发版时忘改一处就不一致;现代做法是在 pyproject.toml 写一份 + 代码用 importlib.metadata.version() 读,或更彻底地用 setuptools-scm/hatch-vcs 从 git tag 推导(打 tag 即发版,连版本号文件都不用维护)——注意 CI 里要设 fetch-depth: 0,否则会算出 0.1.dev1。发布流程是「打 tag → CI 跑测试 → build(sdist 和 wheel 都要)→ twine check → 发布」;现在应该用 Trusted Publishing(OIDC)而不是长期有效的 API token——CI 向 PyPI 证明「我是这个仓库的这个 workflow」,不需要在 CI 里存任何 secret。发布后不能撤回:PyPI 不允许重传同一版本号、yank 只是标记为不推荐、delete 会让依赖它的构建全挂 → 发错了只能发一个新的 PATCH 修正。CHANGELOG 是版本号的说明书——要写「对使用者的影响」而不是「改了什么代码」,破坏性变更必须写怎么迁移;用 towncrier 的碎片文件避免合并冲突。弃用不能说删就删:标记(加 DeprecationWarning,stacklevel=2 才能指向调用方,而且它默认不显示所以还要写进 CHANGELOG)→ 保留至少一个 MINOR 周期 → MAJOR 版本才移除。库和应用的策略不同:库的依赖约束要宽松(写死会造成使用者的依赖地狱),应用用 lock 文件锁死;内部服务可以用 CalVer。保障兼容性的实用手段:对公开 API 做快照测试(assert sorted(dir(pkg)) == snapshot),意外的删除或改名会被立刻发现。