工具自己提交的 commit 同样会触发 push,识别不出来就是死循环:App 收到 push 事件后起了翻译任务,翻译完成后工具自己提交了一个新 commit,这个 commit 又触发了一次 push,push 又拉起新一轮翻译,再提交、再触发……半小时不到,仓库里堆了三十多个翻译 PR。我盯着日志里循环往复的”开始翻译→提交→触发”,才意识到自动翻译最核心的难点根本不是调用模型,而是防循环:工具触发的新 commit 也会回传 push,必须识别”是我们自己提交的”,否则无限循环。后来我把这套识别拆成四个层面,配合签名校验、增量翻译、并发控制和回归测试,才把这条链路做稳。下面按落地顺序讲清楚每一步为什么这么设计。
一、Webhook 总览
Webhook 自动翻译是”仓库 push 后自动起翻译任务”的能力,整体链路我先画了一张图,避免后续加逻辑时方向跑偏。整条链路的处理顺序是有讲究的:签名校验必须放在第一步,任何未通过校验的请求直接拒绝,后面查配置、判 commit 的步骤一个都不执行,这样伪造请求连”仓库是否开了自动翻译”这种信息都探测不到。
GitHub push → Webhook → 验证签名
↓
查仓库是否开启自动翻译
↓
判断 commit 是否来自本工具
↓
不来自本工具 → 起翻译任务
↓
增量翻译 + 提交 PR
图上”判断 commit 是否来自本工具”这一行,就是防循环的第一道闸。跳过它直接起任务,就是我开头说的那个事故。验证签名、查配置、判来源三步串行执行,任何一步不满足就返回,逻辑简单也容易测试。
二、签名校验
Webhook 端点暴露在公网,任何能猜到地址的人都能向它 POST 伪造的 push 事件。我第一版没做签名校验,只在逻辑里判断事件类型,结果发现伪造事件也能触发翻译任务,还白嫖模型 token。补上签名校验后,每个 Webhook 请求都带 X-Hub-Signature-256,这是 GitHub 用 webhook secret 对请求原文做的 HMAC-SHA256 签名:
const sig = req.headers["x-hub-signature-256"];
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response("invalid signature", { status: 401 });
}
这段解决的是”怎么确认请求真的来自 GitHub”的问题。有两个注意点,都是踩过坑的:必须用原始 raw body,不能用解析后的 JSON。我一开始用 JSON.stringify(req.body) 重新序列化,结果字段顺序一变签名就对不上,偶尔成功偶尔 401,排查了整整半天。另一点是必须用 timingSafeEqual 做常量时间比较,普通等值判断在对比失败时耗时更短,存在被时序攻击探测出签名的风险。签名校验通过后,才允许进入事件分发逻辑。
三、识别”自己提交的 commit”
防循环的关键一步是识别自己的 commit,我最初以为最简单的方法是”判断 commit author 等于 app login”,跑了一周发现这招并不可靠。最典型的反例是用户直接在 GitHub 网页上手动合并 PR,合并产生的 commit 仍然带着 app bot 的标识,一旦被当成”我们自己提交的”跳过,用户真实的改动就不会触发翻译;反过来,组织改名或仓库迁移后 author 字段可能变化,把自己人的 commit 误判成外来 commit,又重新掉进循环。实际有反例:
- 用户直接在 GitHub 网页修改并合并 PR,commit 仍带 app bot;
- 组织改名或迁移仓库后 author 变化。
更可靠的做法是”commit message 含本工具的标记”。本项目在 commit message 中加入固定前缀:
docs(translate): {targetLang} from {sourceLang} via docs-translator
Webhook 处理时检查 commit message 是否含 via docs-translator,命中则跳过。这个方案的关键在于”前缀是我方生成 commit 时主动写入的”,可控性远高于 author 字段这种受外部影响的信息。把标记写在 commit message 里还有一个好处:GitHub 网页上直接能看到,用户排查”这个 PR 是谁提的”时一眼就懂。
四、增量翻译触发
第一次做自动翻译时,我图省事,每次 push 都重新扫整个仓库,结果一个中型仓库一次要翻译上千个文件,既费 token 又慢,用户等半天看不到一个 PR。后来改成只翻译变更文件:
- 解析 push payload 的
commits[].modified/added/removed; - 与上次成功提交对比,筛出需要翻译的 Markdown 文件;
- 仅对这些文件起新片段,状态
pending。
增量触发的效果立竿见影:改动一两个文件时,从 push 到 PR 打开通常在一分钟以内。要注意的是步骤 2 不能省,push payload 里的 modified 字段只反映本次 push 内相对上一个个 commit 的差异,如果两次 push 之间有人直接改了文件,还得跟”上次成功提交”对比才准确。对比基准我存的是 commit SHA,而不是时间,时间在跨时区、强推场景下都不可靠。
五、防循环的四个层面
只靠 commit message 前缀判断,理论上能解决循环,但我还是加了三层兜底。原因是我见过前缀被改动的情况:有人 fork 之后改了提交模板,前缀就丢了;也有用户手动改 commit message 后再 push,同样会漏。四层一起用,任何一层单独失效都还有余量:
| 层 | 措施 |
|---|---|
| Commit message | 固定前缀 + 工具签名 |
| PR 描述 | 注明本工具生成,附任务链接 |
| Branch 命名 | 固定 docs/translate-{lang}-{shortSha} |
| 安装身份 | PR 由 GitHub App 创建,commit 标 app[bot] |
这四层各防一类问题:commit message 前缀防”自己提交的 commit”,PR 描述防”PR 被误判成用户手动创建”,branch 命名固定方便统一过滤和清理,安装身份则配合 GitHub App 的权限边界。实际运行中,四层几乎不会同时失效,循环问题自那以后没再复发过。
六、并发与速率
自动翻译比手动翻译更容易撞上并发问题:一个仓库高峰期可能十分钟内被 push 三次,每次都起任务,翻译结果还会互相覆盖。我在这块的处理思路是”去重 + 限流”:
- 同一仓库短时间多次 push:用 commit SHA 去重,只处理最新一次;
- 任务并行度按仓库限制,默认单仓库单任务,避免资源抢占;
- GitHub API 限流:使用 Installation Token 享受 15000/h。
按仓库限制并行度是权衡后的选择。跨仓库并行能提高吞吐,但同一仓库内并发翻译容易产生”先提交的译文被后提交的覆盖”这种脏数据,宁可排队也不要错。commit SHA 去重还有个额外效果:push 事件重复投递时(GitHub 会重试投递失败的请求),幂等性有保证,不会重复起任务。
七、失败与重试
Webhook 链路里的失败点比手动流程多,任何一环出错都可能导致事件被吞。我按场景把失败处理列成表:
| 场景 | 处理 |
|---|---|
| Webhook 解析失败 | 返回 500,GitHub 会重试 |
| 仓库未安装 App | 忽略事件 |
| 自动翻译关闭 | 跳过 |
| 翻译任务失败 | 在 PR 评论中说明,不阻塞 push |
Webhook 解析失败返回 500 是有讲究的:GitHub 对投递失败的请求会持续重试(最长可达数十小时),返回非 2xx 就相当于告诉 GitHub”请再试一次”,等代码修好事件会自动补投。翻译任务失败不打到 Webhook 层面,而是翻译完成阶段在 PR 评论里说明原因,避免让 push 事件重试拖着整条链路。仓库未安装 App 和自动翻译关闭都属于”不该处理的请求”,直接忽略成本最低。
八、可观测性
自动翻译是后台运行的,用户不会主动来看,出了问题只能靠监控发现。我一开始只看日志,等到用户投诉”上周的自动翻译好像没跑”才发现事件早就丢了。补齐指标和告警后,这类问题基本当天就能暴露:
- 关键指标:事件接收量、跳过率、起任务次数、循环识别次数;
- 日志:事件 ID、仓库、commit SHA、动作;
- 告警:单位时间事件量突增(可能是滥用)。
循环识别次数这个指标值得单列:正常情况下它应该很低,一旦升高,说明 commit message 前缀判断可能失效,或者有人批量改 commit message,是循环的前兆。事件量突增告警则能兜住被刷接口的情况,比如有人拿脚本批量触发 push。
九、配置项
上面的逻辑全部跑通后,我发现一个实际问题:不同仓库对自动翻译的诉求不一样,有的只想翻译主分支,有的连依赖锁文件提交也要翻译。把这些行为全部硬编码进代码,每次调整都要发版。于是我把自动翻译的行为收敛成一份 YAML 配置:
auto_translate:
enabled: true
trigger: ["push"]
branches: ["main"]
ignore_authors: ["docs-translator[bot]"]
message_signature: "via docs-translator"
配置中心化之后,运营同学改仓库配置不需要动代码,也不会误碰其他逻辑。ignore_authors 和 message_signature 是防循环的配置化表达,跟第五节的四层措施一一对应。这里有个易错点:配置变更后要等下一次 push 才生效,改配置的人如果以为立即生效,容易误判”功能坏了”。
十、安全注意
Webhook 端点一旦暴露,就是一个可以反复调用的入口。我在加固时按”能做的最少、能做的最稳”逐条过了一遍:
- webhook secret 必填,并定期 rotate;
- 仅监听必要事件,不要开全部;
- 校验 sender 是 GitHub 服务器(IP 白名单或反向 DNS);
- 不在 Webhook 处理中执行高危操作(删除、强制推送)。
第 4 条我特别坚持:Webhook 处理链路里只允许”创建翻译任务、提交 PR”这类可逆动作,删除分支、强制推送这类破坏性操作一律不放进来,就算请求被伪造,损失也是有限的。secret 定期 rotate 能降低泄密后的影响面,事件订阅只开 push,也减少了被无关事件刷量的面。
十一、回归测试
Webhook 链路分支多、依赖外部服务,我吃过一次没测就上线的亏:改了 commit message 模板忘了同步 Webhook 的识别规则,导致自动翻译全部失效,第二天才被用户发现。从那以后,每次改动都要过一轮回归测试:
- 模拟 push 事件,验证触发;
- 注入循环 commit,验证被识别;
- 模拟签名错误,验证拒绝;
- 模拟高并发,验证去重。
这四类用例里,循环 commit 的注入测试最有价值:用一个带 via docs-translator 前缀的 commit 模拟工具自身提交,断言系统不触发新任务;再换一个不带前缀的 commit,断言正常触发。签名错误用例则验证 401 拒绝路径,防止有人把签名校验改坏了还没发现。到这一步,Webhook 自动翻译从接收到防循环的完整实现就闭环了,核心是签名、来源识别、增量、去重四件事,每一件都有对应的测试兜底。
常见问题(FAQ)
Q1:怎么保证 Webhook 一定能收到?
GitHub 会重试失败请求,但生产环境还要监控 Webhook 健康度。
Q2:自己提交的 commit 一定要跳过吗?
不跳过会无限循环,必须跳。
Q3:自动翻译能否限定分支?
可以,配置文件中按 branches 过滤。