大部分发布工作由 agent 完成
AnyRouter 是一个小团队加上许多编码 agent。agent 领取 issue,在自己的 git worktree 里写修复,开 pull request,互相评审 pull request,合并已批准的改动并部署。项目负责人制定规则,并做出只应由人来做的决定。
这个系列讲它在实际中如何运作:我们自动化了什么,什么出过问题,出问题后我们改了什么。文中每个数字都来自我们的 git 历史、GitHub API 或仓库里的脚本。
2026 年 10 月 19 日至 25 日这一周,共有 346 个 pull request 合并进 main。最多的一天 71 个,最少的一天 29 个。10 月 5 日至 25 日这三周共合并 1,111 个。
要撑住这个量,从推送一个 commit 到 Worker 上线的每一步都必须是带关卡的脚本,而不是有人去点按钮。本文逐步介绍这个循环。
循环总览
flowchart TD
I["Issue"] --> L["独立 worktree 中的 agent 通道"]
L --> PR["Pull request"]
PR --> CI["pnpm local-ci 为 commit 签名"]
CI --> RV["全新的评审 agent 为同一 commit 签名"]
RV -->|"要求修改"| L
RV -->|"批准"| M["merge-pr.sh squash 合并"]
M --> Q["已合并的改动等待下次部署"]
Q --> G{"内存、磁盘与租约关卡"}
G -->|"未满足"| Q
G -->|"通过"| D["书签、冒烟测试、pnpm ops deploy、冒烟测试"]
D -->|"新的冒烟失败"| R["回滚本次上传的 Worker"]下文依次说明每一步。
GitHub Actions 已关闭,CI 在我们自己的机器上运行
为了省钱,我们关闭了 GitHub Actions CI,只有发布相关的 workflow 还在那里运行。关卡改成一条本地命令 pnpm local-ci <pr>(scripts/local-ci.ts),于 2026 年 10 月 4 日首次上线。
它运行旧 workflow 的同一套检查,一次一个,因为这台机器只有 15 GB 内存,无法并行构建。18 项仓库守卫每次都会运行,检查内容包括 migration 编号、schema 漂移、应用之间的 import 边界、过期的生成配置,以及 pull request 描述的格式。其余检查按改动范围执行:对改动文件运行 biome,只运行改动能影响到的 typecheck 程序,对改动代码运行 vitest related。
有两个细节让结果可信。
- 检查运行在合并到当前
main之后的 pull request 上,而不只是分支本身。否则一个旧分支会跳过它分出之后才加入main的检查。 - 通过结果由唯一的机器人账号发布为
local-cicommit 状态。状态里写明关卡数量和日志的 sha256,所以一个绿色结果能对应到产生它的那份输出。其他账号发布的状态不算数。
最近一个 pull request 上的真实状态是 41/41 gates passed (scoped, on main 3e175c42)。这个数字随每个 pull request 涉及的范围变化。
每个 commit 一位评审
CI 证明代码能构建、测试能通过,但不能证明改动是对的。所以每个 pull request 还要由一个全新的 agent 会话评审,它不是写代码的那个会话。评审按我们的验证清单阅读 diff,重跑它有疑问的部分,然后批准,或者附上明确的修改清单退回。
批准绑定的是 commit,而不是 pull request。scripts/herdr/approve-pr.sh 接收 pull request 编号和评审实际读过的那个 commit。如果评审期间有人推送,脚本会拒绝,因为当前 head 已经不是评审看过的代码。成功时,它在该 commit 上发布 review 状态,并加上 review:approved 标签。
10 月 19 日至 25 日合并的 346 个 pull request 中,有 342 个带有 review:approved。
只有一种合并方式
每次合并都通过 scripts/herdr/merge-pr.sh。它拒绝发布用的 pull request,这类由项目负责人手动合并。它拒绝没有签名 local-ci 通过状态的 head。然后它运行 check:pr-merge-safety,该检查还要求当前 head 上有签名的 review 状态和批准标签,并拒绝带有 review:changes 的 pull request。
只有这样它才会 squash 合并,并带上 --match-head-commit,这样合并过程中出现的推送会让合并失败,而不是发布未经评审的代码。
这个脚本源于这一步早期版本的一个 bug。当时合并检查的输出被 pipe 给另一条命令,而 shell 管道返回的是最后一条命令的退出码,所以检查失败了,红色的 pull request 仍然被合并。现在检查单独运行,脚本里也有一条注释说明原因。
部署,大约每小时一次
推送不会触发部署。一个轮询循环(scripts/gh-loop/)每 3 分钟检查一次 GitHub。当上次部署后有新的合并,并且距离上次至少过了 30 分钟,它会启动一个部署通道。通道会等到机器至少有 5.5 GB 可用内存和 20 GB 空闲磁盘,并且同时最多运行 3 个重型通道。
部署本身按固定顺序进行。
- 在一个只用于部署的 worktree 中检出刚 fetch 的
main。 - 记录一个 D1 Time Travel 书签,以便把数据库恢复到那一分钟。
- 运行生产环境冒烟测试,作为基线。
- 运行
pnpm ops deploy。它会持有部署租约,所以两台机器不会同时部署。它只部署代码相对其当前运行 commit 有变化的 Worker。它拒绝部署比线上版本更旧的main。 - 再次运行冒烟测试。基线中没有的失败会回滚本次部署上传的每个 Worker。
第 2 步以前是一次完整的数据库导出。在一次导出让数据库停止服务之后,我们去掉了它。一次 D1 导出会阻塞所有查询 2 到 4 分钟,整个期间 API 都在报错。书签不会造成停机。下一篇会讲这次事故以及由它产生的规则。
10 月 26 日 0 点到 17 点(UTC),我们一台机器上的部署日志记录了 15 次成功部署和 2 次失败。该日志在 10 月 25 日和 26 日记录的 18 次常规成功部署中,运行时间中位数为 520 秒。一次完整运行涉及 17 个 Worker。其他机器也会部署,所以这是一个样本,而不是总数。
循环不会做的事
循环会完全跳过一些改动,留给项目负责人:
- 涉及数据库 migration、计费与额度、认证、API key 和已保存的供应商 key,或法律页面的 pull request。
- 删除、重命名或改写数据的 migration。这类需要项目负责人批准并先做备份。只做新增的 migration 会自动应用。
- 发布用的 pull request、草稿和依赖升级。
- 任何与客户沟通的事情。
每条跳过规则都是 scripts/gh-loop/rules.ts 中一张表里的一行,每一行都有一个测试,删掉它测试就会失败。
本系列的下一篇
下一篇讲事故如何变成守卫:数据库导出事故、在 worktree 之间共享的 stash,以及把较新代码回退掉的合并。之后会讲按 Worker 部署、在一台 15 GB 机器上运行许多 agent,以及我们如何寻找免费模型。
相关文章
Building an LLM gateway on Cloudflare Workers
AnyRouter runs as a set of Cloudflare Workers, not a long-running server. That constrains the design in specific ways — a 128MB memory ceiling that rules out buffering responses, a 3MiB script cap that forced a multi-Worker split, and a hard rule that every upstream call goes through Cloudflare's AI Gateway. Here's what that architecture actually looks like.
How automatic failover works in an LLM gateway
A single provider call has a single point of failure: one rate limit, one outage, one bad deploy on their end, and your request fails. Here's the actual mechanism AnyRouter uses to route around that — circuit breakers, escalating cooldowns, and a fallback chain — and why the extra hop barely registers next to model generation time.