← 返回题目列表

Python 项目怎么做版本管理和发布?语义化版本要注意什么?

中等 第 25 / 27 题 更新于 2026/08/03
语义化版本发布流程PyPICHANGELOGCI/CD

简化版

版本号是给使用者的「兼容性承诺」——语义化版本(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

详细版

版本号变更规则

变更类型升哪一位例子
删除/重命名公开 APIMAJOR删函数、改参数名
改变默认行为MAJOR默认值变了、返回结构变了
新增必填参数MAJOR老代码调用会报错
新增功能/可选参数MINOR加新函数、加默认参数
标记弃用MINOR加 DeprecationWarning
修 bugPATCH行为修正为「本来该有的」
重构/性能优化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.pydocs/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 版本、收紧输入校验、改异常类型——这些都是 MAJOR0.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.tomlversionsrc/pkg/__init__.py__version__docs/conf.pyREADME 的徽章、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-scmhatch-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.lockpoetry.lockpip-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.yml workflow」,之后 CI 运行时,GitHub 会签发一个短期的 OIDC token 证明「这次运行确实来自那个仓库的那个 workflow」,PyPI 验证后签发一个只对本次发布有效的临时凭证。好处是:CI 里不需要存任何 secret、凭证是短期的且自动轮换、权限精确到具体的 workflow。配置只需要在 workflow 里加 permissions: {id-token: write} 并使用 pypa/gh-action-pypi-publish action。这是目前 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 版本、收紧输入校验、改异常类型——这些都是 MAJORPython 用 PEP 440 而不是标准 SemVer预发布是 1.0.0rc1(没有连字符),独有 .postN(发布后只改元数据,因为 PyPI 不允许覆盖已发布的版本,而升 PATCH 会让人以为代码变了)和 .devN排序是 dev < a < b < rc < 正式版 < post,而且 pip 默认不装预发布版(要 --pre)。版本号必须单一数据源——常见错误是同时写在 pyproject.toml__init__.pysetup.pydocs/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 的碎片文件避免合并冲突弃用不能说删就删:标记(加 DeprecationWarningstacklevel=2 才能指向调用方,而且它默认不显示所以还要写进 CHANGELOG)→ 保留至少一个 MINOR 周期 → MAJOR 版本才移除。库和应用的策略不同库的依赖约束要宽松(写死会造成使用者的依赖地狱),应用用 lock 文件锁死内部服务可以用 CalVer。保障兼容性的实用手段:对公开 API 做快照测试assert sorted(dir(pkg)) == snapshot),意外的删除或改名会被立刻发现。