工程实践 · 测试、构建与发布
一次改动从本地到用户手里要过四道关:clippy、参数注释 lint、cargo-deny 与仓库自检脚本把住静态检查;core 集成测试用 wiremock 扮演模型,同一批测试还能换到 Docker 或 Wine 里的远程执行端上再跑一遍;PR 只跑 Bazel 与少量 Cargo 检查,全平台 nextest 留到合并之后;发版由 rust-v 开头的 tag 触发,签名、打包、npm 可信发布与 R2 镜像一路自动完成。
工程实践 · 测试、构建与发布
从源码构建与运行讲了 Cargo 与 Bazel 两套构建、justfile 和 npm 包装,读源码之前讲了 AGENTS.md 里的编码与测试约定。这一篇把镜头拉远:一次改动从本地提交到用户手里,要依次过哪几道关。下面的内容都以仓库里的配置与脚本为准。
怎么用
装 Codex 看手册安装、登录与升级。给仓库提改动时常用的命令,justfile 与 AGENTS.md 里都写好了:
| 场景 | 命令 |
|---|---|
| 跑改动所在 crate 的测试 | just test -p codex-core;AGENTS.md 明确要求不要直接 cargo test |
| 格式化、clippy 自动修复 | just fmt、just fix -p <crate> |
| 审阅 TUI 快照的变化 | cargo insta pending-snapshots -p codex-tui,确认无误再 cargo insta accept -p codex-tui |
检查 /*param*/ 参数注释 | just argument-comment-lint -p codex-core |
| 用 Bazel 跑全部测试 | just bazel-test |
| 改了依赖后刷新 Bazel 锁文件 | just bazel-lock-update,并提交 MODULE.bazel.lock |
| 让测试连到 Docker 里的执行端 | source scripts/test-remote-env.sh 后照常 just test,用完调用 codex_remote_env_cleanup |
| 合并之前先跑一遍完整 CI | 推到名字里带 full-ci 的分支 |
怎么实现
第一关:静态检查
workspace 级的 36 条 clippy deny 和 codex-rs/clippy.toml 的禁用方法清单,从源码构建与运行与读源码之前已经讲过。值得补充的是两套构建怎么共用一把尺子:Bazel 跑 clippy 用的是 .bazelrc 里 build:clippy 配置下的一串 clippy_flag,同样读 codex-rs/clippy.toml;.github/scripts/verify_bazel_clippy_lints.py 在 CI 里逐条比对这些标志与 codex-rs/Cargo.toml 的 [workspace.lints.clippy],改了一边忘了另一边就过不去。clippy.toml 还把 tokio 的 MutexGuard、RwLockReadGuard、RwLockWriteGuard 列进 await-holding-invalid-types,配合 await_holding_invalid_type = "deny",持着这几种锁跨过 .await 会直接报错。
仓库还自带一个 lint。tools/argument-comment-lint 是一个 Dylint 库,专管 /*param*/ 形式的参数注释:argument_comment_mismatch 检查注释名与被调函数的形参名是否一致,uncommented_anonymous_literal_argument 找出没带注释的 None、true、数字之类的字面量实参(字符串与字符字面量除外)。两条 lint 默认分别是 warn 和 allow,在本仓库运行时都提升为错误,所以 TUI 代码里随处可见 /*animations_enabled*/ true 这样的写法。它要用 rustc_private,MODULE.bazel 因此另外登记了一套 2025-09-18 的 nightly 工具链;全仓库检查走 Bazel aspect,按 crate 检查则用 DotSlash 拉预编译的版本。
另一类检查是写成脚本的约定。合并门禁里的 repo-checks 跑 verify_cargo_workspace_manifests.py(每个 crate 继承 workspace 的版本、edition、license,声明 [lints] workspace = true,包名符合目录约定,不许新增 crate feature)和 verify_tui_core_boundary.py:后者禁止 codex-tui 依赖或引用 codex-core,报错信息让人改走 app-server 协议,暂时绕不开的放到 codex_app_server_client::legacy_core 后面。TUI 架构里“TUI 只是 app-server 的一个客户端”这条分层,就是靠这个脚本守住的。cargo-deny 管供应链:codex-rs/deny.toml 拒绝未登记的 registry 与 git 源,git 依赖必须钉到 rev,许可证走白名单;被忽略的安全公告有 11 条,注释要求每条写明依赖路径与移除条件,并与 codex-rs/.cargo/audit.toml 保持同步。此外还有 cargo shear --deny-warnings 查未使用的依赖、codespell 查拼写,以及一条体积红线:新增或改动的文件超过 512000 字节就挡下,除非登记在 .github/blob-size-allowlist.txt 里。
第二关:用假模型测真 agent
AGENTS.md 规定,改动 agent 逻辑的功能必须补集成测试。codex-rs/core/tests/all.rs 把 suite/ 下 205 个 .rs 文件编成一个测试二进制,公共设施放在 codex-rs/core/tests/common,crate 名 core_test_support。它的核心是用 wiremock 起的一个假 Responses API:start_mock_server() 开服务器,ev_response_created、ev_function_call、ev_completed 等函数造事件,sse(...) 把事件拼成一行 event:、一行 data: 的 SSE 响应体;mount_sse_once 挂一次回复,mount_sse_sequence 按顺序挂多次回复,并断言请求次数不多不少。mount_sse* 系列都返回 ResponseMock,它本身就是一个 wiremock 匹配器:
impl Match for ResponseMock {
fn matches(&self, request: &wiremock::Request) -> bool {
self.requests
.lock()
.unwrap()
.push(ResponsesRequest(request.clone()));
// Enforce invariant checks on every request body captured by the mock.
// Panic on orphan tool outputs or calls to catch regressions early.
validate_request_body_invariants(request);
true
}
}(codex-rs/core/tests/common/responses.rs:725)
每个打到 /responses 的请求先被记下,再过一遍 validate_request_body_invariants:请求 input 里的每个 function_call_output、custom_tool_call_output、tool_search_output 都必须有同一 call_id 的调用,每个调用也必须有输出,否则当场 panic。suite/ 里一千五百多个测试,凡是经过这个假服务器的,都顺带守着“工具调用与结果成对”这条规矩,不必逐个写断言。
测试读起来像一份剧本。下面这个用例让“模型”第一轮要求执行 sleep 60,命令跑起来后中断,再让用户说一句话,最后检查第二次请求里是否带着 Codex 替被中断的调用补上的输出:
async fn interrupt_tool_records_history_entries() {
let command = "sleep 60";
let call_id = "call-history";
// ...
let first_body = sse(vec![
ev_response_created("resp-history"),
ev_function_call(call_id, "exec_command", &args),
ev_completed("resp-history"),
]);
let follow_up_body = sse(vec![
ev_response_created("resp-followup"),
ev_completed("resp-followup"),
]);
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await;
// ...
codex.submit(Op::Interrupt).await.unwrap();
// ...
let output = response_mock
.function_call_output_text(call_id)
.expect("missing function_call_output text");
let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9])?) seconds\naborted by user$")
.expect("compile regex");(codex-rs/core/tests/suite/abort_tasks.rs:212)
搭实例的是 test_codex() 构建器:build(&server) 用临时目录当 CODEX_HOME,把模型地址指向假服务器的 /v1(codex-rs/core/tests/common/test_codex.rs:577),剧本里的每个 SSE 响应体就是“模型”的一次回复。
AGENTS.md 给的集成测试示例还写着 codex.submit(Op::UserTurn { ... }) 和 request.json_body(),在 v0.158.0 里都对不上:codex-protocol 的 Op 没有 UserTurn 这个变体,suite/ 里的测试用 start_or_steer_turn(TurnInputRequest::user_input(...)) 发起一轮;ResponsesRequest 取请求体的方法叫 body_json()。照抄示例编译不过,以现有测试为准。
同一批测试还能换个执行环境再跑一遍。build_with_auto_env() 按 CODEX_TEST_ENVIRONMENT 在 local、docker、wine-exec 之间选择(codex-rs/core/tests/common/test_environment.rs:150),后两种经 CODEX_TEST_REMOTE_EXEC_SERVER_URL 连到一个远程 exec-server:Docker 模式的执行端是 Linux 容器,Wine 模式的执行端是 Windows,工作目录形如 C:/codex-core-test-cwd-<id>。Docker 环境由 scripts/test-remote-env.sh 起容器、在里面运行 codex exec-server;Wine 变体由 Bazel 生成,codex_rust_crate 的 run_tests_with_wine_exec 为每个集成测试多出一个 -wine-exec-test 目标(defs.bzl:618),把只链接了 codex-exec-server 的测试夹具交叉编译成 windows_x86_64_gnullvm,在 Linux x86_64 上用 Wine 跑起来,再以 CODEX_TEST_ENVIRONMENT=wine-exec 运行原生的测试二进制去连它(codex-rs/exec-server/testing/wine_remote_test_runner.rs:38)。打开这个开关的是 core 与 app-server 两个 crate,AGENTS.md 也要求新测试默认用 build_with_auto_env(),好让一份测试同时覆盖“Linux 控制端、Windows 执行端”这样的跨系统组合。
TUI 这边靠快照。组件测试把界面画进 ratatui 的 TestBackend,再用 insta::assert_snapshot!(terminal.backend()) 与仓库里的 .snap 文件比对(例如 codex-rs/tui/src/status_indicator_widget.rs:381);要连同终端转义序列一起验证时,换用 codex-rs/tui/src/test_backend.rs 里的 VT100Backend,它把 crossterm 的输出喂给 vt100::Parser,模拟一台真终端。全仓库 1,424 个 .snap 文件里有 1,326 个在 codex-rs/tui,审阅快照差异就是审阅界面改动。
Cargo 这条路上的测试由 cargo-nextest 执行,配置在 codex-rs/.config/nextest.toml:失败用例默认重试一次,超过 30 秒记为慢测试、两个周期后终止;会起 app-server 子进程的集成测试放进 max-threads = 1 的测试组串行执行,本地的 local profile 放宽到 4 个;个别已知的慢用例单独放宽超时,注释写着“Do not add new tests here”。Bazel 那边在各 crate 的 BUILD.bazel 里切分片,codex-rs/core/BUILD.bazel 把 core-all-test 切成 16 片、单元测试切成 8 片,macOS 上测试线程数限为 1。
第三关:分层的 CI
.github/workflows/README.md 写明了分工原则:PR 要快、结果好审,全平台验证放到 main 上。入口只有两个:
blocking-ci.yml 在 PR 与推送 main 时运行,调用七个子工作流,最后由 required 汇总,仓库的合并规则只需要要求这一个检查:
required:
name: CI required
# Without `always()`, GitHub skips this job after a failed dependency and a
# required check can appear successful instead of reporting the failure.
if: ${{ always() }}
needs:
- bazel
- blob-size-policy
- cargo-deny
- codespell
- repo-checks
- rust-ci
- sdk
# ...
- name: Require successful dependencies
env:
NEEDS: ${{ toJSON(needs) }}
run: python3 .github/scripts/check_ci_results.py(.github/workflows/blocking-ci.yml:48)
注释点出了一个容易踩的坑:不加 always(),依赖失败时 GitHub 会跳过汇总任务,被跳过的必需检查可能显示为通过。check_ci_results.py 因此把 skipped、cancelled 也算作失败,只认明确的 success,postmerge-ci.yml 用同一个脚本收尾。按 README 的说法,必需检查跑在 GitHub 合成的合并提交上,而不只是 PR 的头提交。
PR 路径上,Rust 的主力是 bazel.yml:macOS 的 aarch64 与 x86_64、Linux 的 gnu 与 musl 各跑一遍 bazel test //...(Linux arm64 自 2026-02-27 起因不稳定被注释掉),Windows 测试按目标名的 cksum 分到 4 台 Windows 机器上,另有 clippy 与发布构建校验,各任务限时 30 分钟;原生 MSVC 的 Windows 测试太慢,只在合并后跑。rust-ci.yml 刻意保持轻量:先按改动路径决定跑哪些任务,再做 cargo fmt、cargo shear、基准测试冒烟和三个平台的参数注释 lint。有 BuildBuddy 密钥时,run_bazel_with_buildbuddy.py 为 openai/codex 的可信运行选 OpenAI 的缓存与远程执行,fork 带密钥时用通用主机,没有密钥就全部在本机执行。
合并之后,postmerge-ci.yml 调用 rust-ci-full.yml 与 V8 canary。前者是完整的 Cargo 验证:各平台 cargo clippy --tests 加 -D warnings;nextest 先在每个平台打一个测试归档,再用 --partition hash:N/4 分 4 片执行,Windows ARM64 的归档在 x64 机器上交叉编译、拿到原生 ARM64 机器上跑;Linux x64 那一路打开 remote_env,跑远程执行端测试。这里用的 Cargo profile 是 ci-test,opt-level = 0,注释说是为了减小二进制、缓解磁盘压力。全部工作流引用的第三方 action 都钉在完整的 commit SHA 上,checkout 一律 persist-credentials: false,不少任务末尾还有一步 check-clean-worktree,确认没有留下该提交却没提交的生成文件。
第四关:发布与分发
发版从推送 rust-v 开头的 tag 开始(.github/workflows/rust-release.yml),同一时间只跑一个发版流程:
构建用的仍是 Cargo:每个目标一次 cargo build --release,Linux 发的是 musl 链接的版本;每个平台分 primary(codex、codex-code-mode-host、codex-responses-api-proxy,Linux 另加 bwrap)和 app-server 两个包。bwrap 先编好并算出摘要,再编 codex,让它内嵌将随包发出的那份 bwrap 的摘要;Linux 的语音运行时则在同一个任务里用 Bazel 构建。签名分三路:Linux 产物用 cosign 生成 .sigstore 签名包;macOS 二进制在 Linux 机器上用 rcodesign 签名,私钥在 Azure Key Vault,只有受保护的 codesigning 环境能用,签完公证,DMG 最后在 macOS 上验证;Windows 走 Azure Trusted Signing。签好的二进制按从源码构建与运行介绍的标准包布局打成归档,release 任务把所有归档的 SHA-256 汇总进 codex-package_SHA256SUMS,连同 config-schema.json 和安装脚本一起挂上 GitHub Release。
npm 的发布顺序有讲究:
root_tarball="dist/npm/codex-npm-${VERSION}.tgz"
sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz"
# Keep this list in sync with CODEX_PLATFORM_PACKAGES in
# codex-cli/scripts/build_npm_package.py. The root wrapper advances
# @openai/codex@latest as soon as it publishes, so every platform
# package it aliases must already exist in the registry first.
# ...
# npm returns HTTP 409 when concurrent publishes update the same
# packument. Every platform tarball is a version of @openai/codex,
# so publish all tarballs serially.
tarballs=(
"${platform_tarballs[@]}"
"${other_tarballs[@]}"
"${root_tarball}"
)
# The SDK depends on this exact root package version.
if [[ -f "${sdk_tarball}" ]]; then
tarballs+=("${sdk_tarball}")
fi(.github/workflows/rust-release.yml:1826)
根包 @openai/codex 一发布就推进 latest,而它靠别名依赖六个平台包,所以平台包必须先上;这些平台包其实都是 @openai/codex 的不同版本(如 0.158.0-linux-x64),并发发布会撞上 npm 的 409,只能串行;SDK 依赖根包的确切版本,排在最后。发布走 npm 的 OIDC 可信发布,不需要 NODE_AUTH_TOKEN;已经发过的版本会被跳过,重跑流程是安全的。哪些版本上 npm 由版本号决定:x.y.z 进 latest,x.y.z-alpha.N 进 alpha 标签,平台包另带 linux-x64 这样的平台标签,其余版本不发 npm;winget 与 developers.openai.com 的更新只针对正式版。
GitHub Release 之后,publish_r2_release.py 把全部资产镜像到 Cloudflare R2 的 codex/releases/<版本>/ 下并生成 release.json,校验通过再推进 codex/channels/latest,正式版还会更新 codex/install.sh 与 codex/install.ps1 两个入口。安装脚本 scripts/install/install.sh 默认先找 https://releases.openai.com/codex,不通再回落到 GitHub:先从发布元数据取得 codex-package_SHA256SUMS 的摘要并校验这份清单,再按清单校验平台包;解压到 $CODEX_HOME/packages/standalone/releases/ 下之后,确认新二进制报出的版本号无误,才用“先建临时链接、再 mv 覆盖”的办法原子地切换 current 符号链接。npm 这条路上,codex.js 启动原生程序时会设置 CODEX_MANAGED_BY_NPM(或 bun、pnpm、Vite+ 对应的变量),codex-install-context 据此判断安装方式,TUI 提示升级时给出对应的命令。
和《从 LLM 到 Coding Agent》对照
Agent Loop把智能体归结为“调用模型、执行工具、回填结果”的循环。Codex 的集成测试把循环的一端换成了剧本:mount_sse_sequence 里的每个响应体是模型的一次回复,ResponseMock 记下循环每一圈发出的请求,“第二圈的请求有没有带上第一圈的工具结果”就能写成确定性的断言,不用真模型也能测完整条循环。中断一篇强调每个 tool_use 都要有配对的结果、中断时要补一个“已取消”:上面的用例验证的正是 Codex 补上的 aborted by user 输出,而 validate_request_body_invariants 把配对规则变成了集成测试共用的前置检查。
流式处理花了不少篇幅讲工具参数以部分 JSON 的形式分片到达、必须攒齐再解析。Codex 的 SSE 解析直接忽略 response.function_call_arguments.delta(codex-rs/codex-api/src/sse/responses.rs:553),函数调用以 response.output_item.done 里的完整条目为准(自定义工具的输入增量 response.custom_tool_call_input.delta 倒是会转发,apply_patch 借它边流边预览 diff,见 codex-rs/core/src/tools/handlers/apply_patch.rs:411),测试里的 ev_function_call 也就只造整条事件,不必模拟分片。Grok Build 的Rust 工程底座里,自更新先下载、跑 --version 冒烟测试,再切换受管的符号链接;Codex 的安装脚本同样先验证再切换,校验靠两级 SHA-256 与版本号比对,区别在于它是一个 shell 安装脚本,而 grok 把这套逻辑写进了 CLI 自己的 Rust crate。
上一篇:Codex Cloud、代码审查与 worktree · 下一篇:复盘 · 对照《从 LLM 到 Coding Agent》