跳到主要内容

数据截至 (上游 commit 2689884a6257)

发布治理与开源边界

这一章讲什么: 本仓库里除了那个薄壳,真正在跑的代码是 scripts/ 下的六个脚本(共 2721 行),其中三套主力守卫占 1942 行。它们和 GUI 自动化无关,但每一套都是由一次真实事故催生的,而且都在解决同一个更深的问题:怎么让一个守卫既抓得住真问题,又不会因为噪声太多而被所有人无视。

守卫文件行数
开源边界scripts/check_source_boundary.py368
平台清单scripts/generate_platform_manifest.py297
发布健康scripts/check_release_health.py1277

另外三个更小的脚本(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、sha256PyPI 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):

  1. 改了但没发版。 openadapt-capture 的一个 PR 删掉了「把原始音频波形上传给第三方」的路径,合进 main 之后就躺在那里。PyPI 上唯一能装到的版本仍然带着那条上传路径,而两个下游包用的是不带上限的 >=1.1.0。最后是碰巧有人去看了一眼才发布出去。
  2. 发布被静默跳过。 四个 openadapt-desktop 标签存在但没有对应的 release 对象,原因各不相同:macOS x86_64 构建失败、环境审批没批、整个 run 被取消、老 runner 上 tomllib 导入失败。

关键洞察:检查状态,不检查事件

第二起事故暴露的问题是:if: failure() 的通知器看不到 cancelledskipped

但简单地「run 被取消就报警」也不行——本仓库的发布工作流在每次 push 到 main 时触发,并归在同一个 concurrency: release 组下,例行的取消是常态且无害的

所以设计成状态式(:23-31):

「一次发布 run 被取消」不是警报;「一个够格发版的提交或一个标签仍未被发布」才是。

注释里还点了一个反面教材:平台清单漂移检查曾经在每次发布后都短暂变红,久而久之没人分得清它的真信号和噪声了

三个探测器

探测器触发条件实现
unreleased-workmain 上有比最新标签更新、且会触发版本 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): 描述,typefeat/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.1chore 在 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. 三套守卫共享的四条原则

把上面的细节抽象出来,是四条可以直接搬到别的项目的原则:

  1. 失败即停。 规则读不到就 exit 2,绝不「没找到规则所以通过」。
  2. 规则不由守卫自己持有。 守卫是执行器,规则从外部策略文件/配置/pyproject.toml 读。
  3. 绿勾必须有意义。 专门写测试确保检查真的在跑——见 01 章§4 结尾那条「关于假绿的纪律」,函数名是 test_external_packages_installed_in_ci(tests/test_import_integrity.py:156-168):本地允许跳过,CI 里兄弟包必须全装,否则跨包检查会静默退化成「什么都没查」。
  4. 噪声等于失效。 每个探测器都配了宽限期、在途抑制、显式豁免,目标是「响了就一定值得看」。

6. 代码地图

主题文件路径符号名
边界策略解析(失败即停)scripts/check_source_boundary.pySourcePolicyload_policyPolicyError
仓库树扫描scripts/check_source_boundary.pyscanALLOWLISTED_PATHS
平台清单生成scripts/generate_platform_manifest.pygenerate_pypi_componentDriftError
未签名占位scripts/generate_platform_manifest.pyUNSIGNED_SIGNATURE
清单校验scripts/validate_platform_manifest.py
发布健康判定(输入只有 state)scripts/check_release_health.pyevaluateReport
状态采集(要联网)scripts/check_release_health.pycollect_state
提交是否触发发版scripts/check_release_health.pybump_levelload_parser_options
三个探测器scripts/check_release_health.py_check_unreleased_work_check_tags_without_release_check_pypi
离线自测scripts/check_release_health.pyself_test_state_detectors
锁文件版本对齐scripts/verify_release_lock.pyverify_release_locksynchronize_release_lock
产物集合与元数据校验scripts/verify_release_artifacts.pyverify_release_artifacts
漂移守卫测试tests/test_platform_manifest_drift.pytest_guard_fails_on_a_tampered_digest
发布 lane 配置.github/release-health.jsonlanesacknowledged_tags