数据截至 (上游 commit 2689884a6257)
发布治理与开源边界
这一章讲什么: 本仓库里除了那个薄壳,真正在跑的代码是
scripts/下的六个脚本(共 2721 行),其中三套主力守卫占 1942 行。它们和 GUI 自动化无关,但每一套都是由一次真实事故催生的,而且都在解决同一个更深的问题:怎么让一个守卫既抓得住真问题,又不会因为噪声太多而被所有人无视。
| 守卫 | 文件 | 行数 |
|---|---|---|
| 开源边界 | scripts/check_source_boundary.py | 368 |
| 平台清单 | scripts/generate_platform_manifest.py | 297 |
| 发布健康 | scripts/check_release_health.py | 1277 |
另外三个更小的脚本(validate_platform_manifest.py 485 行、verify_release_artifacts.py 171 行、verify_release_lock.py 123 行)在第 4 节讲。
1. 守卫一:开源边界(防客户内容泄漏)
事故
脚本文件头把起因写得很直白(scripts/check_source_boundary.py:5-13):某个真实客户的电子病历系统(Cerner PowerChart)部署相关的工作流内容,进了公开的核心仓库,是人工审查发现后手工摘掉的。文档里称之为 the PowerChart incident。
思路:规则外置 + 失败即停
私有仓库 openadapt-internal:source-policy.yaml ← 唯一真源(公开 CI 读不到)
│ 渲染
▼
本仓库 source-policy.public.json ← 可公开的子集,提交进仓库
│ 读取
▼
scripts/check_source_boundary.py ← 自己不含任何 denylist
│
policy 缺失/损坏/不完整 ──→ exit 2,构建停
最要紧的一条设计原则(check_source_boundary.py:26-29):
一个因为「没找到规则」而通过的守卫,比它替换掉的那份硬编码列表更糟——因为所有人都以为它跑过了。
这句话不是这个文件独有的。01 章§4 那条「假绿比没有检查更糟」的纪律,说的是同一件事——它是这个项目的贯穿性原则,本章第 5 节会把它抽象成一条通则。
所以 SourcePolicy.__init__(:126-185)对每一个字段都严格校验:schema 版本不认识就拒、列表为空就拒、正则编译不过就拒,一律抛 PolicyError 并 exit 2。
两种匹配,防改名绕过
| 匹配维度 | 怎 么判 | 实现 |
|---|---|---|
| 路径 token | 文件路径小写后包含黑名单词 | scan,:250-252 |
| 私有目录段 | 路径按 / 切开后有段命中私有集合 | scan,:258-266 |
| 内容签名 | 文件内容含私有产物横幅 | scan,:280-283 |
| 内容正则 | 文件内容匹配黑名单正则,报出行号 | scan,:288-294 |
改文件名躲得过路径检查,躲不过内容检查;反过来也一样。
一个很聪明的自指处理
如果把「私有产物横幅」的完整字符串写进策略文件,那么策略文件自己就会命中自己的规则。解法(:162-178):
# 真实源码节选,scripts/check_source_boundary.py:174
joined = "".join(str(part) for part in entry)
策略里存的是横幅的碎片数组,运行时才拼起来。注释直说了这么做的原因(:159-161)。
另有一份仓库本地的 ALLOWLISTED_PATHS(:72-79),放的是「本来就在讨论这条边界」的文件:守卫脚本自己、策略文件、它的测试、贡献指南。注释强调这份白名单故意不进共享策略——它是仓库本地知识。
可以指向别的仓库
--root 参数允许扫描另一个 checkout,但规则永远从本仓库读(:52-54)。这样其他公开仓库能复用同一份守卫,而不会各自 vendor 出一份分叉的规则。
2. 守卫二:平台清单(防版本谎报)
它要解决的小问题
「当前这一版 OpenAdapt 平台由哪些组件的哪些版本构成」这个问题,如果靠人手维护一份表格,那份表格一定会过时。
思路:只从真实已发布的源头生成,对不上就报错
scripts/generate_platform_manifest.py:963(generate)的数据来源只有三处:
| 数据 | 来源 |
|---|---|
| 四个组件的版本、artifact URL、sha256 | PyPI JSON API |
| 支持的执行基底、发布通道 | https://openadapt.ai/status.json |
| 依赖 pin 范围 | 本仓库 pyproject.toml |
任何一个源头拿不到就抛 DriftError 并退出,不猜、不填默认值(_fetch_json,:120-132)。
漂移检查在 :228-240:仓库里的 launcher 版本和 PyPI 上最新版不一致时,默认直接失败;只有显式传 --allow-unreleased-launcher(发布列车在途中的正常状态)才降级成 warning,而且无论如何清单里记的都是已发布的那个版本。
关于签名:诚实的占位
# 真实源码,scripts/generate_platform_manifest.py:108-113
UNSIGNED_SIGNATURE = {
"algorithm": None,
"value": None,
"status": "unsigned (signing infrastructure pending)",
"plan": "docs/platform-manifest.md#signing-plan",
}
没有签名基础设施,就在清单里明说没有,并指向计划文档。校验逻辑会强制这个结构存在、且不许声称任何签名值。
这比留一个空字段或者假装签过要好得多——机器消费方能明确读到「这份清单未签名」。
配套的漂移测试
tests/test_platform_manifest_drift.py 用构造出来的假 PyPI 响应,逐个验证守卫的行为(:96-250):真实清单要通过、有新版本要失败、digest 被篡改要失败、URL 被篡改要失败、PyPI 上根本没这版本要失败;而 CDN 传播延迟只 warning 不失败(:174-189),但即便延迟中也仍然校验 digest(:190-198)。
3. 守卫三:发布健康(防「合了但没发版」)
这是三套里最精巧的一套,1277 行。
两起事故
文件头记录得非常具体(scripts/check_release_health.py:5-21):
- 改了但没发版。
openadapt-capture的一个 PR 删掉了「把原始音频波形上传给第三方」的路径,合进 main 之后就躺在那里。PyPI 上唯一能装到的版本仍然带着那条上传路径,而两个下游包用的是不带上限的>=1.1.0。最后是碰巧有人去看了一眼才发布出去。 - 发布被静默跳过。 四个
openadapt-desktop标签存在但没有对应的 release 对象,原因各不相同:macOS x86_64 构建失败、环境审批没批、整个 run 被取消、老 runner 上tomllib导入失败。
关键洞察:检查状态,不检查事件
第二起事故暴露的问题是:if: failure() 的通知器看不到 cancelled 和 skipped。
但简单地「run 被取消就报警」也不行——本仓库的发布工作流在每次 push 到 main 时触发,并归在同一个 concurrency: release 组下,例行的取消是常态 且无害的。
所以设计成状态式(:23-31):
「一次发布 run 被取消」不是警报;「一个够格发版的提交或一个标签仍未被发布」才是。
注释里还点了一个反面教材:平台清单漂移检查曾经在每次发布后都短暂变红,久而久之没人分得清它的真信号和噪声了。
三个探测器
| 探测器 | 触发条件 | 实现 |
|---|---|---|
unreleased-work | main 上有比最新标签更新、且会触发版本 bump 的提交,且 main 是绿的,且没有 run 在飞,且超过宽限期 | _check_unreleased_work,:308 |
tag-without-release | 发布形状的标签没有 GitHub release 对象,且没有 run 在跑,且超过宽限期 | _check_tags_without_release,:412 |
unpublished-release | 最新标签的版本在 PyPI 上查不到,且超过 CDN 宽限期 | _check_pypi,:517 |
假阳性控制的核心:bump_level
先解释一个词。Conventional Commit 是一种提交信息的格式约定:标题写成 type(scope): 描述,type 取 feat/fix/chore 等固定词表里的词;标题 带 ! 或正文出现 BREAKING CHANGE: 表示破坏性变更。semantic-release 这类工具就靠读这个格式,决定该发 major / minor / patch 哪一级版本。
bump_level(:204-229)是整个文件里最要紧的函数,它的 docstring 自称「本文件里最重要的假阳性控制」:
提交信息
│
├─ 不是 Conventional Commit 形状? ──→ None(semantic-release 根本不看)
├─ type 不在 allowed_tags 里? ──────→ None
├─ 有 `!` 或 BREAKING CHANGE: ──────→ "major"
├─ type 在 minor_tags 里 ───────────→ "minor"
├─ type 在 patch_tags 里 ───────────→ "patch"
└─ 否则 ────────────────────────────→ None
为什么这么讲究?因为发布流程自己会推提交:
| 发布自己推的提交 | 为什么必须返回 None |
|---|---|
chore: release 1.2.1 | chore 在 allowed 里,但不在 minor/patch 里 |
chore(release): reconcile platform manifest | 同上 |
1.10.0(本仓库发布提交的裸标题) | 根本不是 Conventional Commit |
如果这三种有任何一种被判成「够格发版」,那么每次发布之后的 90 秒内,每个仓库都会报告自己有未发布的工作——永远如此。
解析规则不是硬编码的,而是从本仓库 pyproject.toml 的 [tool.semantic_release.commit_parser_options] 读的(load_parser_options,:165-196)。读不到时用显式的 FALLBACK_PARSER_OPTIONS 并明说自己假设了什么(:168-172)。
其他降噪手段
| 情况 | 处理 |
|---|---|
| 有 run 处于 queued/waiting/running | 该 lane 的缺口探测器全部静默(:278-288) |
| main 是红的或还在跑 | 不报 unreleased-work(红的 main 本身就是信号,而且不该发版) |
| PyPI 不可达 | warning,不报警 |
| GitHub API 不可达 | warning 并 exit 0(GitHubUnavailable,:576) |
| 历史遗留的孤儿标签 | 在配置里 acknowledged_tags 显式豁免 |
最后一条的配置注释值得抄下来(.github/release-health.json):v0.36.1 是 2024 年的遗留标签,永远不会有 release,所以显式确认掉,理由是「一个永远红着的守卫,是没人会看的守卫」。
可离线自测
evaluate(:265-305)的签名是 evaluate(state, report):第一个参数是一份状态快照,第二个是收集结论的 Report 对象。
严格说它不是无副作用的纯函数——report 会被写入 alert/warn/note。但它的全部输入只有 state 这一个字典,函数体内不发任何网络请求,判定结果完全由 state 决定。这就够了:self_test()(:880-1200,320 行)可以完全离线地把各种状态组合喂进去验证探测器,--dump-state 还能把线上采集到的真实状态存下来复现。
采集在另一个函数里:collect_state(:634),要联网的部分全在那边。
这是一条通用设计经验:把「采集」和「判定」彻底分开,判定就能被穷举测试。
4. 发布产物与锁文件校验
两个更小但同样严格的脚本:
scripts/verify_release_lock.py —— 保证 pyproject.toml 的版本和 uv.lock 里那条 editable 根条目的版本一致。它只改版本号那几个字符(synchronize_release_lock,:75-91),不重新解析整个锁 文件,理由是保住已经过审的依赖解析结果。它还要求 editable 条目恰好只有一条,多了少了都报错(_editable_lock_entry,:51-55)。
scripts/verify_release_artifacts.py —— 校验 dist/ 目录:
dist/ 里必须恰好是 { 一个 wheel, 一个 sdist }
│ 另可多一个 .gitignore,但内容只允许是空、单个换行、或一个 *
│
├─ 从 wheel 的 .dist-info/METADATA 和 sdist 的 PKG-INFO 读元数据
├─ Name / Version / Requires-Python 必须和 pyproject 一致
├─ Development Status 分类器必须「恰好」是 Beta 那一条
└─ wheel 和 sdist 的四个字段必须互相一致
.gitignore 那三种取值的判定在 :109(marker.read_bytes() not in {b"", b"\n", b"*"}),别的内容一律报错——它只是个「别把产物提交进去」的哨兵,不该藏别的东西。
actual != allowed 时,报错信息同时列出「多了什么」和「少了什么」(:112-118)——比单说一句「不匹配」有用得多。最后打印两个产物的 sha256(:165-166)。
5. 三套守卫共享的四条原则
把上面的细节抽象出来,是四条可以直接搬到别的项目的原则:
- 失败即停。 规则读不到就 exit 2,绝不「没找到规则所以通过」。
- 规则不由守卫自己持有。 守卫是执行器,规则从外部策略文件/配置/
pyproject.toml读。 - 绿勾必须有意义。 专门写测试确保检查真的在跑——见 01 章§4 结尾那条「关于假绿的纪律」,函数名是
test_external_packages_installed_in_ci(tests/test_import_integrity.py:156-168):本地允许跳过,CI 里兄弟包必须全装,否则跨包检查会静默退化成「什么都没查」。 - 噪声等于失效。 每个探测器都配了宽限期、在途抑制、显式豁免,目标是「响了就一定值得看」。
6. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 边界策略解析(失败即停) | scripts/check_source_boundary.py | SourcePolicy、load_policy、PolicyError |
| 仓库树扫描 | scripts/check_source_boundary.py | scan、ALLOWLISTED_PATHS |
| 平台清单生成 | scripts/generate_platform_manifest.py | generate、_pypi_component、DriftError |
| 未签名占位 | scripts/generate_platform_manifest.py | UNSIGNED_SIGNATURE |
| 清单校验 | scripts/validate_platform_manifest.py | — |
| 发布健康判定(输入只有 state) | scripts/check_release_health.py | evaluate、Report |
| 状态采集(要联网) | scripts/check_release_health.py | collect_state |
| 提交是否触发发版 | scripts/check_release_health.py | bump_level、load_parser_options |
| 三个探测器 | scripts/check_release_health.py | _check_unreleased_work、_check_tags_without_release、_check_pypi |
| 离线自测 | scripts/check_release_health.py | self_test、_state、_detectors |
| 锁文件版本对齐 | scripts/verify_release_lock.py | verify_release_lock、synchronize_release_lock |
| 产物集合与元数据校验 | scripts/verify_release_artifacts.py | verify_release_artifacts |
| 漂移守卫测试 | tests/test_platform_manifest_drift.py | test_guard_fails_on_a_tampered_digest 等 |
| 发布 lane 配置 | .github/release-health.json | lanes、acknowledged_tags |