# 工程实践 · 测试、构建与发布

> 一次改动从本地到用户手里要过四道关：clippy、参数注释 lint、cargo-deny 与仓库自检脚本把住静态检查；core 集成测试用 wiremock 扮演模型，同一批测试还能换到 Docker 或 Wine 里的远程执行端上再跑一遍；PR 只跑 Bazel 与少量 Cargo 检查，全平台 nextest 留到合并之后；发版由 rust-v 开头的 tag 触发，签名、打包、npm 可信发布与 R2 镜像一路自动完成。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/engineering
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 工程实践 · 测试、构建与发布

[从源码构建与运行](https://daiw.net/manual/codex-source/build-and-run)讲了 Cargo 与 Bazel 两套构建、`justfile` 和 npm 包装，[读源码之前](https://daiw.net/manual/codex-source/reading-the-source)讲了 `AGENTS.md` 里的编码与测试约定。这一篇把镜头拉远：一次改动从本地提交到用户手里，要依次过哪几道关。下面的内容都以仓库里的配置与脚本为准。

## 怎么用

装 Codex 看手册[安装、登录与升级](https://daiw.net/manual/codex/installation)。给仓库提改动时常用的命令，`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` 的禁用方法清单，[从源码构建与运行](https://daiw.net/manual/codex-source/build-and-run)与[读源码之前](https://daiw.net/manual/codex-source/reading-the-source)已经讲过。值得补充的是两套构建怎么共用一把尺子：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 架构](https://daiw.net/manual/codex-source/tui-architecture)里“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 匹配器：

```rust
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 替被中断的调用补上的输出：

```rust
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 响应体就是“模型”的一次回复。

<Callout type="warn">

  `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()`。照抄示例编译不过，以现有测试为准。

</Callout>

同一批测试还能换个执行环境再跑一遍。`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](https://daiw.net/manual/codex-source/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` 上。入口只有两个：

```mermaid
flowchart LR
  PR[PR 的合并提交] --> BC[blocking-ci]
  MAIN[推送到 main] --> BC
  MAIN --> PM[postmerge-ci]
  BC --> BZ[bazel<br/>test、clippy、发布构建校验]
  BC --> RC[rust-ci<br/>fmt、shear、参数注释 lint]
  BC --> OT[repo-checks、cargo-deny<br/>codespell、blob 体积、sdk]
  BZ --> REQ{CI required<br/>全部 success？}
  RC --> REQ
  OT --> REQ
  PM --> FULL[rust-ci-full<br/>clippy 矩阵、nextest 分片<br/>远程执行端测试]
  PM --> V8[v8-canary]
  FULL --> RES[Postmerge CI results]
  V8 --> RES
```

`blocking-ci.yml` 在 PR 与推送 `main` 时运行，调用七个子工作流，最后由 `required` 汇总，仓库的合并规则只需要要求这一个检查：

```yaml
  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`），同一时间只跑一个发版流程：

```mermaid
flowchart TB
  TAG[推送 rust-v 开头的 tag] --> TC[tag-check<br/>tag 与 Cargo.toml 版本一致]
  TC --> B[build<br/>macOS 与 Linux musl 的 cargo 构建<br/>Linux 产物 cosign 签名]
  TC --> W[build-windows<br/>Azure Trusted Signing]
  B --> MAC[macOS 签名与公证<br/>rcodesign 加 Azure Key Vault<br/>DMG 在 macOS 上验证]
  B --> NPM[stage-npm-packages]
  MAC --> NPM
  W --> NPM
  NPM --> REL[release<br/>GitHub Release 与 SHA256SUMS]
  REL --> PN[publish-npm<br/>OIDC 可信发布]
  REL --> DS[publish-dotslash]
  REL --> R2[publish-r2<br/>镜像到 releases.openai.com]
  DS --> R2
  REL --> WG[winget 与开发者网站<br/>仅正式版]
```

构建用的仍是 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。签好的二进制按[从源码构建与运行](https://daiw.net/manual/codex-source/build-and-run)介绍的标准包布局打成归档，`release` 任务把所有归档的 SHA-256 汇总进 `codex-package_SHA256SUMS`，连同 `config-schema.json` 和安装脚本一起挂上 GitHub Release。

npm 的发布顺序有讲究：

```bash
          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](https://daiw.net/manual/llm-to-agent/agent-loop)把智能体归结为“调用模型、执行工具、回填结果”的循环。Codex 的集成测试把循环的一端换成了剧本：`mount_sse_sequence` 里的每个响应体是模型的一次回复，`ResponseMock` 记下循环每一圈发出的请求，“第二圈的请求有没有带上第一圈的工具结果”就能写成确定性的断言，不用真模型也能测完整条循环。[中断](https://daiw.net/manual/llm-to-agent/interrupt)一篇强调每个 `tool_use` 都要有配对的结果、中断时要补一个“已取消”：上面的用例验证的正是 Codex 补上的 `aborted by user` 输出，而 `validate_request_body_invariants` 把配对规则变成了集成测试共用的前置检查。

[流式处理](https://daiw.net/manual/llm-to-agent/streaming)花了不少篇幅讲工具参数以部分 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 工程底座](https://daiw.net/manual/grok-build/rust-engineering)里，自更新先下载、跑 `--version` 冒烟测试，再切换受管的符号链接；Codex 的安装脚本同样先验证再切换，校验靠两级 SHA-256 与版本号比对，区别在于它是一个 shell 安装脚本，而 grok 把这套逻辑写进了 CLI 自己的 Rust crate。

---

上一篇：[Codex Cloud、代码审查与 worktree](https://daiw.net/manual/codex-source/cloud-and-review) · 下一篇：[复盘 · 对照《从 LLM 到 Coding Agent》](https://daiw.net/manual/codex-source/recap)
