首页
/ Black 发布流程全解:CalVer 版本策略、release.py 自动化与 GitHub Actions 发布流水线

Black 发布流程全解:CalVer 版本策略、release.py 自动化与 GitHub Actions 发布流水线

2026-09-05 20:53:53作者:苗圣禹Peter

本文基于 Black 仓库的官方文档 发布流程 展开,系统讲解 Black 的发布节奏与 CalVer 版本策略、完整的发版操作步骤,以及背后由 release.py 与 GitHub Actions 工作流(build and publishpublish binariesdockerpost release)组成的发布自动化体系。读完本文,你可以完整掌握:如何计算下一个版本号、如何生成发版 PR、发布产物(sdist、wheel、mypyc 编译 wheel、独立可执行文件、Docker 镜像)分别由哪个工作流构建并分发到哪里,以及发版后需要关注的收尾动作。

发布节奏与 CalVer 版本策略

Black 的发布节奏目标是:每 1~2 个月将 main 分支上的内容发布一次。这一节奏在"尽快把已合并的改进与修复送达用户"和"不让用户群体被过密的版本切分"之间取得平衡,同时保持维护者工作量的稳定与可预测。如果 main 上没有足够多的新内容,可以跳过某个月的发布。

几条硬性约束值得注意:

  • 1 月份的理想发布不应跳过。按照 Black 的稳定性策略(stability policy),每个日历年度的第一个版本允许修改 stable 风格。将 stable 风格的变更限定在 1 月发布,可以让用户对风格变化形成可预期的预期。
  • 没有严重的回归或需要立即修补的 bug 时,每月最多发布一次。版本号本身没有成本,但每次发布都需要维护者投入精力执行"切版",并处理发布后可能出现的连锁问题;更频繁的发布收益递减很快。
  • 版本号遵循 CalVer 标准的 YY.M.N 格式:除非本月已经发过版,N 应为 0。例如 2026 年 1 月的第一个版本号为 26.1.0

release.py 如何计算当前版本与下一个版本

版本号并非人工拍定,而是由 release.py 从 git tag 推导。从源码实现看:

  • get_git_tags() 执行 git tag 并只保留以数字开头的 tag(即 CalVer 版本 tag);
  • tuple_calver()YY.M.N 字符串转成整数元组用于排序,无法解析的 tag(如 alpha/beta 预发布 tag)回退为 (0, 0, 0),源码中留有 TODO 说明对预发布版本的排序支持尚未完善;
  • get_current_version() 取排序后的最新 tag 作为当前版本;
  • get_next_version() 先用 datetime.today().strftime("%y.%m") 取当前年月并去掉月份的前导零(如 011),再扫描同月的历史 tag:若同月没有正式版则返回 YY.M.0,否则在最后一个同月版本的 N 上 +1。

这套逻辑有专门的单元测试 release_tests.py 保障,例如:mock 当前日期为 69.01(故意带前导零以验证去零逻辑),历史 tag 为 ["1.1.0", "69.1.0", "69.1.1", "2.2.0"] 时,get_current_version() 应返回 69.1.1get_next_version() 应返回 69.1.2。这些测试由工作流 release_tests.yml 在 Python 3.12~3.15 × macOS / Ubuntu / Windows 的矩阵上运行——这也印证了文档中"release.py 仅在 Python 3.12+ 上测试过"的说法。该工作流仅在 scripts/release.pyscripts/release_tests.py 或工作流文件本身变更时触发,并以 coverage run scripts/release_tests.py 方式执行。

切版操作:权限要求与前置条件

执行发布的人必须拥有 Black 仓库的 write 权限。 发布流程的万米高空视角是:先准备一个"发版 PR",然后发布一个 GitHub Release;后者的 published 事件会触发所有发布自动化工作流,由其构建并分发全部发布产物。

发布 PR 的生成依赖 scripts/release.py 脚本,日常使用方式是:

python3 scripts/release.py --help   # 查看可用参数
python3 scripts/release.py          # 生成发版所需的大部分变更
python3 scripts/release.py --debug  # 开启 DEBUG 日志

parse_args() 可以看到脚本仅有两个开关:-d/--debug(输出调试日志)与 -a/--add-changes-template(向 CHANGES.md 添加新的 Unreleased 模板,发版后使用,详见后文)。执行默认路径时,main() 会先打印检测到的当前版本与计算出的下一个版本(日志输出到 stdout,方便复制粘贴),然后调用 update_repo_for_release() 修改仓库文件。

切版步骤详解

以下是完整的切版流程,与官方文档保持一致:

1. 确定版本号

运行 python3 scripts/release.py 后,脚本会把计算好的 YY.M.N 版本号打印到 stdout(Next version will be ...),直接复制即可。

2. 检查 changelog 分区是否正确

确认自上一个版本以来,没有任何 changelog 条目被放进了错误的分区。官方推荐的做法是运行:

git diff origin/stable CHANGES.md

通过对比 stable 分支(指向最近一次发布 tag,见下文 post release 工作流)与 main 上的 CHANGES.md 差异,可以快速审读本次将发布的全部变更条目。

3. 提交发版 PR

提交一个编辑 CHANGES.md 与文档的 PR,将最新的变更"版本化"。核心命令:

python3 scripts/release.py [--debug]

从源码看,这一步实际做了两类修改:

(a)清理 CHANGES.md 模板 —— cleanup_changes_template_for_release() 依次执行三个变换:

  1. ## Unreleased 标题替换为 ## Version <下一版本号>
  2. 用正则删除所有 HTML 注释(即模板中指导贡献者如何填写的 <!-- ... --> 说明块);
  3. 删除空的子分区标题(即本次发布没有条目的 ### xxx 小节)。

CHANGES.md 的 Unreleased 模板本身由 NEW_VERSION_CHANGELOG_TEMPLATE 定义,包含 Highlights、Stable style、Preview style、Configuration、Packaging、Parser、Performance、Output、Blackd、Integrations、Documentation 等分区,每个分区都附有"PR 作者请注明:changelog 条目应写 PR 编号而非 issue 编号"等填写指引。

(b)更新文档中的版本号引用 —— update_version_in_docs() 会把文档中出现的"当前版本"字符串全局替换为"下一版本"。注意 SourceFiles 类 中维护的 version_doc_paths 实际包含 三个文件,比文档正文提到的多一个:

4. 手动修正(如果脚本失败)

如果 release.py 执行失败,需要手动完成等价编辑:

  1. ## Unreleased 标题替换为版本号;
  2. 删除当前版本下的空分区;
  3. (可选)通读并编辑 changelog,如移动条目、修正错别字、改写措辞。

5. 等待 CI 通过

发版 PR 合并后,等待所有 CI 通过。如果 CI 失败,必须停下来调查失败原因——Black 的惯例是先修好失败的 CI 再切版。

6. 创建 GitHub Release(Draft)

  1. 点击 Choose a tag,输入版本号,然后选择自动出现的 Create new tag: YY.M.N on publish 选项;
  2. 确认新 tag 指向 main 分支;
  3. 确保 Release 标题设置为版本号(YY.M.N),否则默认标题会是最后一条 commit 的标题;
  4. 将当前版本的 changelog 原始 Markdown 复制粘贴到描述框中。

7. 发布,触发自动化

点击发布后,release: published 事件会触发全部发布工作流(详见下一节),构建与上传工作由自动化接管。

8. CI 完成后补上下一轮的 Unreleased 模板

CI 跑完后,向 CHANGES.md 追加一个供下次发布使用的空模板:

python3 scripts/release.py --add-changes-template   # 或简写 -a

对应实现是 add_template_to_changes():若 CHANGES.md 中已经存在 ## Unreleased 则报错返回 1(避免重复插入),否则把 NEW_VERSION_CHANGELOG_TEMPLATE 插入到 # Change Log 标题之后。若脚本失败,回退方案是从 release.py 源码中手动复制模板粘贴。

值得一提的是,这一步实际上已经由 post release 工作流 中的 new-changelog job 自动完成(见下文),手动命令是它的兜底手段。

9. 观察并验证发布工作流

至此发布基本完成。良好的实践是去观察所有发布工作流是否通过——当然,任何工作流失败时你都会收到 GitHub 通知。若有失败,不要慌张:去阅读对应工作流的日志与配置文件,反推出修复方案。

关于 hotfix 的官方建议:发布产物到达 PyPI 后,可能会看到新 issue 报告回归。回归不等于必须发 hotfix——除非回归严重且影响大量用户,否则通常无需紧急发版。最终请运用你的最佳判断,并征询其他维护者的意见。

发布自动化:四个 GitHub Actions 工作流

Black 的全部发布自动化都基于 GitHub Actions,工作流配置是仓库 .github/workflows 目录下的 YAML 文件,统一由 GitHub Release 的发布事件release: published)触发。下面按官方文档的结构逐一解析,并结合工作流源码补充实现细节。

build and publish:构建并上传 PyPI 产物

这是主发布工作流,配置文件为 pypi_upload.yml。它构建 sdist 与 wheel 并上传到 PyPI——绝大多数用户从 PyPI 安装 Black。工作流同时监听 pull_requestpush: main 事件以做构建演练,但上传 job 仅在 release 事件下执行(if: github.event_name == 'release')。整个流程分为三组 job:

(1)sdist + pure wheel(hatch job)

hatch job 使用 Hatch 构建 sdist 与纯 Python wheel(只包含 Python 代码的 wheel)。这类产物是通用型的,基本可在 Python 支持的任意平台上使用。关键步骤:

  • 通过 python -m pip install --group hatch 安装 pyproject.toml 中定义的 hatch 依赖组(含固定版本的 hatch、hatch-fancy-pypi-readme、hatch-vcs 等);
  • 执行 python -m hatch build 构建 dist/,再作为 artifact 上传供发布 job 下载。

版本号的来源也值得说明:pyproject.toml 配置 [tool.hatch.version] source = "vcs",即由 hatch-vcs 从 git 元数据推导版本并生成 src/_black_version.py;仓库根目录的 .git_archival.txt 中的 describe-name: $Format:%(describe:tags=true,match=[0-9]*)$ 保证 sdist 中也带有正确的 git describe 信息。此外 tool.hatch.metadata.hooks.fancy-pypi-readme 会把 README.mdCHANGES.md 拼接成 PyPI 页面上的项目说明。

(2)mypyc wheels(configure + mypyc 矩阵 job)

Black 使用 mypyc 把核心模块编译为 CPython C 扩展以获得显著的性能提升。mypyc 构建的 wheel 与平台和 Python 版本绑定,因此需要矩阵化构建。从 pypi_upload.yml 看:

  • configure job 通过 cibuildwheel --print-build-identifiers 分别为 linux / macos / windows(含 windows ARM64)打印构建标识符,汇总成构建矩阵输出。完整矩阵仅在 release 事件与 push 到 main 时生成;普通 PR 只构建缩小的两个标识符(cp310 Linux + cp314 Windows),除非 PR 带有 ci: build all wheels 标签——这是对 PR 检查耗时的优化;
  • 每个 mypyc wheels <标识符> job 在对应平台 runner 上执行 cibuildwheel . --only <标识符>,产物上传为 wheelhouse/ artifact;
  • 构建目标与跳过规则定义在 pyproject.toml[tool.cibuildwheel] 段:build = "cp31*"(CPython 3.10+,仅 64 位),跳过 musllinux、32 位 win32、PyPy,以及 free-threaded 构建(cp31?t-*,mypyc 对其支持不佳);Linux 使用 manylinux_2_28 镜像;编译环境设置 HATCH_BUILD_HOOKS_ENABLE=1MYPYC_OPT_LEVEL=3,即真正启用 mypyc 编译钩子;
  • mypyc 编译的排除名单同样在 pyproject.toml 中:blackdblib2to3 的部分模块、output.pydebug.py__main__.py 等不参与编译,理由从注释可见("blackd 没有编译的好理由"、"性能不敏感"、"编译后测试套件会挂"等);
  • 每个 wheel 构建完会跑测试:pytest {project} -k "not incompatible_with_mypyc" -n auto,确保编译版 Black 行为一致。

(3)publish-hatch / publish-mypyc:上传 PyPI

这两个 job 分别下载 sdist-and-pure-wheel 与全部 *-mypyc-wheels artifact,然后使用 PyPI 的 Trusted publishing(可信发布,基于 OIDC 的 id-token: write 权限,而非 API token)将 sdist 与所有 wheel 上传到 PyPI。两个 job 都挂 environment: release 并声明 permissions: id-token: write,注释明确写道 "Required for PyPI trusted publishing"。

publish binaries:构建独立可执行文件

配置文件为 publish_binaries.yml。该工作流用 PyInstaller 为多个平台构建原生可执行文件,让用户无需安装 Python 运行时即可运行 Black。实现细节:

  • 构建矩阵覆盖 5 个目标:windows-latest(x86_64)、windows-11-armubuntu-latestubuntu-24.04-armmacos-latest,产物分别为 black_windows.exeblack_windows-arm.exeblack_linuxblack_linux-armblack_macos
  • 安装依赖使用 python -m pip install ".[colorama]" --group pyinstaller,随后执行 python -m PyInstaller -F ... src/black/__main__.py 做单文件打包,并通过 --add-data 'src/blib2to3' 把内置的 blib2to3 包数据打进可执行文件;Linux 产物额外加 --strip 去符号;
  • 上传前有一个冒烟测试:运行 ./dist/<产物> --version./dist/<产物> src --verbose 确认可执行文件可用;
  • 最后通过 gh release upload 把二进制作为资产挂到对应的 GitHub Release 上(contents: write 权限)。

按照文档说明,这些二进制挂在 GitHub Release 上、目前只能通过 IPv4 下载(GitHub 尚无 IPv6 访问);工作流也支持 workflow_dispatch 手动指定 tag 重跑。

docker:构建官方 Docker 镜像

配置文件为 docker.yml。该工作流使用 Docker buildx(QEMU 支持)构建官方 Black 镜像的 arm64amd64/x86_64 版本,推送到 Docker Hub 的 pyfound/black 仓库。两个要点:

  • 触发条件比文档描述更宽:除 release 发布事件外,每次 push 到 main 也会运行;
  • tag 策略(见 push job):构建 amd64 与 arm64 两个平台镜像后,push job 用 docker buildx imagetools create 合并 manifest 并打 tag。release 事件下打 latest 与版本号 tag,预发布另打 latest_prerelease,正式发版打 latest_release;非 release 的 main 推送只打 latestlatest_non_release

文档还注明:当前该工作流使用关联到维护者账号的 API Token(DockerHub 凭证经 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN secrets 注入)。

post release:发布后的仓库维护

配置文件为 post_release.yml,由 release: published 触发(且仅在非预发布时执行),包含两个 job:

update-stable:把 stable 分支强制推进到最新 release tag。实现非常直接——以 stable 为 ref 检出后执行 git reset --hard "${TAG_NAME}" && git push(见 post_release.yml)。这样维护者就不用记得在发版后某个时点手动更新 stable 分支。这也解释了切版第 2 步为何能用 git diff origin/stable CHANGES.md 来审读"本次发布包含的变更":stable 始终等价于上一次发布。

new-changelog:开一个新 PR,把 "Unreleased" 分区加回 changelog。实现是检出 main(带 tags)后执行 python scripts/release.py -a——与上文第 8 步的手动命令完全一致——再用 git-auto-commit-action 提交到 ci/new-changelog 分支,并以 gh pr create 创建标题为 "Add new changelog" 的 PR(标签 ci: skip newsC: maintenance)。这个 PR 刻意不自动合并:万一发布出问题需要重切版本,changelog 状态还可以回退处理。

关键文件索引

类别 文件 作用
流程文档 docs/contributing/release_process.md 本文主体依据的发布流程说明
发布脚本 scripts/release.py 版本计算、CHANGES.md 版本化、Unreleased 模板管理
脚本测试 scripts/release_tests.py CalVer 计算逻辑的单元测试
测试工作流 .github/workflows/release_tests.yml 在 3.12+ 与三大操作系统上运行发布脚本测试
PyPI 发布 .github/workflows/pypi_upload.yml sdist / 纯 wheel / mypyc wheel 构建与可信发布
二进制发布 .github/workflows/publish_binaries.yml PyInstaller 多平台可执行文件构建
Docker 镜像 .github/workflows/docker.yml amd64 + arm64 多架构镜像构建与 tag 策略
发布后维护 .github/workflows/post_release.yml 更新 stable 分支、自动提交新 changelog 模板 PR
构建配置 pyproject.toml hatch 构建、mypyc 编译钩子、cibuildwheel 目标
变更日志 CHANGES.md 所有版本变更的 Unreleased 模板与历史条目

总结

Black 的发布体系可以概括为"一个脚本 + 一条手动 PR + 四条自动化工作流":release.py 负责版本号推导与 changelog/文档的版本化编辑;维护者手动完成"发版 PR 合并 → CI 全绿 → 创建并发布 GitHub Release"这条主线;随后 release: published 事件驱动 PyPI 发布独立可执行文件发布Docker 镜像发布仓库收尾 四条工作流,把 sdist、纯 Python wheel、各平台 mypyc wheel、五个平台的原生二进制、双架构 Docker 镜像分发到 PyPI、GitHub Release 与 Docker Hub,并自动维护 stable 分支与下一轮 changelog 模板。整个流程中几乎不依赖 API token(PyPI 用可信发布、GitHub 用 github.token),并通过 release_tests.py 的跨平台测试把"最容易出错的版本计算逻辑"锁在自动化之下。

登录后查看全文
热门项目推荐
相关项目推荐