发布流水线
Zuno 的发布产物只构建一次。release-please PR 会在其精确 head commit 上完成验证;合并后, 只有发布 tag 与候选产物拥有相同 Git tree 时,候选归档才会被晋级。
工作流职责
ci.yml是普通贡献 PR 的必需门禁。它使用标准 GitHub-hosted Linux 与 Windows runner; public fork 只获得只读权限,不会得到仓库 secrets。release.yml负责 release-please、精确候选调度、发布身份校验和 GitHub 资产发布。 候选晋级路径不安装 Rust,也不重新编译二进制。release-candidate.yml负责发布增量检查和六个发布目标。每个目标在同一 job 中共同构建zuno与zuno-smoke,打包并解包归档,校验精确可执行架构并运行归档内二进制,生成 provenance,最后才上传产物。Linux、Windows 和 arm64 macOS 原生执行; x86_64 macOS 在macos-15Arm64 runner 上通过 Rosetta 2 执行。Windows x86_64 使用windows-2022,Windows ARM64 使用标准windows-11-armhosted runner。warm-compiler-caches.yml在可信main更新后编译三个 PR 构建面,维护共享缓存; 它独立于必需 PR 门禁和发布流程。publish-compiler-caches.yml只在发布完成后导入精确成功候选的可选编译快照, 不重新编译或上传发布二进制。
按照仓库原生 Actions 审批策略,GitHub 可能会把 GITHUB_TOKEN 创建的 release PR 对应普通 pull_request workflow 标记为 action_required。这是有意保留的人工门禁, 不是测试失败,也不能由发布自动化绕过。仓库将 actions/permissions/fork-pr-contributor-approval 保持为 all_external_contributors,不会添加 CI skip 标记、改用 pull_request_target, 也不会给 release-please 配置特权 token。
release-please 创建或更新 PR 后,控制器不会相信第一次读取到的可变 PR API 结果。它会等待 PR base SHA 等于触发本次控制器的 main commit,验证由机器人创建的 release head 恰好只有这一个 parent,并再次读取 PR,确认 base/head 组合仍然一致。过期的 API 视图只会在 有界时间内重试,绝不会作为候选的 expected SHA 被调度。
release-please 用 conventional-commits 解析器读取每个已合并的 PR,任何一条无法解析的条目都会被静默 跳过:release run 仍显示成功,日志里只有 commit could not be parsed 与 Considering: 0 commits,而 Release PR 不会出现。它解析的文本,在 PR 描述含有 BEGIN_COMMIT_OVERRIDE 时是该描述,否则是 squash 提交消息。不带显式正文的 squash 合并会让 GitHub 把分支上全部 commit 的消息拼进提交消息,其中一行形如 footer、括号不配对,就足以让整条解析失败(51 个 commit 的批次 3 合并产生了 1891 行消息,0.10.1 的 PR 因此没有被创建)。override 取的是描述中该标记词第一次出现之后、到 END_COMMIT_OVERRIDE 为止的全部文本, 去掉首尾空白后按一条 conventional commit 解析——描述里哪怕只是在散文中提到这个标记词,解析器拿到的就是 那段散文(unexpected token ' ' at 1:2)。
规则:多 commit 的 squash 用 gh pr merge --squash --subject … --body … 保持一条 conventional 主题加简短正文;描述里若带 override 块,块内只放一条 conventional 消息(一行标题,可带正文),标记词不得出现在描述的其他任何位置;每次合并到 main 之后确认 chore: release X.Y.Z PR 确实出现。已被跳过的合并,编辑该 PR 的描述补上 override 块即可恢复, release-please 下次运行会重新读取描述。
当文档类或其他不计入版本的提交推进 main 时,release-please 可能不会刷新已有 Release PR, 导致其 head 仍以旧 main 为 parent。控制器连续观察到同一个稳定的过期身份后,会拉取同仓库 release 分支,独立复核单一 parent、机器人作者与 chore: release 标题,并确认旧 parent 是 本次触发 main 的祖先。随后它在临时 worktree 中把这一个 release commit cherry-pick 到精确触发 SHA,并使用绑定旧 head 的 force-with-lease 更新原分支;只有再次 通过 PR API 确认新 head 后才调度候选。冲突、身份不匹配、非祖先或 lease 失败都会使控制器 失败关闭,且不改变 PR 分支。正常恢复不会生成双 parent 的 “update branch” merge,也不再 关闭并重新创建 Release PR。
维护者必须核对精确 head SHA;当 GitHub 把普通 CI run 标记为 action_required 时, 批准对应的精确 run。GitHub 放行 run 后,ci.yml 会有意忽略 github.actor:触发者可能是 批准或重新运行它的维护者,并不等于 PR 作者身份。轻量路由改为严格要求 release-please 机器人 PR 作者、同仓库 head、main base、release-please 分支前缀和 autorelease: pending 标签;它使用非受保护 check 名称,并跳过所有重复构建 job。 精确 head 的候选工作流仍是 zuno/pr-gate 的唯一所有者。普通 PR 与 fork PR 继续执行 完整 CI 矩阵。尚未批准的 action_required 表示正在等待人工操作,不能宣称发布已完成; 如果一直无人处理直到 GitHub 将其过期,就会留下容易误判的失败 chore: release ... 历史,本流程正是为了避免这种遗漏。
Linux 源码门禁安装固定版本的 cargo-nextest。Linux 和 Windows 的 Clippy、测试 job 各自使用独立构建目录并行启动,避免在测试执行前形成全局 串行屏障。Windows 使用 scripts/test-parallel.sh:Cargo 只编译一次,再由有界 worker pool 并发运行测试二进制,避免为每个 test case 单独启动 Windows 进程。托管 Windows 同时运行四个二进制,每个二进制内部一次只执行一个测试;这样既隔离进程全局环境和时序状态, startup 墙钟性能基准会先在无竞争状态下单独执行一次,避免其他进程使预算测量失真;ACP、 ConPTY 生命周期等所有功能 suite 仍留在并发池中,pool 之后不再追加串行队列。
scripts/run_test_binaries.py 负责调度,scripts/ci_process.py 负责执行和回收。 每个 suite 都有执行截止时间,以及独立的 5 秒清理预算。输出写入文件;父进程退出后, 继承 stdout 的后代不会再让调度器无限等待管道 EOF。Windows 在运行测试代码前把等待 握手的 launcher 加入 kill-on-close Job Object,Unix 使用独立进程组;清理会核实组内 已经没有活跃进程。macOS 使用有超时的进程状态查询区分已死的僵尸进程与活跃成员, 不能只用 killpg 的权限错误推断是否还在运行。取消会停止等待的任务并回收正在运行的 suite,启动或清理失败仍然 判失败,不自动重跑测试。
每个 suite 的合并日志上限为 64 MiB;超限会明确报错,不能静默截断后仍宣称测试成功。 真实进程回归测试使用 python -m unittest discover -s scripts -p 'test_ci_process.py'。 goal_uncertain 夹具也为每次异步 ACP 往返设置截止时间,并在 drop 时终止客户端进程, 避免缺失响应让阻塞 reader 拖住测试运行时退出。
调度器通过原生 Python runner 将 Cargo 环境保存为 JSON,不再读取 Git Bash 文本格式的 env,因此 Windows PATH 等进程变量始终保持 原生表示。Python 探测会实际执行一次 import,再把已验证解释器解析为绝对路径;写入 Cargo runner 变量前还会转换成不含空格的 Windows short path,因此 Windows Store 的 应用执行别名无法伪装成可用 Python。
测试 profile 只优化大图附件夹具使用的 image、png 依赖,不缩小图片或删除断言。 主机归档验证会一次构建 CLI 和 smoke driver,原子更新 dist 后运行解包的程序; 两个 smoke target 都遵守 CARGO_TARGET_DIR 和当前平台的可执行文件后缀。 Cargo timing 将编译与测试执行分开记录,历史对照数据保存在性能方法文档中。
原生测试夹具也遵守同一平台边界:PTY API 在 Windows 使用 COMSPEC,不假定存在 sh; LSP 夹具执行已验证的绝对 Python;祖先目录遍历测试使用每次运行唯一的 marker,开发者真实 主目录中的文件不会改变断言结果。
Windows 保留 Cargo 内建 test profile 和标准 target/debug 布局,但在 workflow 中覆盖 该 profile 的 debug 与 split-debug 字段,避免为约两百个短生命周期测试二进制生成和链接 调试数据库。panic 文本仍包含源码位置,开发环境和 Linux 测试仍保留行表回溯。Doctest 只由 Linux 源码门禁执行一次;Windows job 负责原生可执行行为,并显式设置 RUN_DOCTESTS=0, 不再重复一个曾增加八分多钟的跨平台 rustdoc 阶段。Windows 运行会上传 Cargo timings、 构建/环境捕获日志和逐 suite 日志,成功运行也保留诊断;私有 cargo-env.json 不上传。
MSVC 版 zuno.exe 通过仅作用于该二进制的 build-script linker 参数保留 8 MiB 主线程栈。 原生 dumpbin 证据表明,PE 默认的 1 MiB 会在真实 session 构造路径中溢出。该参数不会写入 全局 RUSTFLAGS,因此库和约两百个测试二进制仍可复用原有编译缓存身份。
编译快照与写入归属
编译器使用固定版本 sccache 0.16.0 的本地磁盘后端;SCCACHE_GHA_ENABLED=false 避免逐对象向 GHA 上传。CARGO_INCREMENTAL=0 保持启用,registry/Git 输入单独缓存, 不再保存或共享 Cargo target 树。成功运行也保留 Cargo timings、缓存统计及 Windows 逐套件诊断;私有 cargo-env.json 始终不上传。
三个 PR 临界路径和六个候选目标通过 .github/actions/compiler-cache 恢复本地编译快照。 slot 隔离用途和 target,兼容前缀包含实际 Rust 编译器身份、Cargo profile/配置、相关编译 参数和 sccache 版本;source/run/attempt 后缀使每份快照不可变。只升级 workspace 版本 不会清空恢复前缀;sccache 仍核对每个编译请求,发生变化的 package 会正常重新编译。
每个 slot 的本地上限为 768 MiB,九个满槽稳态约 6.75 GiB,此外还有 registry 缓存和 替换期间的临时副本。只有新 key 已可见,可信清理 job 才删除该 slot 在 refs/heads/main 下更旧的 zuno-compiler-v1- key;它不删除其他 PR 缓存、发布归档或 候选证据。仓库总限额仍可能触发淘汰,所以首次冷恢复属于正常情况。缓存 miss 或可选传输 失败不会跳过编译、测试、provenance 或归档 smoke。
PR 自己写入的 GHA 缓存不能作为其他 PR 的共享基线。 warm-compiler-caches.yml 因此仅在可信 main 上编译与消费者一致的 Linux 测试、 Windows 测试和主机 release 构建面,并使用相同的环境/profile。这些额外编译在后台执行, 不是发布的前置条件。工作流启用后,可在 main 上手动运行 Warm compiler caches; 其他 ref 会被拒绝。
候选缓存产物名为 compiler-cache-release-<target>-<attempt>,不进入 candidate-* 发布产物命名空间,保留两天。release.yml 完成验证和公开发布后才输出可选 handoff, 随后显式 dispatch main 上的缓存发布器。GitHub 将 workflow_run 的缓存权限限制为 只读,因此不能用该事件写入共享快照。发布器会有界等待来源 Release 的精确 attempt 成功结束,再核对来源 main Release 工作流、精确 attempt、公开 tag commit、相同 Git tree、candidate 成功状态、artifact 身份、可移植路径、体积上限和逐文件 摘要,再写入 main 缓存。可选缓存缺失或过期不影响已经发布的版本。发布器在 Linux 上 传输不执行的缓存字节,与消费者使用相同的相对路径和 enableCrossOsArchive 设置, 不重新构建或上传发布二进制。
修改 CI 工具、工作流或构建配置时,还会在六个支持的 OS/架构组合上运行原生 Python 进程/缓存测试。原生测试先运行,再探测缓存传输;Linux 写入的夹具可用时,各平台校验 恢复后的字节。缓存 miss 或服务错误会明确记录为未执行探针,不阻止原生测试运行。普通业务源码 PR 保留原有 Rust 门禁;稳定汇总门禁只允许分类器明确判定不需要的工具检查跳过。
候选不会把 release-please 标签本身当作无害 diff 的证据。automatic 与 dry-run 模式要求 release head 是精确 PR base 之上的单个 commit,变更文件集合必须恰好是 .release-please-manifest.json、CHANGELOG.md、Cargo.lock 与 Cargo.toml,同时拒绝 空白错误并验证只增加一个 patch。只有证明这一点后,轻量候选门禁才检查 locked metadata 与供应链策略。功能 PR 已运行完整 Linux/Windows 测试矩阵,而候选仍会编译并实际执行每个 最终平台制品;release PR 一旦修改可执行源码,会在进入轻量门禁前失败。
Artifact 传输固定到 actions/upload-artifact v7.0.1 和 actions/download-artifact v8.0.1 的精确提交,这两个 action 使用 Node 24 runtime。 Linux musl leg 不再使用基于 Node 的 Zig setup action;.github/scripts/install-zig.sh 会按 Linux runner 的 x86_64 或 aarch64 架构选择 Zig 0.13.0 官方归档,并在解压前使用 硬编码的官方 SHA-256 校验。
Linux 作业会安装发行版提供的 bwrap-userns-restrict AppArmor profile,并在运行 Zuno 前 分别验证 user/mount/PID namespace 路径和 network namespace 路径。它不会关闭 Ubuntu 宿主级的非特权 user namespace 限制。部署依据见沙箱 FAQ。
装上后端并不等于证明它真的做了限制,因此功能 PR 的 Linux test job 会运行 make test-sandbox-e2e。该 target 会构建沙箱所需的 zuno helper 可执行文件,并运行 边界测试:在 bwrap 下执行一个真实进程,要求工作区内写入成功,而写入 .git、.zuno、 .agents、工作区外目录以及指向工作区外的符号链接全部失败;要求有效 capability 集合与 NoNewPrivs 与声明一致;要求 AF_INET socket、ptrace、process_vm_readv 返回 EPERM,同时 AF_UNIX socketpair IPC 仍然可用;并要求 read-only 策略拒绝同一次写入。
当宿主没有 bubblewrap 或没有 helper 可执行文件时,该测试会以具名原因 skip,因此在开发机 上执行 make test 不会受影响。功能 PR 门禁设置 ZUNO_SANDBOX_E2E_REQUIRE=1,把这种 skip 变成失败。release candidate 只有在证明 head 是位于已通过功能测试的 main 之上的 精确四文件发布增量后,才可以省略这套重复边界测试;两个 Linux artifact leg 仍会安装后端并 执行归档内二进制。本地可用 make test-sandbox-e2e 运行同一道门禁,make pre-ci 已包含它。
提交 CI 前,Linux 开发者运行 make pre-ci。它会执行主机侧源码门禁、构建并 smoke 打包后的主机归档,并通过 Zig 对完整 workspace 执行 x86_64-pc-windows-gnu Clippy, 随后通过 cargo-zigbuild 链接全部 Windows GNU 测试二进制但不执行。这条交叉检查可以在本地发现 Windows 条件编译与链接错误,但无法证明 MSVC、ConPTY、Windows Job Object 或 hosted runner 的 loopback 行为,因此原生 Windows CI 仍是最终证据。
两个 macOS 发布目标仍保留精确 Rust triple。x86_64-apple-darwin leg 在 macos-15 Arm64 runner 上交叉构建,先通过 lipo 校验 zuno 与 zuno-smoke, 再用 arch -x86_64 运行 x86_64 smoke driver,通过 Rosetta 2 实际执行归档内的 x86_64 二进制。aarch64-apple-darwin leg 则以 arch -arm64 校验并执行。 翻译层、架构或 smoke 任一失败都会阻止 attestation 和上传;这项优化不会用静态检查替代执行。
仓库 ruleset 严格要求 zuno/pr-gate,并要求分支相对 base 保持最新。缺少这条规则时,候选 工作流会拒绝合并。RELEASE_CANDIDATE_AUTOMATION=true 只负责调度候选并认证精确的 release PR head,认证不等于批准或合并;自动合并由独立的 RELEASE_CANDIDATE_AUTO_MERGE=true 显式开启。未开启第二个开关时,维护者必须重新核对精确 head 后手动合并已认证的 PR,用户身份产生的 merge push 会自然唤醒发布收尾。只有显式开启 第二个开关时,候选工作流才为精确 head 启用 squash auto-merge、等待 GitHub 确认合并,并因 GITHUB_TOKEN 合并不会触发新 workflow 而显式调度发布收尾。
候选身份
封存后的 release-candidate Actions artifact 保留七天,只能通过 workflow run ID 访问。 其中包含六个归档、SHA256SUMS、逐目标证据和 candidate-manifest.json。Manifest 绑定:
- 仓库、签名 workflow ref 与 workflow commit;
- run ID 与 attempt;
- release PR、源 commit、release PR head 与 Git tree;
- 版本与预期 tag;
- 每个目标的归档名、字节数、SHA-256、build/smoke 结论、runner 与 attestation 身份。
发布过程不存在“选择最新 artifact”。晋级会校验 workflow 路径、事件、结论、源 SHA、PR 合并状态、manifest 字段、精确目标集合、大小、checksum 与 GitHub provenance。squash 后的 tag commit 可以与 PR head commit 不同,但两者 Git tree 必须逐字节一致。
发布与恢复
release-please 创建 tag 和 draft release。晋级过程只在 draft 状态上传资产;重新读取并确认 完整资产集合后才转为公开发布。任何不匹配都会让 draft 保持未发布。
自动失败绝不会回退到重新编译。恢复必须显式执行:
- 在精确 release tag 上以
mode=backfill调度release-candidate.yml; - 记录成功的 run ID;
- 以
mode=promote调度release.yml,传入该 run ID、已合并 release PR、候选源 SHA 和 现有 tag。
这样正常路径始终只有一条,同时允许运维者从 artifact 过期或最终发布中断中恢复,而不降低 身份校验强度。
快速开发版本规则
在 AGENTS.md 中对应规则被删除之前,每次发布都必须恰好增加一次 patch: x.y.z 变成 x.y.(z+1)。feat 与 fix 仍用于组织 changelog,但 1.0 之前的 feature 通过 bump-patch-for-minor-pre-major 保持 patch。贡献者不得用 !、 BREAKING CHANGE 或 Release-As 请求更高版本位。
发布控制器不会只依赖约定。候选调度前,它用 .github/scripts/require-patch-release.py 比较当前 manifest 与 release PR manifest; 发布时再比较上一条稳定 tag 与候选 tag。minor、major、跳 patch 或 prerelease 候选都会在 认证或公开发布前失败关闭。
CI 临界路径
第一轮拆分没有缩短端到端时间。基线 PR run 33884240281 用时 17 分 45 秒;run 33935212394 用时 18 分 14 秒。Linux 从 16 分 36 秒的组合 job 改为 14 分 13 秒的 test job 与 4 分 41 秒的 static checks 并行,但主机 artifact smoke 从 9 分 53 秒退化到 18 分 01 秒,反而成为临界路径。release candidate 也几乎没有变化: 33880945283 用时 17 分 10 秒,33936248015 用时 16 分 59 秒,其中原生 x86_64 Windows 制品构建单独占 16 分 12 秒。
第二轮因此优化编译依赖复用,而不是继续增加 job。按 target/用途隔离的缓存覆盖三个实测 PR 瓶颈与两个原生 Windows 候选 leg。release-only delta 证明则移除 13 分钟的候选重复全量 测试,同时不减少功能 PR 的完整测试,也不减少任何最终制品的 smoke 与 attestation。
原生 Windows 继续按 Cargo test suite 粒度运行,而不是为每个 test case 启动进程。实测 844 秒中,build/link 占 548 秒,230 个 suite 执行占 292 秒。scheduler 现在使用稳定的 crate-directory:target 时长键与该次运行审阅后的 hint,因此全新 runner 会立即启动 170 秒 attachment suite、132 秒 tools suite 与 66 秒 TUI suite,不再让它们成为最后长尾。 捕获的私有 Cargo 环境永远不会上传。
时延证据
从 release PR 创建开始计时,到公开 release 发布结束,runner 排队也计入。只有连续三次 端到端运行都在 20 分钟内完成、发布阶段不超过三分钟,并且下载后的发布资产通过 checksum、 provenance 与黑盒 smoke,才能宣称这项改造完成。时延目标不会放宽逐目标实际执行、 候选字节身份或发布校验。