测试与发布

改动 agentrun 的代码后怎么证明它还能用:scripts/test.sh 跑哪些检查,tests/ 里的文件各负责什么,回放样本从哪里来、怎么更新,验收脚本在四种环境里怎么运行,以及 CI 与发布怎么进行。单元与集成测试不需要任何运行时与账号,验收脚本需要真实的运行时与登录。

代码位置

位置内容本页对应的章节
scripts/test.sh提交前运行的全部检查scripts/test.sh
scripts/check_comment.py注释检查,规则见 CLAUDE.md「代码规范」scripts/test.sh
scripts/check_docs.py设计文档的截图与机械检查,改动 docs/ 后运行scripts/test.sh
tests/*.rs、tests/support/cargo 集成测试与共用的辅助代码:support/mod.rs(Meta、invocation、replay、assert_replay、events、texts、network_events、WebServer、sandbox_available、system_bwrap、open_pty)、support/env.rs(Env、Env::with、Outcome、Agentrun、Finished、wait_until、poll_until、write_script)、support/fake.rs(FakeAdapter)、support/process.rs(take_over_if_spawned、spawn_role、spawn_lock、process_is_gone、wait_until_gone)、support/harness.rs(run_tests、test_list!)tests/ 的分工
tests/fixtures/回放样本:各运行时的原始输出与期望的事件流回放样本
scripts/accept/验收脚本 accept.sh、lib/util.sh、lib/scenarios.sh、lib/concurrent.sh 与 docker 镜像的 Dockerfile验收脚本
.github/workflows/ci.yml每个 PR 与 main 的推送在 Linux 与 macOS 上运行的检查CI
.github/workflows/release.yml四个可执行文件的构建与检查、第三方许可证文件、草稿 Release发布
about.toml、about.hbscargo-about 的配置(接受的许可证、构建目标)与 THIRD-PARTY-LICENSES.txt 的纯文本模板发布
Cargo.toml版本号、[profile.release] 与 cargo-binstall 的下载地址 [package.metadata.binstall]发布

src/ 里每个文件末尾的 #[cfg(test)] mod tests 是该文件的单元测试,测试纯函数与适配器生成的参数。过滤代理的测试在 src/network/tests.rs,它们在测试进程内用多个线程在回环地址上启动真实的过滤代理与上游服务。跨模块的行为、真实的子进程与沙箱放在 tests/。

scripts/test.sh

一条命令运行全部检查,任何一步失败就停止并以非 0 退出。提交前在本机运行,CI 也运行它(见 CI)。

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
uv run scripts/check_comment.py
shellcheck scripts/accept/accept.sh scripts/accept/lib/*.sh   # 没装 shellcheck 时打印 shellcheck not found, skipped

cargo test 同时运行 src/ 里的单元测试与 tests/ 里的集成测试。需要 bwrap、socat 或 sandbox-exec 的测试在当前环境没有这些程序或它们启动不了时打印 skipped: 开头的一行并通过,所以在没有沙箱的容器里 cargo test 也能全部通过;要覆盖沙箱路径,在装了 bwrap 与 socat 的 Linux 或 macOS 上运行。scripts/check_docs.py 不在 test.sh 里,改动 docs/ 后单独运行,用法见 CLAUDE.md「文档规范」。

tests/ 的分工

每个文件一个主题,文件名就是主题。测试之间可以并行:每个测试用自己的临时目录,端口由系统分配,不读写用户目录与全局配置。

只在进程内调用一个模块、不启动子进程、不改进程级状态的测试写在该模块的测试模块里,过滤代理的测试在 src/network/tests.rs。运行 agentrun、真实沙箱或子进程的测试留在 tests/,每个文件是一个单独的进程。理由:src 里的函数不必为了测试公开,挪动代码时也不用改 tests/;进程级状态(subreaper、信号处理、环境变量、当前目录)在 lib 的单元测试之间共用,只有单独的进程才能互不影响。

文件测试什么怎么测
framework.rs运行框架:事件顺序、subagent 编号与 parent、未结束调用的补发、失败的运行时在最后几行超过上限时结果稳定为 limit、--raw、退出码非 0 与适配器失败的 detail、会话临时目录的创建与删除、--dry-run 与 --debug 的输出、start 事件记录的沙箱种类FakeAdapter 加一个用 shell 脚本冒充的 claude 可执行文件,在临时目录里的假 PATH 下运行 run::run
session.rs会话环境:子进程环境变量的组装规则、--env 与 --env-file、--debug 与 --dry-run 列出的变量名、AGENTRUN_PI_AUTH 写入登录文件且不出现在输出里、凭据写入失败时在建临时目录之前拒绝与 framework.rs 相同
cli.rs命令行解析与启动前检查:每种用法错误、标准输入是终端且没给 prompt、--format 的预扫描、沙箱不可用时三种 --sandbox 的处理、AGENTRUN_SANDBOX 的来源与优先级、--network 的用法错误、--env 与 --env-file 的错误、--help 与 --version用假的 bwrap 与 socat 脚本模拟缺失、启动失败与挂起;真实 bwrap 或 sandbox-exec 可用时另测通过的情况
claudecode.rs、codex.rs、pi.rs每个适配器:回放样本得到期望的事件流;用假的运行时脚本测 --no-subagents、退出时没有结果事件、私有目录的创建与删除、登录文件检查、AGENTRUN_CODEX_AUTH 写入登录文件并链接进私有目录、模型检查、过滤代理的端口与服务主机assert_replay 回放 tests/fixtures/<运行时>/ 下的样本;其余用临时目录里的假可执行文件
dsh.rsdsh 适配器:回放样本得到期望的事件流;用假的 dsh 脚本测 argv、补丁文件的条目、会话环境里的 DSH_ 与 DEEPSEEK_ 变量、--dry-run 的命令与被删除的变量、缺少 DEEPSEEK_API_KEY 与 --image 在启动前被拒绝、--max-turns 与 --max-tokens 停止 dsh 后它输出 final 但没有 turn_end、以 0 退出时结果为 limit,已经输出 turn_end 的运行超过上限仍为 finished、turn_end 的错误成为 detail、--session-dir 保存会话目录与 --resume 把它放回按 cwd 计算的目录、沙箱运行时的代理变量同上。dsh 不回显提示词,回放时的提示词取 events.jsonl 第一行 prompt 事件的 text;support::replay 给 dsh 的会话环境加一个值为 replay 的 DEEPSEEK_API_KEY,使启动前检查通过
doctor.rsdoctor:运行时去重与顺序、用法错误的退出码、四个运行时的登录检查(dsh 另测 TMPDIR 指向 doctor 临时目录里的 dsh-login/)、沙箱与网络检查的 skip 与 fail、--json、被 SIGINT 中断假的运行时脚本输出固定的登录状态;真实沙箱可用时运行完整的沙箱与网络检查
signals.rs进程组与信号:SIGINT 与 SIGTERM 的转发、5 秒宽限后的 SIGKILL、第二个信号立即 SIGKILL、--timeout、--max-turns 只数主 agent 的调用、--max-tokens 计入 subagent 的用量并在忽略 SIGTERM 时等满宽限期、运行时收到上限的 SIGTERM 后以 0 退出时为 finished、以 143 退出时为 limit、启动前收到信号、沙箱检查期间收到信号、适配器的 launch 期间收到信号而 launch 失败时结果仍是 rejected、被包裹的 pi 直接收到第一个信号、后台进程在退出后被清理harness = false:测试程序自己处理信号,用环境变量 AGENTRUN_TEST_FAKE_AGENTRUN 把自己重新启动成被测的 agentrun 进程,向它发真实的信号。同步靠假运行时写出的文件与事件,每一步最多等 30 秒
concurrency.rs多个运行同时进行:八个运行(claude-code 与 codex 各四个)各用各的会话临时目录,四个 codex 运行还各用各的私有目录(FakeAdapter 以 codex 运行时创建会话临时目录加 -codex 的目录,经 Launch.private_dirs 交给运行框架登记),结束后全部删除(沙箱关闭与打开各一次);两个 --network custom 的运行只看到自己的 network 事件,沙箱里连不上另一个运行的过滤代理;三个运行中一个被中断、一个正常结束时,只清理自己的进程;十六个运行同时写 AGENTRUN_PI_AUTH(相同的值与不同的值各八个)后登录文件完整、旁注与它一致;十个 --sandbox on --dry-run 同时检查沙箱全部通过,测试输出里打印每个检查的耗时harness = false,与 signals.rs 一样把自己重新启动成被测的 agentrun 进程,每个测试同时启动多个进程。同步靠假运行时写出的文件,不靠固定的等待时间,每一步最多等 30 秒
rich.rsrich 格式:滚动区与面板的内容、标准错误进入滚动区、在伪终端里以 end 行与可见光标结束、NO_COLOR;harness 的 --list 与选项检查harness = false:在伪终端里重新启动自己,读终端的输出
wrap.rsLinux 上 pi 的 bwrap 包裹(dsh 用同一个包裹):只能写工作目录、会话临时目录与登录文件,沙箱里的 TMPDIR,socat 转发,wrapper_failure 识别真实的 bwrap 报错只在 Linux 编译,bwrap 缺失或启动不了时跳过
seatbelt.rsmacOS 上 pi 的 Seatbelt 包裹:写限制、配置文件权限、wrapper_failure只在 macOS 编译

signals.rs、concurrency.rs 与 rich.rs 在 Cargo.toml 里声明 harness = false,理由:cargo 的测试 harness 会安装自己的信号处理,这些主题需要测试程序本身就是收信号、拥有终端、能把自己重新启动成 agentrun 的那个进程。它们用 support/harness.rs 的 run_tests 运行 test_list! 列出的测试函数,输出格式与 cargo 相同。所有测试同时开始,每个测试最多运行 60 秒,超时的测试打印测试名并判为失败。run_tests 支持 --list(输出与 cargo 相同)、--exact、--quiet 与 --ignored(选中的测试为空),接受并忽略 --nocapture、--show-output、--include-ignored 与 --test-threads,其他选项报错并以 101 退出。

写假运行时脚本一律用 support/env.rs 的 write_script,它让 /bin/sh 写文件并设权限,测试进程自己不持有脚本的写描述符,所以 fork 发生在 run::run 里的 framework.rs 与 session.rs 也不会在执行脚本时得到 ETXTBSY。harness = false 的测试程序里,每一次 fork(启动子进程、调用 ps、write_script 启动的 /bin/sh)仍先拿 support/process.rs 的 spawn_lock,理由:open_pty 在 openpty 返回到两个描述符设好 FD_CLOEXEC 之间,另一个线程 fork 出的子进程会继承伪终端的描述符,读 master 要等那个子进程退出才读到结束。spawn_lock 不可重入,support/env.rs 的 write_script 与 Agentrun::spawn、support/mod.rs 的 open_pty 与 sandbox_available、support/process.rs 里调用 ps 的 process_is_gone 自己拿锁,持有锁时不能调用它们。

Agentrun 被丢弃时,如果子进程还没退出,先发 SIGTERM,最多等 TERMINATE_LIMIT(10 s),再发 SIGKILL 并回收。理由:只发 SIGKILL 时 agentrun 来不及结束它启动的假运行时,测试 panic 后会留下进程。

回放样本

tests/fixtures/<运行时>/<场景>.* 每个场景三个文件:

文件内容
<场景>.raw.jsonl运行时的标准输出原文,逐行
<场景>.meta.json{"cwd": "...", "exit_code": N, "stderr": "..."}:记录时的工作目录、运行时的退出码与标准错误。cwd 用于把工具调用里的绝对路径转成相对路径,exit_code 与 stderr 用于失败判定
<场景>.events.jsonl期望的统一事件流,不含 start 与 end 之前的启动过程,含 end

support::replay 读 raw.jsonl,按 meta.json 构造 Invocation 与 Exit,以 dry-run 方式调用适配器的 launch(codex 另给 AGENTRUN_CODEX_AUTH 的内容使登录检查通过),再把每行交给 translate_line,最后调用 conclude 并传入 Launch 记录的包裹方式,得到的事件去掉 time 与 duration_ms 后与 events.jsonl 逐个比较。tests/fixtures/fake/basic.* 没有 meta.json,给 FakeAdapter 用。

样本来源:在 Cloud Managed 容器、docker 与 macOS 上直接运行 claude-code、codex 与 pi 录制;dsh 的样本在 Cloud Managed 容器里用下面第 1 步的 agentrun dsh --raw 录制,沙箱打开。每个场景一个工作目录,记录原始输出、退出码与标准错误;meta.json 里 /tmp/agentrun-probe-<随机>/<场景>-<运行时> 形式的 cwd 就是录制时的工作目录。events.jsonl 是写适配器时由当时的适配器生成、逐条核对后保存的。样本记录时的运行时版本见运行页的「运行时版本」。

更新样本的步骤:

  1. 在装有该运行时的环境里运行一次,--raw 写出的文件就是新的 raw.jsonl:
    agentrun <运行时> --format jsonl --raw <场景>.raw.jsonl --prompt "<提示词>" > out.jsonl 2> stderr.txt
  2. 写 meta.json:cwd 取 out.jsonl 里 start 事件的 cwd(--cwd 给出的目录,没给时是当前目录);exit_code 取 end 事件的 exit_code,它是运行时进程的退出码;stderr 取 stderr.txt 的内容,jsonl 格式下 agentrun 把运行时的标准错误原样写到自己的标准错误。
  3. 运行 cargo test --test <运行时> replay_<场景>,失败输出里的实际事件与期望事件逐条比较,确认差异来自运行时的变化而不是适配器的错误,再把实际事件写进 events.jsonl。

新增或更新样本前检查 raw.jsonl 与 meta.json 里没有令牌、API key 与用户目录的路径,仓库是公开的。

验收脚本

scripts/accept/accept.sh 用真实的运行时与登录把 agentrun 跑一遍,按退出码、事件流与工作目录里的文件判定每个场景,输出一张总表。它覆盖 claude-code、codex 与 pi,不覆盖 dsh。它是「Validate」步骤的工具,不在 scripts/test.sh 里运行,理由:需要这三个运行时的账号,并且要消耗模型调用。

scripts/accept/accept.sh [--agentrun PATH] [--runtimes claude-code,pi,codex] [--sandbox on|relax|off]
                         [--label NAME] [--out DIR] [--only NAME,...] [--parallel N]
                         [--claude-model ID] [--pi-model ID]
选项含义默认
--agentrun PATH被测的 agentrun仓库里的 target/debug/agentrun,不存在时用 PATH 里的 agentrun
--runtimes LIST要跑的运行时,逗号分隔claude-code,pi,codex
--sandbox MODE传给每次运行的 --sandboxon
--label NAME输出目录名的前缀<macos|linux>-sandbox-<mode>
--out DIR输出目录的上级目录./accept-out
--only NAME,...只跑这些场景;sandbox-check 总是运行,因为它决定其他场景是否适用除 concurrent-* 外的全部场景
--parallel Nconcurrent-same 与 concurrent-interrupt 同时启动的运行数3
--claude-model ID、--pi-model IDclaude-code 与 pi 的 --model;codex 不传 --modelsonnet、deepseek/deepseek-flash

场景按下表顺序运行,每个场景对每个运行时各跑一次,不适用的组合记为 skip。

场景检查
sandbox-check用 --sandbox on --dry-run 探明当前环境的沙箱能否运行:退出码 0 为可用,退出码 2 且 detail 以 sandbox is not available: 开头为不可用。沙箱不可用且 --sandbox on 时,依赖沙箱的场景(outside-write、session-tmp、三个 network-*)为 skip;relax 与 off 时再用指定的模式 --dry-run 一次,确认命令里的沙箱种类与预期一致
dry-run、usage-error--dry-run 打印命令并以 0 退出;用法错误只有一个 rejected 的 end,退出码 2
relax-note沙箱不可用时 --sandbox relax 的 text 输出第一行是 [note];codex 不适用
basic-taskagent 写一个文件并读回,end.status 为 finished,文件内容正确,有 prompt、tool、usage 事件
outside-write让 agent 用 shell 命令写工作目录之外的文件,文件不存在
session-tmp用 --debug 运行,让 agent 把 $TMPDIR 写进文件:它在标准错误 [debug] tempdir: 给出的会话临时目录之下(macOS 上 claude-code 的例外见会话临时目录);凭据来自环境变量时有 [debug] credentials: 行
network-none让 agent 用 curl 访问 api.github.com,得到的状态码不是 200;pi 另外要求事件流里 api.github.com:443 的 network 事件为 not_allowed,模型服务主机的事件为放行
network-custom--allow-host api.github.com,text 格式:api.github.com 得到 200,example.com 不是 200,输出里有 [main] net denied example.com:443 (not_allowed) 一行;codex 改为检查 --debug 命令行里有 --enable network_proxy
network-fullapi.github.com 得到 200,169.254.169.254 不是 200 且有 private_address 的 network 事件(claude-code 没有事件时也算通过,它的沙箱把私有网段写进了 NO_PROXY);codex 不经过过滤代理,只检查前一半
webfetch只对 claude-code:--allow-host example.com 下 WebFetch 读 example.com 的 tool 事件 denied 为 false,读 www.iana.org 的为 true
subagents、no-subagents让 agent 用 subagent 写文件:默认有 subagent_start 且 subagent_end 为 finished(codex 没有这两个事件,只检查文件),--no-subagents 下没有 subagent_start(codex 另检查 argv 里有 agents.enabled=false);pi 不适用
config-isolation工作目录里放写有随机口令的 CLAUDE.md 与 AGENTS.md(codex 另在一个 CODEX_HOME 里放 AGENTS.md),让 agent 不读文件直接回答口令:输出里没有口令为通过;agent 还是用工具读了文件时记为 skip,需要重跑
fail-model、fail-arg、fail-credential不存在的模型、-- 之后传给运行时的未知选项、无效凭据都得到 failed 与退出码 1,detail 非空
interrupt、timeout让 agent 运行一条 120 秒的命令:运行中发 SIGINT 得到 interrupted 与退出码 130,发 SIGTERM 得到 143,都在信号后 8 秒内退出;--timeout 10 得到 timeout 与退出码 3,20 秒内退出
cleanup让 agent 启动一个脱离父进程的后台 node 进程:运行结束后该进程不存在,临时目录下的 agentrun-* 目录与运行前相同
doctor运行两次 agentrun doctor <运行时> --json,第二次加 --network custom --allow-host api.github.com:沙箱项与 sandbox-check 探明的情况相符,没有其他 fail;沙箱运行时第二次的网络项为 ok,否则为 skip

下表的并发场景只在 --only 列出时运行,理由:它们同时调用多次模型,费用与耗时都比其他场景高;cleanup 场景比较 agentrun-* 目录集合的检查也不能与它们同时进行。前四个场景对每个运行时各跑一次,concurrent-mixed 在所有运行时之后跑一次;sandbox-check 判为沙箱不可用的运行时不参加。每个并发运行有自己的记录目录,名字是场景、运行时加序号或所放行的主机,例如 concurrent-same-pi-1;场景本身的 check.txt 与之后补跑的 basic-task 记录在不带序号的 concurrent-same-pi/ 里。

场景做法通过条件
concurrent-same同一个运行时同时启动 --parallel 个 basic-task,各用各的工作目录全部 finished,每个工作目录的 accept.txt 都是 hello
concurrent-same-cwd同一个运行时的两个运行同时用同一个工作目录,分别写 a.txt 与 b.txt两个都 finished,两个文件内容正确
concurrent-network沙箱运行时,两个 --network custom 的运行分别放行 api.github.com 与 example.com,都访问这两个主机每个运行只有自己放行的主机得到 200;每个运行的事件流里,自己放行的主机没有被拒绝的 network 事件,另一个主机没有被放行的事件(codex 只查结果,原因同 network-custom)
concurrent-interrupt同时启动 --parallel 个运行 120 秒命令的运行,都带 --timeout 30;第一个运行在 prompt 事件 8 秒后收到 SIGINT第一个以 130 退出,其余以 3 退出
concurrent-mixed--runtimes 里每个运行时各一个 basic-task,同时启动全部 finished

每个并发场景结束后再检查共享的状态,任何一项不满足都判 fail:跑了 claude-code 时 ~/.claude.json 是合法的 JSON,跑了 pi 或 codex 时用户的登录文件(存在时)是合法的 JSON;用同一个运行时再跑一次 basic-task 并完成,确认登录仍然有效;临时目录下的 agentrun-* 目录与场景开始前相同;工作目录在脚本私有目录之下的进程都已结束(还在的进程写进说明并结束它们)。残留进程按工作目录找,不按命令名找,理由:按 claude 这样的名字匹配会找到机器上与验收无关的进程。

输出目录 <out>/<label>-<时间戳>/:env.txt 记录运行时版本、沙箱程序与每个运行时的 --dry-run 命令;summary.md 每个场景与运行时一行,列为场景、运行时、pass|fail|skip 与说明;每个组合一个 <场景>-<运行时>/ 目录,内有 cmd.txt、stdout.jsonl(text 格式的场景为 stdout.txt)、stderr.txt、exit_code、duration_ms、raw.jsonl 与 check.txt。有任何 fail 时脚本以 1 退出。分享输出目录前先读 stderr.txt,运行时的诊断信息写在那里。

scripts/accept/Dockerfile 基于 ubuntu:24.04,装 Node 22、用 npm 装三个运行时、bubblewrap、socat、curl 与 procps,并写入一份选中 deepseek/deepseek-flash 的 pi settings.json。镜像里不含 agentrun 与凭据:agentrun 由宿主用 rust:1-bookworm 容器交叉构建后挂载,凭据只经环境变量进入,不挂载用户的 HOME。

四种环境里的验收

验收由用户执行,AI 提供步骤与脚本。每种环境里先跑 scripts/test.sh,再跑验收脚本;沙箱打开与关闭各跑一遍,因为两种模式走的是不同的代码路径。

环境谁运行命令说明
Cloud Managed 容器AIscripts/accept/accept.sh --runtimes claude-code,pi,再加 --sandbox off 跑一遍容器里有 claude-code 与 pi 的登录,没有 codex。bwrap 能启动,沙箱路径在这里验证
dockerAI 与用户按 accept.sh --help 里的三条 docker 命令:构建镜像、在 rust:1-bookworm 容器里构建 Linux 版 agentrun、挂载后运行脚本;先 --runtimes claude-code,pi --label docker-on,再 --sandbox off --label docker-off 跑全部三个运行时默认权限的 docker 容器里 bwrap 启动不了,这里验证的是 --sandbox off 与 on 下正确拒绝的路径。凭据经 CLAUDE_CODE_OAUTH_TOKEN、AGENTRUN_PI_AUTH、AGENTRUN_CODEX_AUTH 与 pi 服务商的 key 变量传入
本机 Linux用户cargo build,scripts/accept/accept.sh,scripts/accept/accept.sh --sandbox offUbuntu 23.10 以后要先按沙箱与网络放行 bwrap。三个运行时都用本机的登录
本机 macOS用户同本机 LinuxSeatbelt 路径只能在这里验证。本机开着代理软件的 fake-ip 模式时,codex 的 network-custom 场景会失败,见doctor 检查

验收结果写在对应 Task 的 issue 里,附 summary.md;失败的场景附该目录的 stderr.txt 与 check.txt。发布前的验收用 release.yml 构建的文件,见发布。

CI

.github/workflows/ci.yml 在每个 pull request 与每次推送到 main 时运行。同一分支有新的推送时,正在运行的旧任务被取消。PR 在 CI 通过后才合并。

步骤ubuntu-24.04macos-15
用 runner 自带的 rustup 安装 stable 工具链与 rustfmt、clippy运行运行
apt-get install bubblewrap socat shellcheck运行不运行
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0运行不运行
astral-sh/setup-uv运行运行
scripts/test.sh运行运行
cargo publish --dry-run --locked运行不运行

Linux runner 上关闭 AppArmor 对用户命名空间的限制,理由:Ubuntu 24.04 默认禁止非特权进程创建用户命名空间,bwrap 启动失败,需要沙箱的测试都会跳过。cargo publish --dry-run 在 PR 上构建 crates.io 的包,理由:Cargo.toml 的 include 只列构建需要的文件,漏掉文件时 crates.io 上的包会构建失败。

CI 不运行真实的 claude-code、codex、pi 与 dsh,也不调用模型,scripts/test.sh 只用假运行时与回放样本。工具链用 rustup 直接安装,不用第三方的工具链 action,也不加缓存。

发布

.github/workflows/release.yml 在三种情况下运行:推送 v* tag;PR 改动 release.yml、about.toml、about.hbs、Cargo.toml 或 Cargo.lock;在 Actions 页面手动运行。它有三个任务。

build 构建四个可执行文件,<版本> 取 Cargo.toml 的 version。由 tag 触发时,tag 去掉 v 后必须等于这个版本,否则任务失败。

文件名目标runner
agentrun-<版本>-linux-x86_64x86_64-unknown-linux-muslubuntu-24.04
agentrun-<版本>-linux-arm64aarch64-unknown-linux-muslubuntu-24.04-arm
agentrun-<版本>-macos-arm64aarch64-apple-darwinmacos-15
agentrun-<版本>-macos-x86_64x86_64-apple-darwinmacos-15

Linux 的文件用 musl 静态链接,两个架构都在各自架构的 runner 上原生构建。理由:docker 镜像的 glibc 版本各不相同,静态链接的文件在 Alpine 与较旧的发行版上也能运行。依赖里没有需要 C 编译器的包;有这样的包时,musl 目标要用 musl-tools 提供的 musl-gcc,runner 上没有安装它,构建会失败。macOS 的两个架构都在 arm64 的 macos-15 上构建,x86_64 由 Xcode 的工具链交叉编译。macOS 的文件构建后用 codesign --force --sign - 做 ad-hoc 签名,理由:链接器只给 arm64 的文件做 ad-hoc 签名,交叉编译的 x86_64 文件没有签名。文件没有 Developer ID 签名与公证,用浏览器下载的文件要先去掉 quarantine 属性。

每个文件构建后检查,任何一项不通过时任务失败:

每个文件上传为 Actions 的产物,保留 14 天。

licenses 用 cargo-about 按 about.toml 与 about.hbs 生成 THIRD-PARTY-LICENSES.txt,按许可证分组列出每个依赖的名字、版本与许可证全文。依赖的许可证不在 about.toml 的 accepted 列表里时任务失败。理由:发布的可执行文件静态链接了依赖,MIT 与 Apache-2.0 都要求随二进制分发附带许可声明。about.toml 的 targets 只列四个发布目标,只在 Windows 上使用的依赖不进入文件。

release 只在 tag 上运行(推送 tag,或在 Actions 页面对 tag 手动运行),等 build 与 licenses 都通过后,对四个可执行文件写 SHA256SUMS(每行 <哈希> <文件名>),用 gh release create --draft 建标题为 agentrun <版本> 的草稿 Release,上传四个可执行文件、SHA256SUMS 与 THIRD-PARTY-LICENSES.txt。正文为空,发布说明由维护者贴入后发布。

Cargo.toml 的 [package.metadata.binstall] 让 cargo binstall agentrun 从 Release 下载这四个文件:pkg-fmt = "bin" 表示 Release 里的文件直接就是可执行文件;每个目标三元组一条 overrides.<三元组>.pkg-url,指向上表中的文件,Linux 的 gnu 与 musl 目标都指向 musl 静态链接的文件。理由:cargo-binstall 默认只认文件名里带目标三元组的文件,在 glibc 的 Linux 上先试 gnu 目标再试 musl。改文件名或增加平台时要同时改这些 override,否则 cargo binstall 找不到文件,回退到 cargo-quickinstall 的预编译文件或本机编译。这份配置随 crates.io 上的包发布,只对配置加入之后发布的版本生效。

Cargo.toml 的 [profile.release] 设 strip = true、lto = true 与 codegen-units = 1,理由:发布的文件更小,代价是 release 构建更慢。

发布一个版本的步骤:

  1. 在 PR 里修改 Cargo.toml 的 version 并更新 Cargo.lock。PR 上 release.yml 的 build 与 licenses 会运行。
  2. 从这个 PR 的 Actions 运行下载四个文件(gh run download <运行号>),用 accept.sh --agentrun <文件> 在四种环境里验收,并运行 agentrun doctor。下载的文件先 chmod +x;macOS 上用浏览器下载的文件先运行 xattr -d com.apple.quarantine <文件>。
  3. 验收通过后合并 PR,在合并后的 main 上推送 tag:git tag v<版本> && git push origin v<版本>。
  4. 等 release.yml 建好草稿 Release,把发布说明贴进草稿并发布。
  5. 在本机检出这个 tag,运行 cargo publish --locked 发布到 crates.io。crates.io 的令牌只在维护者本机,不放进仓库的 secrets。
  6. 运行 cargo binstall agentrun@<版本> --disable-strategies quick-install,compile --root <临时目录> --no-confirm,确认它从 Release 下载了文件,<临时目录>/bin/agentrun --version 输出新版本。--disable-strategies 禁止回退到编译,地址配置有误时命令失败。