Repository navigation
Conversation
|
/review |
4 similar comments
|
/review |
|
/review |
|
/review |
|
/review |
|
/review |
R72 在上游 PR SiliconMod#68 上实测发现:pull_request_review 触发的 run(36357503139) 报 `HttpError: Resource not accessible by integration`,而同 PR 同头提交换成 pull_request_target(36337473071)毫无授权错误、能跑到真实计票。变量只有事件 类型。 根因在 action:GitHub 把 fork 来源 pull_request / pull_request_review 的 GITHUB_TOKEN 降级为只读,计票只需要读权限,但 findOrRecreateVotingComment 会 deleteComment/createComment 必然 403;而它在 main.ts 里排在 evaluateVote 之前, 于是异常在还没计票时就抛出去,required context democracy 变红的原因被写成 「授权错误」而不是票数。 本仓近 12 个 PR 全是跨仓 —— 也就是说「投完票票数自动刷新」这条链路从未工作过。 已在上游 fork Xiaobei09/git-democracy 修复(commit 03bd2fc):写评论失败改为 core.warning 并继续计票,setFailed 只由真实票数判定触发;新增 7 条断言 (其中 6 条已用「还原 src 后必须失败」的反向验证证明非空转)。 同时把 uses 从 @main 改为钉死 commit:@main 是浮动 ref,而这条 action 在 automerge 链路上握着 contents: write,任何能往该 fork 推东西的人都能在不改本 PR 的情况下改变本仓 CI 行为。
|
/review |
5 similar comments
|
/review |
|
/review |
|
/review |
|
/review |
|
/review |
|
逐条对照了 下面三条是建议,都不是阻塞项: 1. api("/repos/%s/git/refs/heads/%s" % (repo, urllib.parse.quote(ref)), token, method="DELETE")
2.
3. 默认值的放宽方向( need_voters = _int_cfg("minVotersRequired", 1)
window_min = _int_cfg("minVotingWindowMinutes", 0)键缺失或拼错时门槛会放宽(人数 1、窗口 0)。当前 一个实测反馈(可以当作闸门语义的自检):拿当前 如果这是预期行为(旧 review 不该静默放行),那建议在输出里补一行**"当前阻止合并的 review 来自谁"**——现在只有 |
|
/review |
|
再补两条触发面/链路上的(都是对着本 PR 的 diff 核过的,不需要外部知识): 1.
而 - types: [opened, synchronize, reopened, closed]
+ types: [opened, reopened, synchronize, ready_for_review]
顺带:原 2. 从「人工点合并」换成 3.(很小)复核结论目前只 |
|
/review |
1 similar comment
|
/review |
|
| ref | .github/scripts/vote_merge.py |
|---|---|
bd44662(本 PR 的 base) |
不存在 |
5885cc3(本 PR head) |
存在(本 PR 新增,+728 行) |
所以 #68 无法靠自己的 automerge 合入
这是个自举问题:引入脚本的 PR 恰好就是第一个需要脚本的 PR。
⇒ #68 必须由维护者手动 squash 合并一次。合并后 base 上就有 vote_merge.py 了,之后所有 PR 才走自动化。不需要为此改代码。
两点澄清
- 这个 FAILURE 不挡合并。 规则集只要求
buildJar+democracy(两者当前都 SUCCESS),automerge不在 required 列表里,mergeStateStatus=UNSTABLE而非BLOCKED。 - 当前票况(用
vote_merge.py的真实函数跑的,不是目测):head5885cc3,update_time=2026-09-28T05:34:35Z,有效票 2/3(作者合成票 + by514 @ 06:41:25Z);ARCloud217 @ 09-27 23:05:19Z 早于 head 提交,已作废。再有一张APPROVED落在5885cc3上即达标。
(提醒:在这个 PR 里发评论不会让票作废——窗口锚点是 head commit 的 committer date,不是 pull.updated_at。)
|
/robin |
🏹 Robin✅ Review done. I flagged 1 thing worth a look. Push fixes whenever you like, then comment |
There was a problem hiding this comment.
🏹 Robin
Heads up: this is a point-in-time review. Push fixes freely, then comment
/robinwhenever you want another pass.
Summary
The changes add a GitHub Action workflow and a Python script for vote-based squash merging. The script is designed to be fail-closed and includes proper error handling for API calls and configuration. No high-severity issues were found. A medium-severity issue was identified regarding missing encoding error handling when loading YAML configuration files.
Robin — the Robin Hood of code review. Free for every PR.
| """只支持 name: number 这种平坦映射,够 .voters.yml / .voting.yml 用。""" | ||
| out = {} | ||
| try: | ||
| with open(path, encoding="utf-8") as f: |
There was a problem hiding this comment.
Robin —
The load_yaml_map function opens files with UTF-8 encoding without handling potential UnicodeDecodeError, which could cause the script to crash if the configuration file is not UTF-8 encoded.
Recommendation: Add error handling for UnicodeDecodeError, either by catching the exception and exiting with a clear error message or by using an error-tolerant decoding strategy.
try:
with open(path, encoding="utf-8") as f:
for line in f:
...
except UnicodeDecodeError:
sys.exit("无法判定:配置文件编码不是 UTF-8")
|
/robin |
1 similar comment
|
/robin |
🏹 Robin✅ Review done. I flagged 16 things worth a look. Push fixes whenever you like, then comment |
There was a problem hiding this comment.
🏹 Robin
Heads up: this is a point-in-time review. Push fixes freely, then comment
/robinwhenever you want another pass.
Summary
本次 PR 新增了独立复核投票并自动 squash 合并的闸门脚本与 workflow,安全设计相当扎实:合并 sha 钉死规避 TOCTOU、严格 fail-closed 的退出码约定、action 引用钉 commit、权限下沉到 job 级、票数与门槛只从 base 检出、逐条枚举与上游 action 的口径分歧并配诊断输出。主要问题集中在「事件驱动 + 投票窗口」组合导致门闸在最常见路径下永不重评估、若干失败路径的诊断与真实成因不符(尤其是跨仓缺凭据这一几乎必然发生的路径),以及若干被自身防御性代码(bad_weights)覆盖却未纳入校验的输入形态。
📄 Findings Not Posted Inline
mod.hjson) — 版本号检查:本 PR 包含 CI 变更(新增 .github/scripts/vote_merge.py、修改 voting.yml),按规范应触发「次(Sub) 位 +1」,但 diff 中未见 mod.hjson 的 version 变更。若确为纯 CI/文档变更且版本号未动,建议 +1。
在 mod.hjson 中把 version 的次位 +1(形如 a0.x.y.z → a0.x.y.(z+1)),并在 PR 描述中说明本次为纯 CI 变更。
💡 2 (.github/scripts/vote_merge.py:297) — 连续三行用 latest.get(login, (None, None, None, ...)) 重复查同一个 key,并各自带一份字面量默认元组(其中第 297 行还塞进了中文魔法串)。默认值一旦不同步就会产出错乱的诊断。
取一次即可:
e = latest.get(login) or (None, None, None, "作者合成票(action 替他投)"),再state, sub, rid = e[3], e[1], e[2];建议把该默认元组提为模块常量。
💡 3 (.github/workflows/voting.yml:106) — automerge job 由 pull_request_target 触发(任意 fork 的 PR 都能起跑),并注入了 contents: write 的 GITHUB_TOKEN 与 AUTOMERGE_TOKEN。当前安全性完全依赖「每个步骤都只引用 base 代码」这一约定(106-109 行的 ref: base.sha 是关键防线),但这类约定没有机制保障,后续任何人加一个来自 PR 侧的 step 或依赖就是直接 RCE。
在 checkout 上加
persist-credentials: false,并建议把 AUTOMERGE_TOKEN 换成只授权本仓的 fine-grained PAT(仅 Contents: read&write);同时可在文件头把「禁止引用 PR 侧 ref / 禁止在 job 内 install 或 uses 未钉 commit 的 action」写成显式约束,便于日后 review。
💡 4 (.github/scripts/vote_merge.py:573) — 阈值全部用 float 百分比比较,边界值(如 pct 恰为 100.0 与 need_pct=100)依赖浮点表示;虽然当前取值下不会出问题,但同一处还承担了「>100 也不会被察觉」的监控职责。
改用整数比较:
ok_pct = total > 0 and for_it * 100 >= need_pct * total,彻底避免除零、NaN 与浮点边界;诊断输出再单独保留%.1f%%的展示值。
Robin — the Robin Hood of code review. Free for every PR.
| if window_min > 0: | ||
| end = update_time + timedelta(minutes=window_min) | ||
| if end > now: | ||
| reasons.append("投票窗口未满(%s 之后才能判定)" % end.isoformat()) |
There was a problem hiding this comment.
Robin —
投票窗口未满时脚本只 report 后 return(退出码 0),而 workflow 的触发器只有 pull_request_target{opened,synchronize,reopened,closed} 与 pull_request_review{submitted},全仓没有任何 schedule(见 585 行注释)。因此当投票人在头提交后的 10 分钟窗口内就投完票(最常见情形),本次运行必然命中「投票窗口未满」并退出,之后再无任何事件重新评估,PR 将永远停留在 open —— 正是本 PR 声称要消灭的「没有事件来重新评估 ⇒ 静默卡住」。
Recommendation: 二选一:① 在 voting.yml 顶层加 schedule: - cron: '*/5 * * * *',让 automerge job 定期对 open PR 重新复核(PR 编号需从 API 列举,不能只依赖事件 payload);② 或在脚本里当窗口未满时于 job 内 sleep/wait 到窗口结束后再判定一次,并设置 sleep 上限。至少应在文档中显式声明该限制,否则维护者会以为「票够了就会自动合」。
schedule:
- cron: '*/5 * * * *'
# automerge job 需支持无 PR payload 的定时触发:env PR 缺省时用
# api('/repos/%s/pulls?state=open&head=...') 列出待复核的 PR
| out = api(path, mt, method="PUT", | ||
| body={"sha": sha, "merge_method": "squash"}) | ||
| return bool(out.get("merged")), (out.get("message") or ""), 200 | ||
| except urllib.error.HTTPError as e: |
There was a problem hiding this comment.
Robin —
merge_pr 只捕获 HTTPError。PUT merge 在网络层失败(URLError/socket 超时)时,api() 会把该异常原样抛出(143 行设计如此:非幂等方法不重试),此处不捕获 → 冒泡到 main 成为未捕获 traceback。这种情形恰恰最危险:请求可能已经合并成功、只是响应没回来(脚本自己的注释 141 行已承认这点),而维护者看到的是红色 traceback + 退出码 1,且因该路径不经过 report(),没有「合并结果: 」指纹行,反而会促使维护者重新触发合并。
Recommendation: 在 merge_pr 里补一个 except (urllib.error.URLError, OSError) as e: 分支:先回读 PR 状态(GET /repos/{repo}/pulls/{pr})确认是否已合并,再决定 report("合并结果待确认…") 与退出码;无法确认时按 fail-closed 退出 1 并写明「可能已合并,请先核对」。
except (urllib.error.URLError, OSError) as e:
# 响应丢失 ≠ 未合并:回读状态再下结论
st = api("/repos/%s/pulls/%s" % (repo, pr), token)
if st.get("merged"):
return True, "响应丢失但已确认合并", None
return False, "合并结果未知(网络失败 %s): %s" % (type(e).__name__, e), None
| num_voters += 1 | ||
| per_user[login] = (vote, w) | ||
| if vote > 0: | ||
| for_it += vote * w |
There was a problem hiding this comment.
Robin —
权重只校验了「是不是整数」(499 行 bad_weights),没有拒绝负权重。for_it/against_it 用 vote * w 累加,负权重会让 total 变成负数,而 pct = for/total*100 在同为负数时得到大于 100 的正百分比。构造:.voters.yml 里有一项 x: -5 且 x 投赞成,三名正权重投票人投反对(作者若注册且权重>0 还会再 +w)—— 此时 for=-4、against=3、total=-1、pct=400% ≥ 100%,num_voters=3 ≥ 3, unanimous 反对的 PR 被放行。
Recommendation: 在 bad_weights 检查处一并拒绝非正权重(fail-closed,与本文件既有防御风格一致),并把 pct 夹到 [0,100] 以免百分比越界掩盖异常:
bad_weights = sorted(k for k, v in voters.items() if not isinstance(v, int) or v < 0);
pct = min(100.0, max(0.0, for_it / total * 100)) if total > 0 else 0.0。
total = for_it + against_it
pct = min(100.0, max(0.0, for_it / total * 100.0)) if total > 0 else 0.0
| p(" 修复办法:在本 PR 上产生一次真实事件(再提交一次 review,或 push 一次)") | ||
| p(" 让它在当前头提交上重跑。") | ||
| else: | ||
| p("- 未识别的拒绝码,处置方式同上:先看 HTTP %s 与原始 message。" % code) |
There was a problem hiding this comment.
Robin —
跨仓缺凭据时 merge_pr 返回 code=None,落到 else 分支输出「未识别的拒绝码,处置方式同上:先看 HTTP None 与原始 message」。而脚本自己的注释指出本仓近 12 个 PR 全部 isCrossRepository=true,也就是「缺凭据」几乎必然是维护者遇到的第一个失败。此时给出的诊断指向 HTTP 状态码,等于把「去配一个 secret」的问题引导到「看状态码」的方向上,与 356-361 行建立的「按状态码分流」设计自相矛盾。
Recommendation: 把 code 为 None 单独分支处理,直接复述 pick_merge_token 已给出的处置办法(到 Settings → Secrets 里配 AUTOMERGE_TOKEN),不要落入通用 HTTP 诊断:
elif code is None: p("- 缺凭据,非投票问题:见上方「合并凭据」行,按其提示配置 %s。" % MERGE_TOKEN_VAR)。
| summary(["## 自动合并复核:合并被拒(HTTP %s)" % code, ""] + _SUM) | ||
| sys.exit(1) | ||
| report("已合并 %s(头提交 `%s`)" % (detail or "", sha)) | ||
| p("- 贡献者分支清理: %s" % delete_head_branch(head, repo, token)) |
There was a problem hiding this comment.
Robin —
delete_head_branch 收到的是 main 传入的 GITHUB_TOKEN(delete_head_branch(head, repo, token),token 即 secrets.GITHUB_TOKEN),但该函数的 docstring(388-395 行)与变量名所描述的场景都是「跨仓 fork 上的分支」,而本文件 104-118 行反复论证的核心前提正是:fork 来源事件下 GITHUB_TOKEN 会被降级为只读。也就是说,维护者按文档设置 VOTE_MERGE_DELETE_FORK_BRANCH=1 后,删除请求必然 403,且异常被 413 行吞掉、只留一句「删除 xxx 失败(HTTP 403)」,开启开关后功能实际不生效。
Recommendation: 要么把合并用的凭据(merge_pr 内部 pick_merge_token 选出的那把)传给 delete_head_branch,要么在开关说明中写明「仅对同仓可写凭据生效」。同时把 413 行的提示语里加上「若为跨仓,403 说明该凭据对 fork 无写权限」。
| """只支持 name: number 这种平坦映射,够 .voters.yml / .voting.yml 用。""" | ||
| out = {} | ||
| try: | ||
| with open(path, encoding="utf-8") as f: |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
以 encoding="utf-8" 打开 .voters.yml,若文件带 BOM 且首行不是 ---,首个投票人的键会变成 "\ufeffxxx",该投票人权重被判为缺失(0)而静默消失。docstring 24-26 行已把它列为已知分歧(action 侧 js-yaml 会剥 BOM),但 bad_weights 这道防线完全接不住它,属于「有已知缺陷但缺低成本防线」的一类。
Recommendation: 改用 encoding="utf-8-sig"(同时兼容有无 BOM 的文件),或在检测到首行以 '\ufeff' 开头时 sys.exit fail-closed,两者都比现在的静默丢票安全。
| % ("、".join("`%s`(%s,权重 %d)" | ||
| % (k, per_vote[k][2], per_vote[k][1]) for k in blockers) | ||
| or "(无登记投票人投反对票 —— 说明反对者未登记或权重为 0)")) | ||
| p(" 要放行只能由他重新 review 或 dismiss 那张票;本闸门无过期机制。") |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
「赞成率」阻塞分支里 blockers = sorted(k for k, v in per_vote.items() if v[0] < 0) 收集了所有反对者,包括权重为 0 的未登记投票人,随后统一输出「要放行只能由他重新 review 或 dismiss 那张票」。但权重 0 者的反对票根本不进入 against_it,永远无法挡门,这句提示对他是错的(同一段 666 行的 or 分支其实已经意识到这种情形)。
Recommendation: 分两组输出:blockers_w = [k for k in blockers if per_vote[k][1] > 0] 用「要放行只能由他重新 review 或 dismiss」,权重 0 的另列为「未登记/权重 0,不参与计票」附注。
| # 表的取名范围 = 计入 numVoters 的人 + 作者(作者可能权重 0,不进 per_user, | ||
| # 但读者要能一眼看到「作者那张是 action 替他投的合成票」)。 | ||
| # 用 if 而不是无条件补一行:无条件补会在作者已登记时打印成两行。 | ||
| rows = sorted(set(per_user) | {author} if author else set(per_user)) |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
sorted(set(per_user) | {author} if author else set(per_user)) 依赖三目表达式优先级低于 |,虽然结果正确但极易被误读为 (set|{author}) if author else ...;且注释「无条件补会在作者已登记时打印成两行」是错的——集合并集本身就已去重,这里的 if author 分支没有任何作用。
Recommendation: 显式拆开写:rows = set(per_user) / if author: rows.add(author) / for login in sorted(rows):,并删掉或改写那条与实现不符的注释。
| % (need_voters - num_voters, need_voters, num_voters, | ||
| ", ".join("`%s`%s" % (k, "(票已作废)" if k in dropped_detail else "") | ||
| for k in missing) or "无")) | ||
| if dropped_detail: |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
dropped_detail(因早于本次更新而作废的票明细)只在 num_voters < need_voters 分支里打印。若作废的票导致的是 total == 0 或赞成率不达标(这是更常见的场景),「我明明投了票为什么不算数」的答案就完全不出现在输出里,与本 PR 为 rt334 建议 4 增加阻塞明细的初衷相悖。
Recommendation: 把 dropped_detail 的打印提到三个阻塞分支之外(或在 reasons 非空时统一打印一次),与 reasons 的具体构成解耦。
| return latest | ||
|
|
||
|
|
||
| def tally(reviews, voters, author, update_time=None, now=None): |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
tally() 声明了 now=None 参数但函数体内从未使用(时间相关的判断都在 main 里做),属于误导性接口:读调用点会以为计票内部用到了「当前时间」。
Recommendation: 删除 now 参数并同步修改 570-571 行的调用(tally(reviews, voters, author, update_time)),或明确注释它是为将来窗口判定预留的。
|
复核了当前 head( 1. 上一轮我要求改的那一处已经修掉了
2. 新发现:本 PR 动了
|
| 合并顺序 | 结果 |
|---|---|
| #68 先合 | #50 随后抬到 a0.13.0.0,没问题 |
| #50 先合 | #68 再合会把 version 写回 a0.12.4.5 —— 版本回退,之后所有基于 test 的构建都会带更低的版本号(更新检查、发布与玩家侧的版本比较都按这个值走) |
建议把 mod.hjson 这一行从本 PR 移出(纯 CI 改动不必动版本,版本留给维护者在合并时统一处理),
或者至少在 PR 描述里注明"合并时需按目标分支的实际版本重放这一行"。
另外
automerge 这个 check 在本 PR 自身是 failure(它自己的闸门跑不了自己),另有一条较早的 democracy failure。
如果这些在 test 上属于必需检查,本 PR 现在仍合不进去——合并前建议确认一遍。
审查归因(Robin Review / workflow_run 二段式)现象:本 PR 的 日志中的报错: 根因(已复现,四条证据交叉):
历史成因: 为何本 PR 的修复当前不生效: 验证方法(供复核者复现): printf '' | gh api -X POST repos/SiliconMod/Silicon/pulls/68/comments --input -
# => {"message":"Invalid request.\n\nNo subschema in \"oneOf\" matched.\nFor 'oneOf/0', nil is not an object...补充对照:同一 payload( |
审查结论:当前
|
| 投票人 | 最新 review | commit_id | 判定 |
|---|---|---|---|
| Xiaobei09 | 作者合成票 | — | 有效 +1 |
| rt334 | APPROVED @ 09-30 14:20:33 | ed9631b |
有效 +1 |
| ARCloud217 | APPROVED | a56c600 |
作废(不匹配 head) |
| by514 | APPROVED | 5885cc3 |
作废(不匹配 head) |
num_voters = 2 < minVotersRequired = 3 ⇒ 即使 ruleset 放行,vote_merge.py 也会以「投票人 2 < 门槛 3」拒绝。
高:「票已全部作废」这一判断不成立
描述里写「头提交 ed9631b 的 committer.date 晚于全部真人 review」。实测:
ed9631b committer.date = 2026-09-30T13:49:58Z <- 锚点
rt334 APPROVED = 2026-09-30T14:20:33Z <- 晚于锚点
rt334 commit_id = ed9631b <- 落在当前 head
而且脚本现在的主锚点是 commit_id(GitHub 钉死、作者无法回填),时间锚点已降为兜底 —— 按时间锚点解释与代码实际行为不符。
rt334 那张票是有效的,不必重投;实际还差的是 ARCloud217 或 by514 中的一张。
中:blocked 的真实原因很可能不是 required checks
ruleset #22253791 实测:
"strict_required_status_checks_policy": true,
"required_status_checks": [buildJar, democracy],
"require_extra_approval_for_unattributed_changes": true- required checks 最新结论:
buildJarsuccess、democracysuccess ✓ - head 未落后 base(
behind_by = 0)✓ - 「
automerge不在 required 列表」✓ 这点判断正确
三者都满足却仍 blocked,剩下最可能的解释是:PR 作者是 Xiaobei09,但全部 10 个提交的 author/committer 都是 Xiaobei08(89635391+xiaobei08@users.noreply.github.com),命中 require_extra_approval_for_unattributed_changes。
这会影响「必须由维护者手动 squash 合并一次」的结论 —— 若阻塞来自 ruleset,手动合并同样会被挡,除非操作者具备 bypass 权限。
中:mod.hjson 与 #50 的合并顺序冲突(这条尚未回应)
| PR | head 版本 |
|---|---|
| #50 | a0.13.0.0 |
| #68 | a0.12.4.5 |
#50 先合、#68 后合 ⇒ 版本被写回 a0.12.4.5,即版本回退。之后基于 test 的构建都会带更低版本号,而更新检查、发布与玩家侧比较都读这个值。建议把这一行从本 PR 移出,或在描述里注明「合并时按目标分支实际版本重放」。
中:窗口 10 -> 0 建议单列为治理变更
.voting.yml 把 minVotingWindowMinutes 从 10 改成 0,等于移除冷静期、3 票即可即时合并。这属于治理参数变更而非脚本改动,建议在描述里单独标注,便于事后追溯是谁在何时改了治理参数。
低:automerge 自举失败的层级区分
诊断(脚本还不在 base 上,checkout 钉 base 导致找不到文件)正确。补一个层级区分:workflow run Voting 的 conclusion 是 failure,但其中 required 的 democracy job 是 success,失败的是非 required 的 automerge job。「run 失败」不等于「required check 失败」,容易被后来者误读。
代码本身:核过源码,写得扎实
以下逐项核对无误,值得记一笔:
- TOCTOU:
body={"sha": sha, "merge_method": "squash"}把复核过的 sha 钉进合并请求; - 双锚点:
commit_id优先(不可伪造),时间降为兜底,commit_id为空串时 fail-closed; api():30s 超时、只对GET/HEAD重试、4xx 不重试(403/409 原样冒泡)、429 重试、204 空体单独处理(否则一次成功的删分支会被报成「清理异常」);- 凭据分流:同仓
GITHUB_TOKEN/ 跨仓AUTOMERGE_TOKEN/ 缺失 fail-closed; - 权限面:权限下沉到 job、
persist-credentials: false、checkout 钉base.sha; - 删 fork 分支:默认关闭,须仓库变量显式开启;
- 负权重:加载处拒绝 +
tally内再守一道; - 前轮建议的
types移除ready_for_review、缺键默认值改 3/10 —— 均已落地。
建议
- 先确认 blocked 是否来自
require_extra_approval_for_unattributed_changes(若是,需额外一名维护者 approve,或调整 ruleset / 用 bypass); - 向 ARCloud217 或 by514 索取针对
ed9631b的重投(rt334 的票已有效); mod.hjson那一行移出本 PR,或注明合并时重放;- 窗口 0 的变更在描述里单列。
run_gh() 此前无论成败都只交回 p.stdout,而 gh 的错误文本(HTTP 状态码、 GitHub 返回的 message)整条写在 stderr 上,随 capture_output 一起被丢弃。 于是失败在日志里只剩「rc!=0 + 输出为空」这一个信号,无法区分限流/权限/ 认证与「payload 不被接受」。 这对本 PR 尤其要紧:行内评论那段刚修好「payload 从未送进 stdin 导致 8/8 被 422」。若 422 还有别的成因(例如 LLM 给出的 path/line 不在 diff 内, 而 :314 的守卫只挡空值、不校验是否在 diff 内),缺了这段日志就只能靠猜, 本 PR 的修复本身也就无法被验证。 改动刻意保持返回面为 (returncode, stdout) 二元组,因此 :248/:325/:339 三个调用点零改动,成功路径的返回值与之前逐字节一致;仅在 rc != 0 且 stderr 非空时补一条 error 日志。 本地回归台 114/114 通过(含为此新增的 4 条断言);另经两个变异体验证断言 确有区分力:回退本改动 → A13/K7/K7b 转红;去掉 rc != 0 守卫 → A14 转红。
|
/robin |
🏹 Robin✅ Review done. I flagged 19 things worth a look. Push fixes whenever you like, then comment |
There was a problem hiding this comment.
🏹 Robin
Heads up: this is a point-in-time review. Push fixes freely, then comment
/robinwhenever you want another pass.
🚨 1 High |
Summary
本 PR 在投票自动合并链路上做得很扎实:squash 合并用 sha 钉死头提交堵住 TOCTOU、第三方 action 钉到具体 commit、顶层 permissions 下沉到 job 级、checkout 显式钉 base.sha 且 persist-credentials: false,并对默认门槛、负权重、BOM、嵌套缩进、review 草稿、时间戳解析失败等边界统一采取 fail-closed,这些都是有经验支撑的正确取舍。review_from_diff.py 的 stdin修复也定位准确(payload 之前根本没送出,行内评论被 422 全量拒收)。仍需修的确定性问题集中在:行内评论未指定 diff侧(本次修复落地后会被 CI 全部拒收,反而比现状更糟)、merge_pr 在 PUT 返回空体时的崩溃、以及两处判定口径/异常安全的小瑕疵。
📄 Findings Not Posted Inline
🔵 1 (.github/scripts/vote_merge.py:358) — rid = r.get("id") or 0 后用「id 最大」破平:若某条 review 缺 id(被限流截断或字段缺失),rid=0 会让先到的那条以 prev[2] >= rid 恒成立而永久占位,之后所有真实 review 都被跳过,该投票人等于静默失声。
缺 id 时按「列表下标」破平(用 enumerate 的序号兜底),或对缺 id 的 review显式记入诊断(进 dropped_detail 并说明原因)。
🔵 2 (.github/scripts/vote_merge.py:96) — API_TIMEOUT_RETRIES 名为「超时次数」,实为重试次数(循环 range(API_TIMEOUT_RETRIES + 1) 共尝试 3 次)。名字容易被误读为「总共只允许 3 次调用」或「超时 3 次」,建议改成 ATTEMPT/RETRY 语义的命名。
改名为 API_MAX_RETRIES,并把 API_RETRY_BACKOFF_SECONDS 的注释改成「线性退避基数」。
🔵 3 (.github/scripts/vote_merge.py:962) — successful-merge 路径只调了一次 p(),没有像其它四条出口那样把「清理结果 + 合并凭据」纳入阻塞明细区块;同时962 行之后没有输出最终指纹以外的状态快照,Step Summary 里看不到 delete_head_branch 的返回值细节(它确实进了 _SUM,但缺少小节标题区分「清理」与「票数复核」)。
在合并成功路径补一个「- 合并凭据 / - 分支清理」小节标题,保持各出口的 Step Summary 结构一致。
🔵 4 (.github/workflows/voting.yml:122) — automerge 的条件只有 needs.democracy.result == 'success',没有分支/路径/actor 限制,也没有条件判断本次事件是否真的改动了票(closed 事件也会跑一遍完整复核与 Step Summary 写入)。仓库也没有任何 schedule 或超时重评估机制——投票不达标时 PR 只能等下一次真实事件,这与文件头承认的「活性死锁」是同一个根因,只是换到了脚本这一侧。
若要让窗口/票数在无事件时也能收敛,考虑加一条每日低频 schedule 只做判定与 Step Summary(不合并),或对 failed 结果的 PR 定期重评。
🔵 5 (.voting.yml:4) — 把 minVotingWindowMinutes改成 0 并注释「投票窗口已删除」,但 vote_merge.py 的文件头第 2 条仍在长篇论证「投票窗口从 PR 最近一次更新起算、action 用 pushed_at 是错的」,且默认兜底仍写 10。窗口逻辑已不再生效,注释与代码默认值却还按旧语义存在,日后有人读到会以为窗口判定仍在起作用。
在 vote_merge.py 文件头与 _int_cfg 处各加一句「当前生产配置 minVotingWindowMinutes=0,窗口检查为休眠路径」,或把默认兜底改成与生产配置一致的 0 并注明理由。
💡 6 (.github/scripts/review_from_diff.py:72) — input=None 时 subprocess 不接管 stdin,gh 会继承 runner 的 stdin;建议显式 DEVNULL,避免无输入场景下子进程意外读到管道数据。
stdin=None if stdin_data is not None else subprocess.DEVNULL
💡 7 (.github/scripts/vote_merge.py:204) — parse_ts 用 strptime 逐个试三种格式,而 GitHub REST 的时间戳格式固定(%Y-%m-%dT%H:%M:%SZ);且 isoformat()(1588 行附近)产出的 +00:00 形式并不在三种格式里。这里换成 fromisoformat(s.replace('Z','+00:00')) 可同时覆盖 GitHub 格式与本脚本自身产出的格式,删掉格式列表。
dt = datetime.fromisoformat(s.replace("Z", "+00:00")); return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
Robin — the Robin Hood of code review. Free for every PR.
| # 【修 2026-09-30】此前只构建了 payload 却从未把它送进 stdin —— | ||
| # `gh api --input -` 收到空 body,行内评论 8/8、5/5 全部被 422 拒收, | ||
| # 每条审查只剩摘要 body。现在把 JSON 以 stdin 传入,行内评论真正落地。 | ||
| rc, out = run_gh([ |
There was a problem hiding this comment.
Robin — 🚨 HIGH · confidence: high
payload 补进 stdin 之前,所有行内评论都因 422 被拒收,因此 side/位置类参数从未真正生效过。GitHub 的 POST /repos/{o}/{r}/pulls/{n}/comments 在同时给出 start_line(或 start_side)与 line、side 时要求三者同侧,否则返回 422 "start_line must be omitted when using multi-line comments" / "start_side must match side"。若 diff 上下文行落在新增侧,把 side 固定成 LEFT(GitHub 默认值)会让每一个行内评论继续被拒收——本次修复只会把「静默全部失败」变成「全部失败并有日志」,功能依旧不可用。
Recommendation: 新增一个输入 hook 拿到当前 diff 的逐行 side 映射(可用 gh api repos/{repo}/pulls/{n}/files --paginate 解析 hunk 头与 +/- 前缀),评论时 side 与 start_side 取同一行的 side;拿不到映射时建议先 gh pr diff 解析,或对该条评论降级为 summary body 提交,不要继续静默 422。
side = sides.get((path, line), "RIGHT")
payload = {"path": path, "line": line, "side": side,
"body": body}
| try: | ||
| out = api(path, mt, method="PUT", | ||
| body={"sha": sha, "merge_method": "squash"}) | ||
| return bool(out.get("merged")), (out.get("message") or ""), 200, mt |
There was a problem hiding this comment.
Robin —
api() 对 204 No Content 返回 None(这是它自己刻意做的设计,见176 行注释),而这里直接 out.get("merged")。一旦 merge端点返回空体(例如经代理/网关改写,或未来行为变化),会抛 AttributeError 成为裸 traceback,绕过520-536 行的三路分流——main 只捕获了 urllib.error.HTTPError 与 URLError/OSError,异常会一路冒到 main,掩盖真正的合并状态。
Recommendation: 加一行显式守卫,把「空响应」与网络失败归到同一个无法判定分支:
if not out:
return False, "无法判定:合并接口返回空响应(请确认 PR merged 状态,勿直接重试)", None, mt
return bool(out.get("merged")), (out.get("message") or ""), 200, mt
| return "已删除 %s:%s" % (repo, ref) | ||
| except urllib.error.HTTPError as e: | ||
| return "删除 %s:%s 失败(HTTP %s,不影响合并)" % (repo, ref, e.code) | ||
| except Exception as e: # 含 URLError / ValueError / KeyError |
There was a problem hiding this comment.
Robin —
except 块里用 %s 格式化 repo 与 ref,但这两个名字是在 try 内部第 558/559 行才赋值的。若 head.get 抛异常(数据形状与预期不符、或中间结果为 None 导致属性错误),NameError 会在处理异常的过程中再抛一次,整个清理阶段崩溃——而按 542-544 行的设计意图,清理阶段的任何异常都必须被吞掉,绝不能让 job 以退出码 1 收场(PR 已合上却显示红叉)。
Recommendation: 把 repo/ref 提到 try 之前初始化:
repo, ref = "", ""
try:
...
except urllib.error.HTTPError as e:
return "删除 %s:%s 失败(HTTP %s,不影响合并)" % (repo, ref, e.code)
| if total == 0: | ||
| reasons.append("无任何有效票(action 在此情形会因 0/0=NaN 而静默放行)") | ||
| elif for_it * 100 < need_pct * total: | ||
| reasons.append("赞成率 %.1f%% < 门槛 %d%%" % (pct, need_pct)) |
There was a problem hiding this comment.
Robin —
门槛判定用整数运算 for_it * 100 < need_pct * total(无浮点误差),诊断输出用 pct < need_pct(浮点)。当 for_it/total 恰好等于 need_pct/100 时两种口径可能不一致,导致 reasons 里列了「赞成率不足」却不打印「赞成率: 反对票来自 …」这条阻塞明细——恰好是 rt334 评审建议4 要求的那条诊断被跳过。
Recommendation: 把判定结果存成变量,诊断分支复用同一个变量,保证两处口径同源:
pct_blocked = total > 0 and for_it * 100 < need_pct * total
...
elif pct_blocked:
blockers = ...
p(" · 赞成率: 反对票来自 %s" % ...)
| reasons.append("无任何有效票(action 在此情形会因 0/0=NaN 而静默放行)") | ||
| elif for_it * 100 < need_pct * total: | ||
| reasons.append("赞成率 %.1f%% < 门槛 %d%%" % (pct, need_pct)) | ||
| if update_time > now: |
There was a problem hiding this comment.
Robin —
update_time 取自头提交的 committer.date,而所有决定性票用 t <= update_time 判过期。GitHub 时间戳只有秒级精度:贡献者 push之后同一秒内完成 review,这张票会因 t == update_time 被作废。方向是 fail-closed(不会误合),但会把完全合法的票丢进 dropped_detail,并触发「无任何有效票」的假阻塞结论。
Recommendation: 锚点①(head_sha 不符)已经覆盖了 PR 更新导致旧票作废的需求,锚点②可放宽为严格晚于一秒:t < update_time + timedelta(seconds=1);或直接把锚点②的判定改成 t < update_time 并在 dropped_detail 里标注锚点来源,避免维护者看到「票已作废」却查不到理由。
elif update_time is not None and (t is None or t < update_time):
stale = True
| # 拿到的令牌权限。 | ||
| # 若日后要进一步收紧:改回 triage,或改为白名单式触发(在 job 级 if | ||
| # 里按 github.event.comment.user.login 过滤),而不是全局放宽。 | ||
| min-command-permission: read |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
min-command-permission 从 triage 放宽到 read,把机器人命令的触发面从「triage 及以上」扩大到「任何 read 权限用户」。注释里已论证令牌权限不受影响、action 只发 review 评论,这个论证成立;剩下的是可被外部利用的成本/骚扰面:read 级用户可无成本地反复触发审查流水线并在 PR 上刷评论。
Recommendation: 采纳注释里已给出的白名单方案:在 job 级加 if 按 github.event.comment.user.login 过滤到名单内用户,或改回 triage。
if: >-
github.event_name != 'issue_comment' ||
contains(fromJSON('["rt334","robin"]'), github.event.comment.user.login)
| # 表的取名范围 = 计入 numVoters 的人 + 作者(作者可能权重 0,不进 per_user, | ||
| # 但读者要能一眼看到「作者那张是 action 替他投的合成票」)。 | ||
| # 用 if 而不是无条件补一行:无条件补会在作者已登记时打印成两行。 | ||
| rows = sorted(set(per_user) | {author} if author else set(per_user)) |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
sorted(set(per_user) | {author} if author else set(per_user)) 依赖「位或优先级高于三元表达式」这一冷知识。语法上正确(等价于 (set(per_user) | {author}) if author else set(per_user)),但极易被误读成 set(per_user) | ({author} if author else set(per_user))。
Recommendation: 加括号显式表达意图:rows = sorted((set(per_user) | {author}) if author else set(per_user))
| for line in f: | ||
| lineno += 1 | ||
| raw_line = line | ||
| line = line.split("#", 1)[0].rstrip() |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
注释剥离用 line.split("#", 1)[0],会把键名或值里合法出现的 # 一起截断(未加引号的键如 user#1: 3、带 # 的路径/分支式名字),静默得到一个错误的键。这属于「自己都没读对配置」却按可信配置继续判定,与本文件反复强调的 fail-closed 取向不一致。
Recommendation: 只把「第一个 #之前不含引号」的行视为注释(简单实现可要求 # 前只允许空白),或在文件头注明「不支持值内含 #」。
| continue | ||
| try: | ||
| out[k] = int(v) | ||
| except ValueError: |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
重复键在 out[k] = v 处静默取后者,而 action 侧 js-yaml 会直接抛 duplicated mapping key——差异清单 a) 已记录为「已知缺陷且未擅自改」。后果是本脚本可能读到一个与 action 完全不同的权重配置却继续做合并判定。
Recommendation: 既然已经在缩进/BOM 上采取 fail-closed,重复键同样应该当场拒绝(一行判定即可),把分歧族从「更宽」移到「同口径」。
| print("- API 重试 %d/%d(%s %s,上次失败:%s)" | ||
| % (attempt + 1, API_TIMEOUT_RETRIES, method, path, | ||
| getattr(last, "code", type(last).__name__))) | ||
| time.sleep(min(API_RETRY_BACKOFF_SECONDS * (attempt + 1), 10)) |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: medium
429 与 5xx 走同一条线性退避(2s、4s),没有读 Retry-After 响应头。在被限流时这很可能在 GitHub 给出的冷却窗口结束前就把剩余重试耗尽,把瞬时限流变成确定性的 403/失败。
Recommendation: 在 HTTPError 分支取 e.headers.get("Retry-After"),存在则按它 sleep(封顶 60s),否则回退到现有退避。
|
/robin |
🏹 Robin🔄 This review run was replaced by a newer Robin run. No action needed — the newer run posts its own result when it finishes. |
|
/robin |
🏹 Robin✅ Review done. I flagged 6 things worth a look. Push fixes whenever you like, then comment |
There was a problem hiding this comment.
🏹 Robin
Heads up: this is a point-in-time review. Push fixes freely, then comment
/robinwhenever you want another pass.
🔵 3 Low | 💡 3 Suggestions
Summary
The diff implements robust fixes to the review/voting pipeline: gh API calls now pass payloads via stdin and log stderr on failures, and a new fail-closed vote_merge gate hardens TOCTOU, YAML parsing, and merge semantics. Security boundaries (pull_request_target base pinning, least-privilege tokens, BOM/nested YAML guards) are well handled. Overall solid improvement with strong diagnostics.
Robin — the Robin Hood of code review. Free for every PR.
| # :314 的守卫只挡空值、不校验是否在 diff 内),没有这段日志就只能靠猜。 | ||
| # 返回面刻意保持 (returncode, stdout) 二元组:3 个调用点(:248/:325/:339)都无需改动, | ||
| # 成功路径的返回值与之前逐字节一致。 | ||
| if rc != 0 and p.stderr.strip(): |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
On gh API failure only stderr is logged (not stdout). For some 422 cases GitHub may include useful details in stdout; currently lost.
Recommendation: Also log stdout when rc != 0 (trimmed) to aid debugging without changing the (rc,out) return contract.
if rc != 0:
err = p.stderr.strip()
outm = p.stdout.strip()
log("error: gh api rc=%d: stderr=%s stdout=%s" % (rc, err, outm))
| # 恰好是提出 CHANGES_REQUESTED 的人,他那条反对票就没了,闸门直接漏放。 | ||
| # 方向是 fail-open,所以按「字节以 EF BB BF 开头」当场拒绝,而不是靠解码报错。 | ||
| try: | ||
| with open(path, "rb") as fb: |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
BOM detection opens the YAML file twice (first rb read 3 bytes, second rb open again later). For small config files this is negligible.
Recommendation: Read once and inspect prefix, or rewind via a single handle to avoid duplicate opens.
| # 为空),json.load 读到空体抛 JSONDecodeError。它是 ValueError 的 | ||
| # 子类,**既不是 HTTPError 也不是 OSError**,本函数的 except 一条都 | ||
| # 接不住,会一路冒到 delete_head_branch 的 `except Exception`,把一次 | ||
| # 成功的删除报成「清理异常(不影响合并)」——分支真删了,维护者却看到 |
There was a problem hiding this comment.
Robin — 🔵 LOW · confidence: high
204 No Content handling is correct but not obvious. A short inline note helps future maintainers.
Recommendation: Add a brief comment that 204 returns empty body so json.loads is skipped (current comment is long; can be concise).
| "-X", "POST", "repos/%s/pulls/%s/comments" % (args.repo, args.pr), | ||
| "--input", "-", | ||
| ]) | ||
| ], stdin_data=json.dumps(payload)) |
There was a problem hiding this comment.
Robin — 💡 SUGGESTION · confidence: high
Passing JSON via stdin_data is correct. Consider logging payload size (not content) on POST failures to distinguish empty-body cases.
Recommendation: Optionally include len(json.dumps(payload)) in debug logs when rc != 0.
| p("- 阻塞明细: 无(人数 / 赞成率 / 窗口三项门槛全部满足)") | ||
| p("") | ||
| p("票数复核通过,执行 squash 合并(头提交已钉死为 `%s`)" % sha) | ||
| merged, detail, code, merge_token = merge_pr( |
There was a problem hiding this comment.
Robin — 💡 SUGGESTION · confidence: high
merge_pr returns merge_token for reuse (good). Consider documenting in a short comment that network errors return code=None (cannot determine) to avoid retrying non-idempotent PUT.
Recommendation: Add a one-line note near the URLError/OSError branch stating why code=None is returned.
| configPath: . | ||
| # 其它事件留空,由 action 回落到 github.event.action。 | ||
| payloadAction: ${{ github.event_name == 'pull_request_review' && 'synchronize' || '' }} | ||
|
|
There was a problem hiding this comment.
Robin — 💡 SUGGESTION · confidence: high
payloadAction remap for pull_request_review -> synchronize is explicit. Document why dismissed also maps to synchronize (already stated above) in the step or inline to prevent future regression.
Recommendation: Add a short inline comment on that line noting dismissed is treated as synchronize to trigger re-evaluation.
两处都是文档/注释修正,不改任何运行时行为。 1) .voting.yml:4 注释的机制描述是错的 原文写「0 → 代码跳过窗口检查」。实测 voting.ts 的 `if (votes.voteStartedAt)` 照样执行,只是窗口长度归零后 判据 `voteStartedAt + 0 > now` 恒不成立。改成写清真实机制, 并补上「为什么恒不成立」(voteStartedAt 取的是 head 仓 pushed_at,恒 <= now,不是「投票开始时间」)。 `minVotingWindowMinutes` 的值仍是 0,未动语义。 2) README.md 缺 a0.12.4.5 条目(不一致由本 PR 引入) base 上 mod.hjson=a0.12.4.4 与 README 的「a0.12.4.4(最新)」 一致;本 PR 三笔 bump 都只改 mod.hjson 一行,从未带 README。 按本仓惯例(见 9611319 / (SiliconMod#61) 的 diff:新增一节带(最新), 同时把上一节的(最新)剥掉)补 a0.12.4.5 一节并剥掉 4.4 的标记。 全部条目内容逐条对账 commit 与源码,未写无出处的断言。
## 背景与归属
本 PR 的 base(12b96ef)在 `robin-review.yml:248-261` 的 step 里有 `set -euo pipefail`
且无 `continue-on-error`。基线行为是:网关不可达时 `call_llm` 返回 None,脚本继续往下走,
发出「未发现值得指出的问题」的 review 并退出 0 —— 审查闸门压根没跑起来,CI 却全绿。
本 PR 只把「压根没拿到应答」这一类改成 fail-closed,「应答了但畸形」保持原回退,
以免破坏既有语义。
## 改动
### 1. call_llm 的异常分类与返回值契约
返回 `("err", detail, kind)`,`kind` 区分三类:
- `transport` —— 连不上 / HTTP 4xx、5xx / 网络层失败,压根没拿到应答
- `bad_json` —— HTTP 200 但响应体非 JSON
- `bad_shape` —— HTTP 200 且 JSON 合法但结构异常
把 `json.loads` 移出网络层 try,才能区分「请求失败」与「拿到应答但内容畸形」。
主循环用 `reached_llm` 记录本轮是否至少拿到过一次应答:两次都压根没拿到 ⇒
记录原因并 `return 1`;拿到过但解析不出 findings ⇒ 仍按「未发现值得指出的问题」处理(rc=0)。
**判据是「拿到应答」而不是「解析成功」** —— HTTP 200 的坏 JSON 同样是网关确实应答了,
把它误判成「闸门没跑起来」会让 fail-closed 指向错误的方向。
### 2. 一条坏 finding 不再杀死整次审查
`line` 不可转整数(如 `"N/A"`)或 `findings` 元素不是对象时,此前 `int(line)` / `fd.get()`
抛未捕获异常,审查带 traceback 死掉,连末尾汇总 review 都发不出去(Robin 审查项 4144570820)。
现在只跳过该条并记 warning,末尾汇总照发、rc=0。跳过必须留 warning,否则退化成静默丢弃。
### 3. robin.yml
`antongulin/robin@v2.7.2 → v2.8.0`。该 tag 与 v2.7.2 仅相差一次发版提交、无功能改动,
原钉版本只是落后一个次版本号。
## 验证
- test-review-from-diff.py:123 条断言全部通过(新增 K8、I26b、I28b;I26/I27/I28 由
「现状刻画·缺陷」反向重写为「已修」期望契约)
- 新增 K8 用 AST 逐个校验 call_llm 的 return 元组长度全为 3。这是我在本次开发中
实际犯过的错:三元组契约重构时漏改了成功分支那一个 return,导致所有「网关正常返回
合法 JSON」的路径都在解包处抛 ValueError 崩溃。py_compile 查不出这类契约错误,
故由该断言守住。已用三种变异验证其有区分力(退回二元组 / 另一分支退回二元组 / 函数改名)
- I26b/I28b 亦经变异体验证(去掉 warning / 去掉 isinstance 守卫),且变异体先过
py_compile 自检以排除语法错造成的假阳性
- diff-vote-tally.py、test-vote-merge.py 未受本次改动影响(vote_merge.py 未改)
- check-tables.py / check-grep-c.py / check-stale-cmds.py 均 rc=0
`Robin` job 的 action 从 `antongulin/robin@v2.8.0` 改钉到 `Xiaobei09/robin@main`(本仓维护者自己的分发镜像)。 ## 为什么必须改钉 两项修复只存在于镜像里,若继续钉上游官方 tag,这两项就是死代码: 1. **重试次数可配** —— `llm-max-attempts`。此前 `LLMClient` 的 `maxAttempts` 在上游源码里被传成字面量 `undefined`,实际重试次数恒为默认 3 (OpenRouter free-router 例外 5),而 `action.yml` 没有任何开关可调。 网关/出口抖动时只能接受或忍受。 2. **状态评论接管 + 继承上次评价** —— 此前每次运行都 `createComment`, 同一 PR 被审多次就堆多条「On it」,最新那条还可能滚出视野; 重试一开始更会把上次已经显示的评价直接抹掉。现在状态评论带不可见标记 `<!-- robin:status -->`,新运行原地更新并用可见的 `> **Last result:** …` 继承上次结论。summary 评论无标记,绝不会被接管或覆盖。 ## 影响面 - 触发条件未变。`robin.yml` 的 job 条件是「同仓 PR 自动触发」**或** 「`/review`、`/robin` 评论且评论者为 OWNER/MEMBER/COLLABORATOR」。 后者发生在 base 仓上下文、token 可写,**对 fork PR 同样生效**, 所以本 PR(跨仓)上 `/robin` 仍能触发。 - 镜像只含 `action.yml` + `dist/index.js` + `.github/` 配置; `dist/index.js` 由上游源码 `npm run build`(`tsc && ncc build`)生成, 未手改。上游合入后改回 `antongulin/robin@<tag>` 即可。 ## 供应链与可回滚性 这一行单独成笔,注释里写明了来源与改回条件,维护者只回滚本行即可 恢复到上游官方 action,不影响本 PR 的其他改动。 ## 验证 - 镜像侧:`config.test.ts` +12 条、`status-comment.test.ts` 22 条新增; 三种变异(反转 verdict 扫描方向、去掉标记判定、丢弃继承 verdict) 全部被捕获;其余 13 个套件逐个复跑全绿,共 185 条断言; `tsc --noEmit` 与 `npm run build` rc=0。 - 本仓:`check-tables.py`、`check-grep-c.py`、`check-stale-cmds.py` 均 rc=0; `robin.yml` 过 YAML 解析。 **归属**:base(`12b96ef`)钉的是 `antongulin/robin@v2.7.2`,本 PR 上一笔 已升到 `v2.8.0`。本笔不是修上游的 bug,而是把修复落到实际生效的产物上, 因此刻意单独成一笔。
上游 `test` 分支在本 PR 打开之后已前进到 `0bc9a51`(`a0.13.0.0`「信号与卫星 · 第一阶段」,已发布 `va0.13.0.0`),而本分支也改过 `README.md` 与 `mod.hjson`, 两侧改在同一处锚点 ⇒ GitHub 报 `mergeable=false / dirty`,PR 无法合入。 本次把 base 并进来并手工解掉这两处冲突。 ## 版本号:a0.12.4.5 → a0.13.0.1 冲突的解法不能拍脑袋,依据是本仓 `.github/code-reviewer.md` 的 「版本号检查(必查项)」: - 本 PR 是纯 CI 批次,无游戏内容改动 ⇒ 按规范只递增**次(Sub)**位; - 基准分支 `test` 的 `version` 已是 `a0.13.0.0`,而 `a0.12.4.5` 比它**更旧** —— 继续沿用旧号会让 PR 的版本低于已发布的基线,属实打实的回退。 故 `mod.hjson` 取 **`a0.13.0.1`**(`a0.13.0.0` 的 Sub +1),而不是保留 `a0.12.4.5`,也不是取 base 的 `a0.13.0.0`。 ## `README.md` 更新日志 该表按**新版在前**排列(既有证据:`a0.12.4.5 → a0.12.4.4 → a0.12.4.3`), 所以: - 本 PR 的 CI 批次条目改挂 `### a0.13.0.1(最新)`,置于表首; - base 的 `### a0.13.0.0「信号与卫星 · 第一阶段」` 去掉「(最新)」标记, 紧随其后,内容一字未动; - 全文 `(最新)` 标记经机器核对**恰好 1 处**,且在 `a0.13.0.1` 上。 ## 顺带补齐的一处账实不符 上一笔 `fd88ebb` 把 `robin.yml` 的审查 action 改钉到 `Xiaobei09/robin@main`, 但**没有同步更新日志**。本次在该条目下补上这一行,否则这次改钉在 changelog 里查不到。 ## 冲突解法之外没有额外改动 `assets/`、`README.md` 的卫星段落、`mod.hjson` 其余字段均取 base 侧, 本 PR 自身的修复内容未被触碰。解冲突前的状态已备份为 tag `r893-premerge-backup`(= `fd88ebb`),需要逐字节回退可直接 `git reset` 到该 tag。 **归属**:`README.md` 与 `mod.hjson` 的冲突由 base 的 `0bc9a51` (`feat/satellite-v2` 合并)造成,不是本 PR 引入;本 PR 只是恰好改了同两处文件。
`check-robin-yml.py` 每次都从 GitHub API 逐个复算 `.github/robin.yml` 注释里记录的每个 PR 的 diff 字符数。本轮它报红: ``` ✗ SiliconMod/Silicon SiliconMod#68:注释写 54798,实测 91685(差 +36887) ``` 注释里那个 `54798` 是更早 head 上的旧值,没随本 PR 的 diff 增长更新。 ## 增长是本 PR 自己造成的,不是上游动了 diff merge-base 全程是 `12b96ef`,所以这与上游无关。逐 head 实测: | head | diff 字符数 | |---|---| | `ed9631b` | 78552 | | `f9b54ed` | 79740 | | `fa06a76` | **82368 ← 首次越过 80000** | | `e74b596` | 91180 | | `fd88ebb` | **91685**(与闸门实测逐字吻合) | 越线主要来自上一笔 `e74b596`:`review_from_diff.py` 的坏 finding 处理 +91 行,及其对应的长 changelog 条目。**属预期** —— 那些提交确实往 PR 里 加了内容,不是哪里回退或多算了。 ## 后果是真的,所以注释里那句「本仓其余 PR 均 100% 保留」已不成立 `max-diff-size: 80000`,截断是 head-only `slice(0,N)`: - 本仓现有**两个** PR 超线:#10(130167)保留 61.5%、SiliconMod#68(91685)保留 **87.3%**; - 靠后文件整块消失,模型会把残缺 diff 当完整 diff 审 → 臆断。 当前影响面:SiliconMod#68 的 `Robin` job 在 fork PR 上是 skipped,所以眼下无人被截断; 但 `/review`、`/robin` 评论那条支路跑在 base 仓上下文、token 可写,**会真的截断 SiliconMod#68**。 ## 只摆准数据,不动值 同文件第 49–50 行明确记录「改这个值是行为变更、不在本次评审点范围内, 是否上调由维护者定」。本笔遵守这条边界:**`max-diff-size` 仍保持 `80000`**, 只把三处失准改成实测真值,并补上逐 head 链路与影响面。是否上调到 200000 (同文件分析认为对最大者 #10 留约 1.5 倍余量)仍由维护者定。 ## 验证 - `check-robin-yml.py` 由 **13/1 转为 14/0**,其余 9 个 PR 的数逐字吻合未动; - 全文 `(最新)` 类断言、`max-diff-size = 80000` 断言仍通过; - 另跑 `check-annot-injection.py`、`check-grep-c.py`、`check-stale-cmds.py`、 `check-tables.py`、`test-review-from-diff.py`(123 条)、`test-voting-yml.py`,全 rc=0。
There was a problem hiding this comment.
自动审查结果(Robin Review / workflow_run 二段式)
本 PR 是纯 CI/工具链批次,游戏代码零改动,mod.hjson 由 a0.13.0.0 递增到 a0.13.0.1 符合版本规范。vote_merge.py 的 fail-closed 设计(空响应体、BOM、嵌套缩进、负权重、403/405/409 分流)整体自洽,未发现崩溃或数据损坏级问题;主要问题是 robin.yml 把第三方 action 钉到个人 fork 的浮动分支 @main,与本 PR 在 voting.yml 中新立的「第三方 action 必钉 commit」规则自相矛盾,另有两处需复核的低风险点。
共 3 条发现,0 条已发为行内评论。
更新日志把两件事都写成了已交付,但它们在**本 PR 合入之前都不生效**。
根因是同一个、且是刻意设计:`voting.yml` 里两个 job 都显式把 checkout
钉死在 base 提交上。
## 取证
`voting.yml` 的两个 job 都写了同一行:
- `democracy` job:`ref: ${{ github.event.pull_request.base.sha }}`
(文件头 ①「显式钉在 base 提交上,不检 PR 代码」)
- `automerge` job:同一行 ref,注释原文
「**复核脚本必须来自 base,不能是 PR 作者提供的版本**」
而 `configPath: .` 是相对 checkout 出来的 base 树解析的,所以:
| 本 PR 的改动 | base(`siliconmod/test`)上有吗 | 合入前的实际效果 |
|---|---|---|
| `.voting.yml` 的 `minVotingWindowMinutes: 0` | ❌ 仍是 `10` | 投票仍需等 10 分钟 |
| `.github/scripts/vote_merge.py`(新增闸门) | ❌ 无此文件 | 闸门读不到脚本 |
逐分支实测该键取值:`12b96ef` = `10`、`siliconmod/test` = `10`、本分支 `HEAD` = `0`。
钉住的那版 action 侧无问题:`config.ts` 确有 `minVotingWindowMinutes` 字段
(默认 `0`,负值归 `0`),`voting.ts` 也确实读它 —— 所以 10 来自**配置源**,
不是 action 忽略该键。
## 两处更正
1. **投票合并闸门**:闸门无法用来合入它自己。首次合入需人工操作,
或先合一个「只带脚本、不带闸门」的 PR。
2. **投票窗口归零**:本 PR 的 CI 运行中 `Voting` 会以
`Vote requires a minimum voting window of 10 minutes` 失败,
归零要等合入 base 之后才生效。
第 2 条正是本 PR 上 `Voting` 持续红的**预期**成因 —— 它不是本 PR 的缺陷,
也不是可以「修掉」的东西。写清楚是为了让维护者不要把这两个红当成 bug 去查。
## 影响
不改任何行为,只更正 changelog 的措辞。base 钉死本身是防篡改的正确设计
(`pull_request_target` + PR 作者提供的脚本/配置 = CI 被劫持),
本 PR 不动它。
## 验证
`check-grep-c.py`、`check-tables.py` rc=0;README UTF-8 解码成功、
`U+FFFD` = 0;两个新增警示块各命中 1 处(`assert` 唯一性)。
There was a problem hiding this comment.
自动审查结果(Robin Review / workflow_run 二段式)
本 PR 是纯 CI/文档批次(Python 闸门脚本、workflow、README、.voting.yml),mod.hjson 已按规范递增次位到 a0.13.0.1,版本号处理正确。vote_merge.py 的计票逻辑 fail-closed 覆盖较完整,但存在若干一致性缺口:robin.yml 把第三方 action 钉在浮动 ref @main(与本 PR自己在 voting.yml 文件头立的「第三方 action 必钉 commit」铁律冲突)、review_from_diff.py 的「拿到畸形应答」路径仍会发假通过 review、以及行内评论 422 的根因(path/line 不在 diff 内)只加了日志未消除。
共 4 条发现,0 条已发为行内评论。
|
/review |
一句话讲清「双轨计票」——为什么
democracy绿 ≠vote_merge.py绿democracy(action,required context)ARCloud217旧票(09-27)也计入 ⇒ 4 👍vote_merge.py(本 PR 引入的严格门,automerge 前置)两者数字不能并排比:
democracy的「4 👍」把 09-27 的历史票也算进去了;vote_merge.py的「3/3」只认 head 更新后的新鲜票。两条计数此刻都达标(democracy=SUCCESS、vote_merge 逻辑复核 3/3 100%),只是口径不同。判定「严格门是否放行」看vote_merge.py的输出,不要看democracy的颜色。① 自举:本 PR 自己不能靠
automerge合入,需维护者手动 squash 一次automergejob 把 checkout 钉在ref: ${{ github.event.pull_request.base.sha }}(复核脚本必须来自 base,不能是 PR 作者提供的版本),而.github/scripts/vote_merge.py正是本 PR 才引入的:basebd44662无此文件、head5885cc3有。run36391962061日志原文:python3: can't open file '.../.github/scripts/vote_merge.py' [Errno 2] No such file or directory(exit 2)。⇒ 自举问题:引入脚本的 PR,恰好就是第一个需要脚本的 PR。 必须由维护者手动 squash 合并一次;合并后 base 上就有该脚本,之后所有 PR 才走自动化。
延伸后果(rt334 评审第 1 条):本 PR 的
automerge代码路径在生产上零执行,第一次真正运行是在下一个 PR 上——automerge 若有 bug,暴露在下游 PR,而不是本 PR。本 PR 的验证依据只有回归台 265/265 + 试验田,请按此预期复核。②
AUTOMERGE_TOKEN未配置 ⇒ 跨仓 PR 的 automerge fail-closed本 PR 起,自动合并需要一把凭据:跨仓 fork PR(本仓近 12 个 PR 全部跨仓)用
AUTOMERGE_TOKEN(Actions Secret);不配置则设计内 fail-closed —— 不合并、只打配置提示、不报错。本 PR 不再是「零 secret」的 PR。配置方法(必须是
secrets,写成vars会恒为空串且无任何报错):③
vote_merge.py不是 required check —— 二选一,维护者定required context 目前只有
buildJar+democracy;严格门跑在automergejob 里,而automerge不是 required context → 手动合并可绕过严格门(本 PR 当前正是靠「维护者自觉」守这道门)。rt334 评审第 3 条给出的选项:vote_merge做成 required status check(需让 Voting 工作流产出同名 check-run 并加入规则集22253791;属后续代码改动,会 invalidate 本 PR 已收的 3/3 票 —— 见「合并后建议」)。rt334 评审建议在顶部单开清单,避免淹没在正文里;三项的完整论证分别在正文 R82 / R83 / R84 节:
.voters.yml负权重-1权重能抹掉一条CHANGES_REQUESTED(三张 +1 + 一张 -1 → 赞成率 150% 放行),形状与 PR #50(3+1=66.7%、卡 23 天)完全重合minVotersRequired: -1让人数维度消失,action 侧可直接放行合并前维护者必须知道的(4 条,均为固有事实)
voting.yml读的是 base 版本(pull_request_target恒读 base):钉死 SHA、job 级permissions、两处ref: base.sha、整个automergejob 在合并前零生产执行。且合并前跑的是Xiaobei09/git-democracy@main浮动 ref —— 那个个人 fork 的 main 已在合并前就位于上游 CI 的信任边界里;合并后才收窄成钉死 commit。请勿把「已实测」用在合并前路径(R73 三节有完整取证)。VOTE_MERGE_DELETE_FORK_BRANCH(vars,不配不删 fork 分支)、AUTOMERGE_TOKEN(secrets,不配跨仓不合并);vars/secrets写错会恒为空串且无报错。main()的实时 API 路径(取 head.sha、取时间、调 merge、写 Summary、删 fork 分支)只能在实跑里验证,而实跑要等合并之后 ⇒ 本 PR 合并时,其最关键的合并动作路径从未在生产配置下跑过。合并后建议(按序)
AUTOMERGE_TOKEN(对应必读 ②)。automerge—— 这是该代码路径的第一次生产执行(回归台只覆盖纯函数)。vote_merge变 required check(必读 ③ 选项 A)与/或修 R82/R83/R84 三项 fail-open —— 任一代码改动都会 invalidate 相应 PR 已收的票,需按新 head 重新收集。背景
当前
Voting工作流实际上无法自动更新票数、也从不合并。PR #67 上三个 open PR 的mergeStateStatus全是BLOCKED,卡在 required contextdemocracy上。查明的 4 个缺陷(均附实测证据)
1. 投完票票数不刷新 —— 「自动更新」的根因
投票载体是 PR review(Approve / Request changes)。action 遍历
pulls.listReviews(reactions.ts)。但
src/main.ts:213的switch只认payloadAction ∈ {opened, reopened, synchronize, closed},没有default分支:voting.yml原本就配了pull_request_review: [submitted],但submitted匹配不到任何 case → 每次投票提交后 workflow 什么都不做,票数要等下一次 push 才刷新。实测(PR #67):event=
pull_request_review、payloadAction='submitted'的那条 run 结论是success,但全日志voting failure message×0、numVoters×0、Current Voting Result×0 —— 一条并非真实评估的绿色 check。修复:
payloadAction: ${{ github.event_name == 'pull_request_review' && 'synchronize' || '' }},强制改写成 action 认的值。2. 同名 context 的「空跑 success」救不回合并
GraphQL 权威读数(PR #67):
buildJardemocracydemocracy两条 run 挂在同一
head_sha,同名 context 只要有一条 FAILURE,statusCheckRollup.state就是FAILURE(后到的 success 不会覆盖)。所以那条空跑 success 只会污染 rollup,救不了任何东西。3.
issue_comment触发器是纯空跑同样落进 switch 空档(
created匹配不到),每个评论都白烧一次 Actions。已移除。4. action 没有任何合并能力
穷举
main.ts的merge/push/refs/heads—— 零命中(唯一pushed_at是用于作废旧票的时间戳)。自动合并必须另加。本 PR 的改动(2 个文件)
.github/workflows/voting.yml:pull_request_review用payloadAction强制真评估;移除issue_comment;新增独立automergejob(permissions补contents: write)。.github/scripts/vote_merge.py(新增):合并前独立复核票数。为什么
automerge必须独立成 job:若并入democracyjob,合并失败会把 required checkdemocracy弄红,PR 反而永远合不掉(自死锁)。automerge不是 required context,失败不阻塞。票数复核规则(
vote_merge.py)严格复刻 action 语义(每人最后一次 review 覆盖前一次;
weight = voters.get(user) ?? 0;PR 作者被 action 自动记 +1),并在此基础上往更严的方向加:0/0 = NaN,NaN < 100为false→ 静默放行head.repo.pushed_at(头仓任意分支的 push,别人推别的分支也会顶掉)合并闸门的三条设计约束
① 退出码必须能区分「判定不合并」和「无法判定」
原先 draft / closed 退出 1 而「票未达标」退出 0 —— 同样是「决定不合并」却颜色不一致。合并不可逆的代码里,「拿不到数据」必须 fail-closed(退出 1、不合并),绝不静默放行。
每条路径都打印一行以
合并结果:开头的指纹(已合并/未合并(<原因>)/合并被拒:<原因>),便于在 run 日志里 grep 确认到底走了哪条 —— 不能只看 job 的 conclusion。② 合并时把
sha钉死到本次复核过的头提交原先用
gh pr merge,它合的是调用那一刻的当前 head,不是我们复核过的那个 —— 复核通过到合并生效之间若被推了新提交,那个未复核的提交会被合入。现改用 RESTPUT /pulls/{n}/merge带sha;头被改动时 GitHub 返回 409,不会把未复核的提交合进去。③ 合并成功之后不许再失败
分支清理阶段吞掉全部异常并降级为提示行。否则「PR 已合并」却报退出码 1,维护者会看到红叉以为合并失败 —— 这种「事后失败」比直接报错更有误导性。
安全性
weight = voters.get(user) ?? 0,陌生人投多少票权重都是 0。自动合并由「≥3 位登记投票者 approve」把守。core.setFailed的结果。allowed_merge_methods仅["squash"]),并显式传merge_method: "squash"。第一版脚本在
Xiaobei09/Silicon试验田跑通了失败路径和通过路径、自动合并实测 40 秒完成。但代码评审轮次随后发现一个 P0:脚本里有一行引用了从未定义的变量now,只要minVotingWindowMinutes > 0(上游是 10)执行到投票窗口判定就抛NameError,退出码 1 —— automerge 会永远合不掉任何 PR。它没被试验田发现,是因为我在试验田为了引导通过路径把该值临时放松成了 0,
if window_min > 0为假,那一行从未执行。试验田配置与生产配置的差异会直接掩盖 bug,所以最终验证全部改用上游真实配置重跑。修复后:73 项断言全过 —— 11 项
main()全路径(合并成功 / 窗口未满 / 票不够 / 零票且作者未登记 / 零票但作者已登记 / 草稿 / 已关闭 / 缺 token / 缺 head.sha / 时间戳格式异常 / 合并被拒 / 清理阶段异常)、9 组计票回归(更新前旧票作废、作者自票保留、submitted_at边界、同一人多次 review 取时间最晚者且与列表顺序无关、未登记用户权重 0、0 票不放行等)、11 项parse_ts。安全面:
pull_request_target下的两处隐式信任边界,已显式化voting.yml用的是pull_request_target—— 它以本仓权限运行,且能被任意 fork 的 PR 触发,是 GitHub 里权限面最大的触发器之一。上一版有两处安全属性是隐式的,逐一显式化:① 顶层
permissions→ 下沉到 job 级合并需要
contents: write,写在顶层就会被每个 job 继承——包括跑第三方 action 的democracyjob。而该 action 来自个人 fork 的浮动 ref(Xiaobei09/git-democracy@main):任何能往那个 fork 推东西的人,都会在每个 PR 上拿到本仓的contents: write。现在只有真正要合并的
automerge持contents: write;democracy只有contents: read+pull-requests: write+issues: write。②
checkout的ref:从隐式默认值 → 显式base.sha默认行为确实是检 base(实测 checkout 日志为
git checkout --force -B test refs/remotes/origin/test,即 base ref 而非 PR head),但这是actions/checkout的隐式解析结果。哪天有人顺手加一行ref: ${{ github.event.pull_request.head.sha }},就变成「外部贡献者用自己 PR 里的代码 +contents: write跑在本仓」= 远程代码执行,而 diff 里只多了一行看起来无害的 ref。现在两个 job 的 checkout 都显式写
ref: ${{ github.event.pull_request.base.sha }},票数与门槛(.voting.yml/.voters.yml)因此只能来自 base——外部贡献者无法通过改 PR 里的配置来放宽门槛或给自己加权。这条边界在 review 时一眼可见。实测验证(静态校验不能替代实跑)
在试验田
Xiaobei09/Silicon开 PR 验证收窄后的权限够用:Contents: read/Issues: write/PullRequests: write—— 无Contents: write✓ 收窄生效voting failure message: '- Vote has 1 voters, did not have the required min 3'✓ 权限够用(若是权限不足,action 会在到达计票前就抛 API 错误)7372a48ee0...= base 提交 ✓ 显式base.sha生效automergejobskipped✓ fail-closed本轮(R71)又补掉的三处「比 action 更宽 / fail-open」漏洞
上一轮我把脚本与 action 的分歧总结成「三处有意分歧,都是往更严的方向」。这次逐行审
边界分支时,发现还有三处不满足这个前提,其中第一处是安全相关的绕过。已修,并把
测试台从 47 条扩到 73 条。
latest_reviews只比submitted_at的字符串,而它只有秒级精度APPROVED再CHANGES_REQUESTED时,本脚本保留先投的赞成而放行;action 的result.set是后写覆盖,保留后投的反对而拦住tally的时间过滤写作t is not None and t <= update_timeload_yaml_map是平坦解析器,.voters.yml若写成嵌套会静默拍平内层键Voters类只收顶层number值),与「分歧一律往更严」的前提直接冲突修法
id(rid),与 action 的遍历顺序一一对应:[K]特意写成「同一对 review 反转 API 返回顺序后结论必须完全相同」,确认破平只看 id、不受返回顺序影响。
APPROVED/CHANGES_REQUESTED)按过期票作废(fail-closed);
vote == 0(PENDING/COMMENTED/DISMISSED)不参与任何门槛判定,仍不受影响(断言
[M]钉住这一点,避免为收紧而误伤)。.voting.yml各门槛的裸int()包起来,从ValueError崩溃变成可读的 fail-closed 说明。实测真实配置(5 人各权重 1、
percentageToApprove: 100)不受影响。另补一条可观测性:头提交时间取自提交者本地时钟,可能被顶到未来(机器时钟不准、
或提交日期本身写了未来)。此时每张票都早于
update_time→ 全部作废,且窗口终点也在未来 → 永远合不上。原先日志里只看得见「无任何有效票」,无从定位真正原因。现在这个
原因被写进
合并结果:指纹(断言[T20])。附带修掉的一个误诊:合并被拒必须按 HTTP 状态码分流
pull_request_target+ 写权限的场景下,「合并被拒」有三种成因完全相反的路径,而 GitHub 对其中两种都只回一句含义模糊的 message:403contents: write(workflow 权限配错,或头在 fork 上跨仓写不通)405democracy是否在当前头提交上重跑过409上一版把三者一律解释成 ruleset 挡住。若真出现 403,维护者会被引导去查投票数和 required check,而正确处置根本不涉及重投票——排查方向完全跑偏且一直查不出结果。现已按状态码四路分流,报告行带上状态码。
附:近期 9 条
democracyrun 的实测体检voting failure message计数 0)voting failure message: '',3 票 + 作者)9 条里 7 条是空跑假绿,只有 3 条真评估,其中 2 条失败。 唯一一次真评估通过是 PR#64(3 位登记投票者 + 作者自票,100%)。这既印证了缺陷 1(
switch无default),也让本 PR 的必要性更直观。判据是 action 自身的输出指纹:
voting failure message计数为 0 即判定为空跑(conclusion不可信)。日志需用gh api repos/<o>/<r>/actions/jobs/<job_id>/logs取——gh run view --log对这些 run 返回空。未改动 / 需要维护者知晓
投票门槛保持原样(
percentageToApprove: 100、minVotersRequired: 3、minVotingWindowMinutes: 10)。本次只修机制,不动治理规则。reactions.ts:Counting PR author as a +1 vote,理由是作者无法 review 自己的 PR)。因此登记投票人的 PR 实际只需另外 2 票即可自动合并。若希望作者自票不计入,需要改 action 上游代码 —— 这不在本 PR 范围内,请维护者定夺。minVotingWindowMinutes的窗口起点用的是错的字段(action 上游缺陷,workflow 内无法覆盖)。main.ts:81取pullRequest.data.head.repo.pushed_at,而repo.pushed_at是头仓任意分支的最后 push 时间,与本 PR 无关。实测 PR ci: Robin 改用 zengate 网关访问 big-pickle(删 bootstrap、零 secret) #67:2026-09-27T14:42:24Zhead.repo.pushed_at2026-09-27T16:22:55Z那次 16:22 的 push 是往同仓的
test分支推的,与 PR ci: Robin 改用 zengate 网关访问 big-pickle(删 bootstrap、零 secret) #67 毫无关系。后果是:任何人对头仓任意分支的一次 push 都会把该 PR 的投票窗口向后推;而全仓.github/里没有任何schedule触发器(已 grep 确认),窗口被推之后没有任何事件会重新评估 → PR 静默卡住,democracy一直红着挡住合并。本 PR 的脚本早已改用头提交时间(正确值),但democracy这个 required check 仍由 action 用pushed_at计算,ruleset 卡的还是错的那个值。要根治需改 action 上游。为此脚本做了两件诊断性的事(不改判定逻辑):把两个窗口起点与差值打进日志;票数全过却被 ruleset 拒合并时,点明真正原因是 required check 而非票数(GitHub 对这种拒绝只回一句
Pull Request is not mergeable,很容易被误读成投票没过),并给出排查步骤。📌 更正本描述早先的一处不准确说法:先前写的是「票投够后那次评估仍会因窗口未满而失败,需要再有一次真实事件触发第二次评估」。实测证据不支持这个说法——两次真评估失败(PR#67
14:43:04、PR#6606:59:11)的voting failure message只有「Vote has 1 voters, did not have the required min 3 voters」一条,不含窗口那条,说明当时窗口是满足的。当前真正的阻塞点是票数不足(1 < 3),不是窗口。 窗口的作用是在票数之外多一道时间要求,而它的起点是错的。ruleset 无需任何改动。实测
GITHUB_TOKEN在strict=true+ required check 全绿时能直接完成 squash 合并,不需要allow_auto_merge(那是给 UI 按钮用的)。本 PR 自身不会运行新的
Voting工作流 ——pull_request_target读的是 base 分支的 workflow 版本。已在Xiaobei09/Silicon试验田完整验证。待验证:
pull_request_review触发路径无法用单一账号验证(GitHub 不允许作者 review 自己的 PR),需第二个账号或维护者提交一次 review 确认票数即时刷新。若payloadAction表达式写错,submitted事件只会退回今天这个「空跑假绿」行为,不会引入新风险。R72 新增:实测发现「投完票自动刷新」这条链路从未工作过(已在 workflow 层修复)
现象:同一个 PR、同一个头提交,只换事件类型,结论相反
36357503139pull_request_reviewerror while running action: HttpError: Resource not accessible by integration(原文写作 GitHub Actions 的 error 注解前缀字面量,为免被日志处理器误解析,此处改用文字描述)36337473071pull_request_target1 < 3失败两个 run 的 head 都是
a56c600,唯一变量是事件类型。头仓Xiaobei09/Silicon经 API 确认
fork=true, parent=SiliconMod/Silicon。根因
pull_request/pull_request_review事件上的GITHUB_TOKEN降级为只读。显式写permissions:不能覆盖这一点。listComments/listReviews/pulls.get)。findOrRecreateVotingComment会deleteComment+createComment—— 必然 403。
main.ts里findOrRecreate排在evaluateVote之前。异常在「还没计票」时就抛出去,于是 required context
democracy变红,而红的原因被写成「授权错误」而不是票数。运维会去查权限,真正的原因被掩盖。
为什么这是 P0
pull_request_review触发器正是为了实现「投完票票数自动刷新」而加的(即本 PR 的核心诉求)。而上游近 12 个 PR 全部是跨仓 PR,也就是说:
这条链路在修复前从未成功运行过一次。
修复
上游 fork
Xiaobei09/git-democracycommit03bd2fc:comments.ts:新增describeError();delete/create/update三处写操作各自
try/catch,失败时core.warning记一笔并继续;findOrRecreateVotingComment返回类型放宽为Comment | null。comments.ts:commentToId接受并透传null;updateVotingComment收到null时变成 no-op(不把id=null打到 API 上)。main.ts:commentId为null时显式告警,说明计票结论仍然有效。最终的
setFailed只由真实票数判定触发,与 token 权限无关。新增
__tests__/comments.test.ts7 条断言(全部 mock Octokit,不触网)。其中 6 条已做反向验证:把
src还原成修复前的a048d59后这 6 条必须失败;唯一在旧代码下也通过的那条是
commentToId returns the id,那正是必须保持不变的行为(回归护栏,不是空转断言)。
本 workflow 的改动
uses: Xiaobei09/git-democracy@main→uses: Xiaobei09/git-democracy@03bd2fc54efb989f86d7b996faf71e240e42bc39钉死的原因同文件头那段安全注释:这是个人 fork 上的 action,而它在
automerge链路上握着
contents: write。用浮动@main意味着任何能往那个 fork 推东西的人,都能在不改本 PR 的情况下改变本仓 CI 的行为。权限面要靠「钉到具体 commit」
才落得住。
新头提交上的 run
36367078443已验证行为正确:Vote has 2 voters, did not have the required min 3,零授权错误,投票评论正常发出(issues/comments/5861832641)。R72 新增:
automerge在跨仓 PR 上仍然做不到(架构限制,非本次可修)上表证明
pull_request_review下 token 是只读的。同样的降级也适用于automergejob——它需要contents: write才能执行合并 PUT。推论(待验证 + 验证方法):即使票数达到 3、由
pull_request_review触发的那次 run 里,
automerge的合并请求也会拿到 403。验证方法:当 PR #68 上出现第 3张赞成 review 时,检查该
pull_request_reviewrun 的automergejob 日志里有无Resource not accessible by integration/HTTP 403(注意用词边界 grep,裸grep 数字会命中时间戳微秒)。
能走通的三条路,都需要维护者决策:
(即
default_workflow_permissions);pull_request_review下secrets.*不可用,github.token才是被降级的那个);pull_request_target触发的 run 里合并」——即票投满后需要再有一次push。这是目前唯一无需任何配置变更就能生效的路径。
R73 新增(一):投票门槛在真实数据上的可达性 —— 量化结果
用 API 真值复刻
readReactionsCounts+weightedVoteTotaling+evaluateVote的完整算法,对上游最近 25 个 PR 全量模拟(判据来自pulls/{n}/reviews与.voters.yml,不靠印象):numVoters < 3)CHANGES_REQUESTED)配置真值(读自
origin/test):percentageToApprove: 100、minVotersRequired: 3、minVotingWindowMinutes: 10;.voters.yml只有 5 人。三点结论:
percentageToApprove: 100意味着零容忍反对票:forIt/(forIt+againstIt)只要有一个登记投票人最新状态是CHANGES_REQUESTED,就再也到不了 100%。这是设计意图(共识制),不是缺陷,但维护者应知道它把「有人认真提了反对意见」等同于「PR 永久阻塞」。+1(reactions.ts在读完所有 review 后无条件执行result.set(author, APPROVED)),因此minVotersRequired: 3实际只需要 2 个真人 review。这与本 PR 概述里「作者自己的票还要算」一致。机器人github-actions[bot]不在.voters.yml,权重 0,不参与计票(登记表起了应有作用)。R73 新增(二):修了第二处计票缺陷 —— 未提交的 review 草稿会抹掉真票
readReactionsCounts用「遍历到就result.set」实现后写覆盖。而 GitHub 的PENDINGreview(有人开了 review 草稿但没提交)submitted_at为null、state被映射成0、id又比已提交的 review 大、排在列表末尾。于是:反向同样成立:提出反对的人只要开一个草稿就等于撤销了反对。
同时修掉一个次要问题:「取 PR 作者」的
pulls.get原本写在分页循环体内(N 页调 N 次,且作者的+1会被后续页里作者的 review 盖掉)。现移到循环之后,作者+1在所有 review 之后生效一次。修复位置:上游 fork
Xiaobei09/git-democracycommita0c7f646b1ffd614a73175e63f9c66fdb806a153(src/reactions.ts+29/−15),本 workflow 的uses:已钉到该 commit。测试:新增
__tests__/readReactions.test.ts10 条断言全过(纯 mock,不触网)。反向验证:把src/reactions.ts还原到修复前后 4 条失败 / 6 条通过 —— 失败的正是 3 条 PENDING 用例 + 1 条pulls.get次数用例;通过的 6 条是必须不变的回归护栏(作者+1、后提交意见覆盖、DISMISSED记 0、null user跳过、非 PENDING 状态不因submitted_at缺失被丢弃)。构建prettier/tsc/eslint/ncc全 0,dist/index.js2000671 → 2001477 字节(+806),按dist/index.js.map的sources分组计数核对依赖闭包零变化。两个脚本的口径已核对为一致:
vote_merge.py靠submitted_at为null→sub = ""排序最低而天然免疫草稿(草稿赢不了「取最后一次」,且即便赢了,vote = 0也不参与任何门槛判定)。为把这条口径钉死、防止将来有人把or ""改成or "9999"导致草稿顶掉真票,回归台新增[N1]/[N2]两条断言(73 → 75)。反向验证:注入该变异后 73 过 / 2 失败,失败的正是[N1]/[N2],且实际值显示该投票人的票整张消失(numVoters2 → 1)——「静默吞票」在合并闸门自己的测试台里被复现。严重性如实标注:全量扫描上游 45 个 PR / 71 条 review,
state=PENDING与submitted_at为null的条数均为 0 ⇒ 本缺陷在上游尚未发作,属潜在缺陷。修它的理由是「一旦有人留草稿就会静默锁死投票且不自行恢复」,而不是「它正在造成故障」。R73 新增(三):⚠️ 更正 R72 的一处结论 —— 本 PR 自身的配置从未被任何 run 执行过
R72 我写了「钉到
03bd2fc后行为正确(生产验证 run36367078443)」。那句话的结论对、但归因错,必须更正:36368626834(head64a2d543)日志里,action 被解析为Download action repository 'Xiaobei09/git-democracy@main' (SHA:a0c7f646b1ff...)——@main,不是钉死的 SHA。pull_request_target事件永远读 base 分支的 workflow 文件。PR ci: 投票闸门支持自动更新与自动合并(修 pull_request_review 空跑假绿) #68 的 base 是SiliconMod:test,其voting.yml第 21 行写的是uses: Xiaobei09/git-democracy@main。voting.yml里@main出现 0 次(权威判据grep -nE '^\s+uses:'= 1 行、已钉 commit)。既然日志显示@main,就证明执行的不是我这份文件。pull_requestrun 0 条;3 条pull_request_reviewrun 全部早于钉死提交(最新2026-09-27T23:05Zvs 钉死于2026-09-28T01:42Z),因此也无从判断pull_request_review读哪个版本。因此,以下内容目前零生产执行证据:钉死的 SHA、job 级
permissions、两处ref: base.sha、以及整个automergejob(该 run 的日志包里只有democracy一个 job)。它们的正确性依据目前只有:静态审查 +vote_merge.py的 73 条回归断言 + action 侧的 10 条 mock 单测。同时纠正 R72 措辞里的另一处:验证到的确实是
03bd2fc/a0c7f64的代码行为正确(Vote has 2 voters, did not have the required min 3、零授权错误、投票评论正常发出),但那是经 base 分支的浮动@main命中的,不是经本 PR 的钉死行。这既是缺口也是论据:今天上游的闸门实际解析的是我这个 fork 的浮动
main—— 也就是说Xiaobei09/git-democracy已经在合入本 PR 之前就位于上游 CI 的信任边界里了。合入后改成钉死 commit 正是把这个边界收窄。待验证 + 验证方法(单账号无法自行触发,需第二账号或维护者):
pull_request_review事件读的是 PR 版本还是 base 版本 —— 由第二账号在 PR ci: 投票闸门支持自动更新与自动合并(修 pull_request_review 空跑假绿) #68 上提交一次 review,查该 run 日志的Download action repository行显示的是钉死 SHA 还是@main。test分支上产生一个真实 run;届时automergejob 才会第一次执行,并同时接受「跨仓 PR 写权限」这一关(见下节)。为什么本 PR 不自己加
pull_request触发器来补这个验证:合入后pull_request与pull_request_target会在同一事件上各产生一条同名democracycheck。同一 context 名存在多条 CheckRun 时,任一条 failure 就让statusCheckRollup整体判红、后到的 success 不覆盖 —— 这会让「可执行性验证」的收益被一个更隐蔽的判定风险抵掉。是否加该触发器是维护者的取舍(收益:PR 自身配置可提前验证;代价:双 check 同名),不在「本次修复」范围内,故不在本 PR 里动。R74 新增:「票已够但卡在投票窗口、且之后无事件重跑」——机制成立,但上游 0 次发生(含一处自我更正)
我一度报了一个不存在的 P0,先更正
R73 的 25 PR 全量模拟里,「仅窗口不足」占 5 条。R74 我按此追查「活性故障」,写了分类器扫全部
Votingrun,得到:36302341535/36302757813/36302929643/36303111394/363034321550f848d22→f83e8558→ea25e85c→2f48a9f7→d62f6b69),但head_branch全是同一个feat/satellite-v2⇒ 全部属于 PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50state=open、merged=False、updated_at=2026-09-27T07:32:57Z按这个证据,我准备上报「PR #50 票已够、纯粹卡窗口,已静默卡 29 小时」的 P0。回读失败消息原文后撤回:
evaluateVote能产生三条失败消息(百分比 / 人数 / 窗口),我的分类器只匹配了后两条。而那 5 条 run 的失败消息第一条就是- Vote did not have the required 100% voter approval.—— 它们失败在百分比,窗口只是顺带命中。更正后的结论:「票已够、纯粹只卡窗口」的活性故障,上游历史上 0 次发生。 PR #50 的红是设计内的,不是机制故障。
PR #50 的真实阻塞源:100% 零容忍 + 一条没撤销的 CHANGES_REQUESTED
按
.voters.yml(真实路径在仓库根,不在.github/下)加权后的逐人状态:ARCloud217weight=1APPROVED→ +1Xiaobei09weight=1CHANGES_REQUESTED→ −1github-actions[bot]weight=0COMMENTED→ 0(不在登记表,不计数)rt334(作者)weight=1 合成 +1(作者无法 review 自己,票由 action 无条件补上,即使他从未留过 review)forIt=2 / againstIt=1 / numVoters=3(人数已达标) / percentage=66.7%。
唯一阻塞源是
Xiaobei09于2026-09-04T08:14:46Z提出的CHANGES_REQUESTED,至 2026-09-28 已 23 天未撤销。percentageToApprove: 100意味着forIt/(forIt+againstIt)必须等于 100,任何一名登记投票人的最新状态是CHANGES_REQUESTED就再也到不了。R73 的 25 PR 模拟中「仅百分比不足」2 条 + 「双不合格」1 条 = **3/25(12%)**属此类。维护者需要知道的固有代价:反对票没有过期机制,「有人认真提了反对」等价于「PR 无限期阻塞」,除非他重新 review 或配置被改。
本 PR(#68)的阻塞源恰好相反
ARCloud217weight=1APPROVED→ +1Xiaobei09(作者)weight=1 合成 +1 → +1forIt=2 / againstIt=0 / numVoters=2 / percentage=100%。百分比已达标,只差人数(
minVotersRequired: 3)。也就是说 #50 卡在「人够了但有人反对」、#68 卡在「没人反对但人不够」——两个 PR 的阻塞原因完全相反,都不是代码能解的:#50 要等Xiaobei09重新 review,#68 要等第三名登记投票人 approve。R73 已模拟确认:只要出现第 3 张赞成 review,#68 立刻全绿并触发automerge。活性机制本身仍然成立(潜在缺陷,非正在发生的故障)
机理是确凿的:
evaluateVote只在votes.voteStartedAt非空时查窗口;窗口未满 → 判失败;而voting.yml的on:只有pull_request_target:[opened,reopened,synchronize,ready_for_review]+pull_request_review:[submitted],没有schedule,main.ts的 switch 也无default。于是「最后一个事件发生在窗口内」时,窗口虽会自然过期,但没有任何 run 会重新判定,democracy红、automerge(needs.democracy.result == 'success')不执行。纠正一个听起来对的修法:
schedule修不了这个问题最顺手的候选是加
schedule周期性重评估。它不成立:required status checks 挂在具体 commit SHA 上,schedule触发的 run 关联的是默认分支的头提交,产出的 check 落在那个 SHA 上,GitHub 不会拿它去满足某个 PR 头上democracy的要求;同一 context 名在不同 PR 头上互不相通。真正能解开「required check 卡住」的只有两条路:(a) 让同一 PR 头上再产生一次该 check 的 run(需要真实事件,如作者推一个空提交、或维护者点「Re-run failed jobs」——窗口已过期时重跑同一条 run 即可转绿);(b) 让 workflow 在本次 run 内有界等待到窗口过期后自行重判(最长约 11 分钟的 sleep,对每个失败 run 都付这个成本,且需要区分「只卡窗口」与「票确实不够」)。
两条都超出「PR 内容仅含本次修复」的范围,交维护者取舍,本 PR 不动。
另一处仍存但本 PR 不改的缺陷:投票窗口的锚点取错了实体
main.ts用updateTime = pullRequest.data?.head?.repo?.pushed_at作为窗口起点,这是仓库级的 —— 头仓任意分支、任意人 push 都会重置这个 PR 的 10 分钟窗口。R67 已实测到一次 101 分钟的跨分支污染。正确锚点应是头提交的committer.date(与vote_merge.py的口径一致)。不擅自动的理由:改锚点会改变闸门严格度(头提交时间 ≤ push 时间 ⇒ 新窗口结束得更早 ⇒ 更宽松)。虽然方向上是修 bug 而非放松,但「放松投票闸门」应当由维护者显式决定。待验证 + 验证方法:由维护者确认是否接受把窗口锚点改为头提交
committer.date;确认后需在__tests__补一条「其他分支 push 不得重置窗口」的回归断言。同一处
updateTime为空时的Promise.reject(new Error("There's no head commit uploaded"))位于evaluateVote之前(与 R72 修掉的deleteComment顺序问题同族),表现为整条 run 失败而非一个可读的人数结论。它是 fail-closed 的,不改。R75 新增:合并闸门与 action 计票的唯一分歧类 —— 已修(第 10 个 commit
de35ebf)发现过程:差分对拍机器
R73 曾把「PENDING 草稿」这一族钉进口径一致断言(
[N1]/[N2])。R75 把口径验证扩成差分对拍:checks/diff-vote-tally.py按 actiona0c7f646的readReactionsCounts语义(线性遍历、result.set(login, vote)后写覆盖、跳过PENDING+submitted_at==null草稿、循环后无条件给作者 +1)重新实现一份,与vote_merge.py的latest_reviews+tally在同一份 review 流上对拍(update_time=None排除「更新后旧票不算」那张刻意存在的层,只对拍「谁是最新一张票」)。随机 200 组(含反演生成)对照:修前 26 例分歧、修后 0 例。26 例 100% 属于同一个分歧类。
分歧类:id 序与 submitted_at 序相反(先以草稿打开、后提交)
GitHub API 返回 review 按 id 递增;action 的「最后写者胜」= id 最大者胜。旧实现按
(submitted_at, id)取最晚者。当同一人的两张已提交 review 出现「id 早但 submitted_at 晚」(先开草稿、中间隔了别人投票、最后才提交草稿)时,两者选出不同的最终票:DISMISSED(id=10, sub=17:00:10)→APPROVED(id=11, sub=17:00:05)。action=+1,旧实现=0(赞成票整张消失)。APPROVED(id=10, sub=17:00:10)→CHANGES_REQUESTED(id=11, sub=17:00:05)。action=反对,旧实现=放行 —— 方向偏「放行」,安全相关。修复内容(
de35ebf,+17/−9,只动vote_merge.py)latest_reviews只保留每人 id 最大的已提交 review,显式跳过state==PENDING && submitted_at==null的草稿(与 action 的not counting unsubmitted PENDING review draft逐字对齐);PENDING常量;docstring 重写选择键约定;[O1]/[O2]反演族断言;反向变异验证双红(把选择键改回按时间 →[O1]实得(0,0,0)、[O2]实得(1,1,0),两断言同时失败,证明断言真的钉住了该口径);影响评估
上游 45 PR / 71 条已提交 review 中,反演对 0 ⇒ 该差异属潜在缺陷(机制真实、样本为零)。方向分析:旧实现更宽的方向(放行 action 会拦的)在自动合并链路中被
needs.democracy.result == 'success'结构性中和;更严的方向(action 已绿但闸门拒绝)会导致合并不再触发。两条都会在「评审者用过草稿式 review 流程」的 PR 上显现,因此修掉是「本次修复」的合理内容。待验证 + 验证方法:真实反演对在哪个 PR 上首次出现 —— 持续跑
checks/diff-vote-tally.py的对拍方向;若上游某 PR 出现反演对,latest_reviews与 action 已同口径,不会再分叉(这正是修复的意义)。R76(第 11 个 commit
6bfe04a):新头de35ebf的生产实况 + 405 诊断补全现状:PR #68 离全绿只差一张登记投票人的 APPROVED
#22253791(name=test,branch target=~DEFAULT_BRANCH,enforcement=active,bypass_actors=[])。PR 分支的 required checks 只有buildJar+democracy两个,无 required review 数、无 review 人名单;strict_required_status_checks_policy: true。de35ebf的生产 run36372216746(pull_request_target,2026-09-28T03:03:57Z):action 经Xiaobei09/git-democracy@main(当前=a0c7f646,即本 PR 的修复)执行;readReactionsCounts=Map(2) { 'ARCloud217' => 1, 'Xiaobei09' => 1 };失败消息只有「Vote has 2 voters, did not have the required min 3 voters」(百分比=100%、窗口已过)——不再有其它因子;无任何 403。同头buildJarconclusion=success。base.sha == test 当前 HEAD == bd44662b,strict_required_status_checks_policy维度已满足。[B2]早已钉住,无需修改)。dropped=[Xiaobei09, ARCloud217]),numVoters=1(仅作者 +1)、forIt=1/againstIt=0、百分比 100%;判定不通过,但阻塞点从 action 侧的「66.7% 反对票」变成「计数 1<3」。规则按设计工作:更新后旧票不算、作者票恒算;两侧口径(action 无时限 vs 本脚本有时限)差异是刻意的,勿当 bug。405 诊断补全(本次 commit)
merge_pr的 docstring 与 405 分支原先把 405 单一归因于「ruleset / required check 挡住」。实测仓库合并方法设置(allow_squash_merge等字段)用 triage token 读不到(返回 null,需 admin 可见性),意味着「本脚本请求 squash merge 而仓库未开启」这条真实成因被漏掉了。本次把 405 分支与 docstring 改为「合入前的约束不满足:ruleset / required check 挡住,或合并方法不被允许」,并提示按原始 message 与仓库 Merge 设置/ruleset 允许集确认。回归台 77/77 全过(ruleset在、权限问题不在、403 与 405 诊断文本互异等反向断言仍咬住)。待验证 + 验证方法:①squash 合并方法在上游是否可用 —— 需管理员查看 SiliconMod/Silicon 的 Merge 设置,或在闸门全绿、跨仓写权限解决后观察合并结果;②合并方法被禁时的确切状态码(403 或 405)与 message 措辞 —— 查 GitHub REST「Merge a pull request」响应定义,核对后再按原始 message 分类。
R79(第 12 个 commit
ff49e3f):窗口锚点污染的生产实证(全仓 4/4 open PR 扫描)R75 起的「action 用
head.repo.pushed_at当窗口起点」一直挂着待验证(R67 曾实测 PR #67 偏差 101 分钟)。本轮把它做成可复现的生产样本,并顺带更正代码里那条已经过期的实测注释。全量样本(
head.committer.datevshead.repo.pushed_at)pushed_at7e936a3d62f6b66bfe04af2f752e三条结论
pushed_at是仓级锚点,同 fork 的所有 PR 共用:ci: Robin 改用 zengate 网关访问 big-pickle(删 bootstrap、零 secret) #67 与 ci: 投票闸门支持自动更新与自动合并(修 pull_request_review 空跑假绿) #68 的pushed_at完全相同(2026-09-28T03:15:15Z),而各自头提交相差 12.5 小时。污染源就是本轮之前我在自己 fork 上推的那一次(R76 的6bfe04a,03:15:12Z)——不是同一次 push 换了时刻,而是锚点被一次次无关 push 累加着往后推:偏差从 R67 记的 101 分钟涨到 753 分钟。voting.ts:34的if (votes.voteStartedAt)看似会在锚点缺失时整段跳过窗口检查;但main.ts:85-94证实真实评估路径!updateTime直接reject(new Error("There's no head commit uploaded"))(fail-closed)。voteStartedAt: null(main.ts:45-48)只喂createCommentBody的初始占位评论,不参与任何判定。本次 commit 的改动
仅注释与一句诊断文案(+10/−4,无行为变更):更正「实测 101 分钟」为当前 753 分钟并说明增长机制,补「仓级、同 fork 所有 PR 共用」与「活跃 fork ⇒ 活性死锁」两条事实,诊断输出加「仓级锚点,同 fork 的其它 PR 共用它」。回归台 77/77 全过。之所以没有为这段文案加断言:
main()依赖实时 API,文案是诊断输出而非判定输入,回归台只覆盖纯函数(latest_reviews/tally/load_yaml_map/分流文本),把main()的打印抽出来测会为了覆盖率改结构,不划算。R80:补上「赞成率不足」这条闸门维度的主流程覆盖(回归台 77 → 89)
审计结论:
main()的 16 个分支里,「赞成率不足」是唯一没有主流程场景的闸门逐条把
main()的守卫与回归台的run()场景对表(T1–T20):head.shaupdate_time解析不出这不是无关紧要的盲区:
percentageToApprove: 100配上一条未撤销的CHANGES_REQUESTED正是 PR #50 已卡 23 天的真实阻塞源(forIt=2 / againstIt=1 / numVoters=3 / 66.7%),而回归台里所有「票够」场景都是 3 人全赞成 = 100%。新增 3 个场景(按 PR #50 的真实拓扑)
rt334、ARCloud217 赞成、Xiaobei09 一条未撤销的CHANGES_REQUESTED⇒numVoters=3 / 赞成=2 / 反对=1 / 66.7%⇒ 判不合并、不调用 merge 端点、指纹点名「赞成率 66.7% < 门槛 100%」、计票表把对象者标为反对。反向变异(证明新断言咬得住)
pct < need_pct改成pct < -1(百分比守卫失效)⇒ T21 双红,日志显示那条 66.7% 的流被真的合并了 = 闸门被绕过。num_voters < need_voters改成num_voters < -1⇒ T3 / T4b 双红(票数不足也被合并)。git diff为空,最终 89/89 全过。一次「断言红 ≠ 代码错」的现场
T21 首版红了两条,读输出才发现原因:当时把作者设成了投反对票的
Xiaobei09,而作者覆盖规则会把他强制成 +1(他无法 review 自己的 PR),于是反对票被自己的作者身份抵消、赞成率变 100%、PR 反而被放行。这不是 bug,正是用户规则「作者自己的票还要算」在起作用。已按 PR #50 的真实拓扑(作者=rt334、对象者=Xiaobei09)改正场景,并在断言旁写明这个坑,避免后来者把它当代码缺陷去「修」。R81:action 侧同款盲区 ——
src/voting.ts(evaluateVote)零测试,已补 12 条断言R80 在 Python 侧发现的「判定函数的关键分支没有测试」,在 action 侧同样存在且更严重。
审计:fork 里
src/*.ts与__tests__/*.test.ts的对应关系expect数comments.tscomments.test.tsconfig.tsconfig.test.tsmain.tsmain.test.tsINPUT_TOKEN)reactions.tsreactions.test.tsvoters.tsvoters.test.tsvoting.tsvoting.ts里的evaluateVote是 action 的唯一判定点:三条失败消息(百分比 / 人数 / 窗口)与「通过 / 不通过」全部由它产出。也就是说,上游的闸门判定逻辑此前没有任何一条自动化断言。新增 12 条断言(
__tests__/voting.test.ts,fork commitd5582a7)0 赞成 0 反对 / 3 人⇒0/0 = NaN,NaN < 100为 false ⇒ 百分比检查不触发,evaluateVote返回空串 = 放行一个零票 PR。这实测坐实了vote_merge.py里「无任何有效票」那道守卫是真实必要的分歧,而不是注释里的一句自我声明。voteStartedAt = null⇒ 整段窗口检查被跳过。(main.ts:85-94的!updateTime→reject使真实路径 fail-closed,这条钉的是纯函数自身的边界。)<而非<=)、4:1加权 = 80% < 100%、minVotingWindowMinutes=0窗口恒不触发、percentageToApprove=0任何比例都通过。反向变异(3 次,全部可编译、全部咬住)
percentageForIt < pct→< -1votingEarliestEnd > new Date()→> new Date(0)numVoters < min→<= min三次变异后
git diff均为空,prettier --check/tsc --noEmit/ jest 12/12 全过。一次「变异跑不起来」的处置:
if (votes.voteStartedAt)→if (true)这个最直观的变异让 jest 报Tests: 0 total—— 原因是 TypeScript 的类型收窄:if (votes.voteStartedAt)把类型收窄成Date,条件一改,add(votes.voteStartedAt, …)立刻编译不过(TS2345: 'null' is not assignable)。「测试套件跑不起来」不是「断言通过」也不是「变异无效」,必须换成一个能编译的等价变异(改守卫内部的比较式)才算完成反向验证——否则很容易把一次无效验证当成「已验证」。为什么
d5582a7不需要重钉 PR #68 的 pingit diff --name-only a0c7f646..d5582a7 -- src/ dist/为空 ⇒ 纯测试提交,产物与源码零改动,PR #68 里uses: Xiaobei09/git-democracy@a0c7f646…的钉死值继续有效。(若动了src/,就必须重新构建dist/并重钉 pin,否则钉住的 SHA 里没有修复码。)R82:提案(需维护者决策) —— 负数门槛值会静默关掉闸门,且两侧表现不同
事实
git-democracy的config.ts校验不对称:percentageToApprove越界 → throw(fail-closed)。minVotersRequired < 0→ 静默归 0(line 54-56)。minVotingWindowMinutes < 0→ 静默归 0(line 57-59)。minVotersRequired: -1是「无限制」的通用写法(阈值配置的惯例),维护者手滑就会写进去,而闸门会无声消失。两侧后果不同(本轮实测,不是推理)
minVotersRequired: -1+ 零票0 < 0假;百分比因0/0=NaN不触发;voteStartedAt=null时窗口被跳过 ⇒ 返回空串 = 放行【口径边界】minVotersRequired: -1 -> 归 0 -> 任意人数都通过已钉住)vote_merge.pynum_voters < -1恒假,但独立的「无任何有效票」守卫先拦下vote_merge.py侧为什么惰性:total > 0蕴含「至少一张正票」⇒「该人权重 > 0」⇒numVoters >= 1恒成立,所以need=-1与need=1在任何可达状态上等价;唯一能让人数闸门起区别的状态(零有效票)已被「无任何有效票」守卫独立拦下。回归台[T24b]用「零票 + 门槛 −1 仍被无票守卫拦住」钉住这一点——去掉total == 0守卫时[T4]/[T24b]会同时变红(有区分力的验证,不是空转断言)。提案(三选一,需维护者拍板)
percentageToApprove一致)——最干净,但会让现在写着-1的配置直接失败。-1等于关闭该维度,请勿用于投票人/窗口」。本 PR 不擅自改:这是改闸门语义(收紧方向),属维护者决策;且现网
.voting.yml是minVotersRequired: 3/minVotingWindowMinutes: 10,.voters.yml全部权重为1,无实际影响。顺带修掉一处「虚假的绿灯」
fork 原有两条测试名叫
readVotingConfig has error on invalid minVotersRequired / minVotingWindowMinutes,但断言写的是resolves——名字说「会报错」、断言说「不报错且被静默归 0」。读者按名字会以为 fail-closed 被守住了,实际它固化的正是 fail-open。已把名字改成与行为一致(fork commit572c57a),并把 fail-open 标注进测试名。只改测试命名与断言,src/零改动。本轮两个「测试自身出问题」的现场(都记下来了)
else True,恒真。反向变异abs(need_voters)也没能让它变红(场景里只有 1 张票,-1与1都放行)——对照也通过就说明测试量不出东西。已改为正面断言「1 张票真的合并了」并补[T24b]取得区分力。git commit --amend -F <文件>修正,并按 AGENTS.md 约定用grep -q $'\ufffd'复查。R84:提案(需维护者决策) —— 穷举配置解析面:查出三处「比 action 更宽」的分歧,并更正一条被证伪的旧说法
为什么做这一轮
R82/R83 各报了 1 个配置层 fail-open。按「分类器要先穷举代码能产生的全部变体」那条纪律,只报 2 条就意味着可能漏掉恰好是安全漏洞的那一条。本轮把
.voters.yml/.voting.yml的全部输入变体逐条与 action 侧(js-yaml +typeof val === 'number')对照。穷举出的对照表(全部实测,非推理)
vote_merge.py(手写平坦解析器)duplicated mapping keybad_weights拦下---首行bad_weights拦下---首行bad_weights拦下~键"null""~"""三处「更宽」的机制与后果
① 重复键 ⇒ 后者静默胜出(可与 R83 的负权重叠加)
实测
[T31]:3 人全赞成、唯一那条CHANGES_REQUESTED因后写的-1被减成负数 ⇒反对=-1⇒ 放行合并。日志里没有任何「配置有重复键」的提示。② 嵌套写法的防线只挡住一半 —— 本轮更正一条被证伪的旧说法
此前(本文件与源码注释都)写着「
load_yaml_map会拍平内层键、认出一批 action 认不出的投票人 —— 这是唯一一处比 action 更宽的漏洞,与其拿着读错的配置放行,不如停下来说明」。R84 实测证明:后一句的防线只挡住一半。bad_weightsvoters:(留空,最常见){'voters': '', 'rt334': 3}votersvoters: 1+ 缩进rt334: 3{'voters': 1, 'rt334': 3}漏过后后果不是「多认一个没真人会用的键名」(那确实无害),而是拍平把内层真人
rt334的权重顶到了顶层:numVoters=3 赞成=5—— 权重 3 计入赞成数,但人数只按人头 +1,于是「一个人的票重 3 倍、人数却只算 1」。action 侧rt334的值是嵌套 object,被过滤器滤掉 ⇒ 权重 0 ⇒ 不计入,人数只有 2 而卡住。同一个真人,两侧权重不同,本脚本更宽。正确判据是「这个文件根本不是平坦映射」,而不是「拍平后的值是不是整数」——后者只能偶然拦下一种写法。已更正源码注释与报错文案(运维读到的是文案,不是注释),并加断言
[T32]钉住文案本身。③ BOM ⇒ 首个投票人消失(单变量对照,最干净的实证)
.voters.yml带 UTF-8 BOM 时,open(..., encoding="utf-8")不剥 BOM ⇒ 首行键名变成\ufeffXiaobei08⇒ 他在名单里消失 ⇒ 他的票也一起消失。numVoters=2 赞成=2 反对=0 100.0%numVoters=3 赞成=2 反对=1 66.7%两组门槛行逐字相同、计票行不同(
[T35b/c]自检了这一点,避免「唯一变量」只是嘴上说法)。方向是 fail-open:消失的是反对者时,那条反对就没了。action 的 js-yaml 会剥 BOM,所以是本脚本单方面放行。现实触发路径:AGENTS.md 已记录「PS 5.1
Set-Content -Encoding UTF8会写入 BOM」,本项目被 BOM 咬过(mod.hjson)。现网.voters.yml首行是---,带 BOM 时会变成垃圾键\ufeff---并被bad_weights拦下 ⇒ 当前是 fail-closed,属潜在缺陷;触发条件是「首行不是---+ 文件带 BOM」。提案(三选一,需维护者拍板)
load_yaml_map遇缩进行直接拒绝(判据从「值是不是整数」换成「结构是不是平坦」)—— 一次性堵住 ① 之外的全部结构性问题,最干净。PyYAML或yaml.safe_load)与 action 对齐 —— 重复键/BOM/嵌套的行为自然与 js-yaml 一致,但引入新依赖,且会同时把「更宽」变成「更严」。.voters.yml只用---开头、无 BOM、无重复键、纯平坦整数」。本 PR 不擅自改:三处都改闸门语义(收紧),属维护者决策。回归台用候选修复 1 本身做反向变异(拒绝缩进行)—— 它让 5 条断言转红,其中一条暴露了实现的缺陷:
ValueError未被捕获会变成 CRASH 而非 fail-closed,正确写法需并入sys.exit的诊断路径。交付物(commit
6df6059,判定逻辑零改动,只有注释与报错文案)run()只重定向了 stdout,fail-closed 分支走sys.exit("…"),消息落在ex.code上且从未返回 ⇒ 「无法判定时到底点名了哪个键」这类断言无处可查。已加模块级LAST_ERR旁路(不改 3 元组返回值,避免动 40 多处调用点)。本轮四个「测试自身出问题」的现场
run()的voters=参数是整体覆盖:T33 首版只写了一行rt334,另两个人权重 0 ⇒numVoters=1而非 3。断言红 ≠ 代码错,是我把名单写漏了。voters)不会有真人投票,危害不成立;真正的危害是内层真人权重被顶到顶层(②)。LAST_ERR用得太晚:对照断言放在了run()之前,跑到NameError: out35c。变量必须先定义后使用,哪怕只差几行。raise ValueError实现「拒绝缩进」,实测变成CRASH:ValueError—— 与本项目已有教训「fail-closed 守卫可能是死代码,依赖被调函数返回哨兵值而非抛异常」同族。R83:提案(需维护者决策) ——
.voters.yml里的负权重能抹掉一条反对票,两侧同构审计:权重解析是安全属性,而
voters.ts只有 1 条断言voters.tsif (obj)短路 /typeof val === 'number'过滤器 /!instanceof Object抛错)实测出来的 4 件事(不是推理)
-1权重不被任何一侧拦下。action 的Voters构造器只按typeof val === 'number'过滤(-1是 number,原样进入);vote_merge.py的bad_weights只查「非整数」(isinstance(v, int),-1是整数,放行)。⇒ 两侧同构,没有口径分歧,但共有一个 fail-open。后果:
against_it += -vote * w让反对票变成负数。 三名 1 权重赞成者 + 一名-1权重反对者:1-1一个负权重就能把一条
CHANGES_REQUESTED从票数里减掉。 这与 PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 的形状完全一致(3 人赞成 + 1 人反对 = 66.7%、已卡 23 天)——若Xiaobei09的权重是-1,PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 会直接放行。「超过 100% 的赞成率」本身就是可观测信号:
vote_merge.py的计票行会打出赞成率=150.0%、反对=-1。运维看到 >100% 就知道.voters.yml混进了负权重(正常路径下这个值不可能出现)。-1权重者还会被剔出「投票人数」和计票表(w > 0才计)——他既不贡献人数、也不留记录,只在against_it上留下那个负数。另两个静默丢弃 / 守卫漏洞
abc: "1")会被静默丢弃:typeof val === 'number'把它过滤掉,不报错不告警;而未登记的人用?? 0,所以后果与「没登记」完全相同——那一票彻底消失。真实触发路径:从 JSON 片段复制权重时手滑加了引号(JSON 里数字常带引号)。这是静默 fail-closed:症状是「票就是不够」,看不出是配置写错了。instanceof Object拦不住数组(Array也是Object的实例):.voters.yml顶层写成 YAML 列表时,readVoters不报错,而是造出名为"0"/"1"的假投票人。现无实际危害(GitHub 登录名不会是"0"),但它证明这道守卫的判据选错了——真正要排除的是「顶层不是平坦映射」。config.ts的同一道守卫有同样的漏洞。提案(三选一,需维护者拍板)
percentageToApprove的处理一致,最干净;现网全部权重为1,无实际影响。Voters构造器拒绝负权重,vote_merge.py侧改为w = w if isinstance(w, int) and w > 0 else 0(负数按未登记处理)——折中,一行改动。本 PR 不擅自改:改它=改闸门语义(收紧方向),属维护者决策。回归台
[T27b]用的就是候选修复 2 本身做反向变异——它让 2 条断言转红,即修复一旦落地会被立刻发现,而不是悄悄改变行为。交付物(fork commit
16dda99,src/零改动)__tests__/voters.test.ts从 1 条 → 9 条断言;新增 8 个测试数据文件,其中voters-four-neg.yml与voters-four-ones.yml只差abc: -1/abc: 1一行,构成单变量对照。---,js-yaml 解析成null而非undefined;已拆成voters-null-doc.yml(null)与voters-zero-byte.yml(0 字节 →undefined)两条。本轮三条工具/方法教训
tsconfig.json里有"exclude": ["node_modules", "**/*.test.ts"],所以npx tsc --noEmit从不检查测试文件(tsc --noEmit --listFiles | grep -c __tests__= 0)。此前几轮报告里的「tsc=0」只覆盖了src/;测试文件唯一的类型闸门是 ts-jest。voters.ts的守卫if (!(data instanceof Object)) { throw ... }与 TS 类型收窄是结构耦合的——把throw换成 no-op 后,收窄失效、data退回unknown⇒TS2345⇒Tests: 0 total。四种改法(&& false、别名收窄、恒假长度比较、no-op info)全部编译不过。最终用「同时补data as object」才做出可编译的行为变异。yamlSafeLoad(fileContents)→yamlSafeLoad(fileContents || '{}'),结果测试依然全绿——因为该数据文件内容是---\n(真值字符串),||根本不触发。判据:反向变异没红时,先确认它真的改变了那个输入。R85:落实 rt334 的四条评审意见(唯一一处收紧方向的闸门变更已标注)
rt334于 2026-09-28 在本 PR 留下评审,明说设计取向认同、四条都不是阻塞项。本节逐条对应,并把「为什么这么取舍」写清楚,便于复核与回退。① 删贡献者 fork 分支 → 改为默认关闭、须显式开启
问题:
delete_head_branch里的repo取自head.repo.full_name。跨仓 PR 时它就是作者自己的 fork;只要作者勾了 "Allow edits by maintainers",base 仓的 token 就能删掉对面 fork 上的分支。作者可能还要留着 rebase,或同一分支还挂着别的 PR —— 合并一个 PR 不等于授权清理别人的工作分支。改法:须维护者设置仓库变量才删:
变量不存在时
${{ vars.X }}求值为空串 ⇒ 默认不删,并在日志里打一行说明为什么没删、怎么开启。为什么用仓库变量而不是
workflow_dispatch的 input:push/pull_request事件无法传 inputs(PR 事件下inputs为空),那种写法在真实触发路径上恒为空串 —— 开关等于不存在。同仓 PR 的行为不变(仍交给仓库自带的自动删分支设置)。
②
api()无超时 →timeout=30+ 有限重试问题:
urlopen默认无限等待。GitHub 侧静默挂起时 job 会一直挂到 workflow 上限(默认 6 小时),而闸门卡住 = PR 永远 BLOCKED —— 正是本 PR 想消灭的那类「没有事件来重新评估」。改法与取舍:
URLError/ socket 超时)5xx/4294xx(含 403 / 409)merge_pr的分流诊断GET/HEAD)PUT(merge)上限 2 次(共 3 次请求),退避
min(2 × (n+1), 10)秒。每次重试打一行可 grep 的指纹:断言
[T46]~[T53]逐条钉住上表的每一格。③ 缺键不再静默放宽门槛 —— 本 PR 唯一一处收紧方向的变更
问题:原实现
int(cfg.get(key, default)),default取 1 / 0。也就是说把minVotersRequired拼成minVoterRequired,门槛会从 3 人悄悄掉到 1 人;把minVotingWindowMinutes拼错,投票窗口直接变成没有窗口。而症状(「票不够」/「窗口没过」)与「配置写错了」长得一模一样,排查方向会完全跑偏。action 侧的对照:
config.ts的Config构造器三个默认值全是 0 —— 缺键时 action 是完全放行的。percentageToApprove越界会抛错,但另两个键悄悄归 0。改法:缺键时取文档门槛
3/10并打⚠️警告点名键名:为什么选兜底而不是
sys.exit:sys.exit是 rt334 建议里的另一个选项(缺键即拒绝),但它一旦遇到配置真有错,就让所有 PR 永远合不掉 —— 把「配置写错」变成「闸门不可用」。取文档门槛则仍然按文档挡住,且警告让配置错误当场可见。sys.exit方案只需替换main()里_int_cfg的那一个if分支即可回退,本 PR 在代码注释里写明了这一点。显式写
0仍然生效(不夺走维护者主动关掉投票窗口的能力)—— 收紧只作用于「配置写错」这一种此前静默通过的状态,断言[T42b]钉住。生产配置
.voting.yml三个键齐全 ⇒ 本项对现网行为零改变,只影响「配置写错」这条此前静默放行的路径。④ 输出指名「是谁的哪一张 review 在挡着」
问题:此前输出只有「未合并(赞成率 66.7% < 门槛 100%)」,维护者必须自己去翻 review 列表才能定位到答案 —— 而那恰恰是「为什么合不掉」的答案。(
rt334是独立提出这一点的;本 PR 早前 R80 就把「赞成率不足是唯一没有主人的失败场景」记成了待办。)改法:新增「
- 阻塞明细:」块,每一条对准reasons里的某一项:三项全过时打印「`- 阻塞明细: 无(人数 / 赞成率 / 窗口三项门槛全部满足)」」,免得维护者去找一个不存在的阻塞源。
latest_reviews从返回(vote, sub, rid)改为(vote, sub, rid, state)—— 不带state的话COMMENTED/DISMISSED/PENDING三种都被压成vote=0,运维只看到「他不计入」而不知道他干了什么。tally返回值扩到 7 元组(新增per_vote/dropped_detail),index 0..4的含义一字未动。交付物(commit
d76f429)vote_merge.py:+209 / −25;voting.yml:+8。1(红 2)、只恢复窗口默认值0(红 2)、删掉缺键警告(红 1)、4xx也重试(红 3)、非幂等方法也重试(红 3)、去掉urlopen超时(红 1)、删掉整段阻塞明细(红 3)。print("- 阻塞明细:")那一行标题,没有任何断言锚在它上面,全套件依然 214/214);换成按块删除后 T43/T44 才变红。反向变异没红时,先确认它真的改到了有断言锚点的位置(R82 教训的又一次应验)。本轮三条工具/方法教训
[T53]在测试文件里重写了一个无条件重试的api(),它当然会重试 3 次 —— 但那是另一个函数,对被测代码零证明力。改成复制vote_merge.py到临时目录、删掉守卫那半句、单独import成另一个模块,才是真正的源码层变异。id会把最关键的判据磨平。review()此前不设id⇒rid恒0⇒latest_reviews的prev[2] >= rid(0 >= 0)在同一人多张票时永远保留首张,破平逻辑实际上从未被真正测过;输出里的review #?是同一个原因。已让助手发单调递增 id,并加[T38b]自检助手本身 —— 没有断言能发现测试助手退化。DELETE_FOREK_HEAD_BRANCH曾在 import 时求值。等于把这个开关永久冻结在进程启动那一刻:测试无法用环境变量驱动它,将来若有人import后再改环境变量也会静默失效。改为只存变量名 + 调用时现读(delete_fork_head_branch_enabled()),env 始终是唯一事实来源。R91 新增:本 PR 唯一一处改动合并前不生效,且原因不在本 PR(实测归因)
现象
本 PR 对
.github/scripts/review_from_diff.py的全部改动是+6/-2三处,只做一件事:把 payload 真正送进 stdin而
Robin Review在本 PR 上的审查(run36724596029)里,行内评论 0/5 成功,汇总 review 正文自报共 5 条发现,0 条已发为行内评论。根因(已复现,四条证据交叉)
Robin Review是workflow_run两段式的第二段,其actions/checkout@v4未指定ref(robin-review.yml:61),因此执行的是默认分支(test)的代码,而非本 PR head。base 版本恰好缺少stdin_data那一半:grep -c 'stdin_data=json.dumps(payload)'12b96ef,实际执行的那份)ed9631b)run_gh(args, timeout=60, stdin_data=None)不传 ⇒subprocess.run(input=None)⇒gh api --input -收到空 body ⇒ GitHub 判定请求体不是 object。复现命令(返回与 CI 逐字相同的报错):补充对照:同一 payload(
{commit_id, path, line, body}四字段)配真commit_id返回 201 Created,说明请求结构本身合法,问题仅在 stdin 为空。这是自指闭环,不是缺陷
GitHub
workflow_run的固有语义:第二段代码版本由 base 分支决定。⇒ 本 PR 的唯一改动要等本 PR 合并后才生效;合并后下一次审查才会带上这 5 条行内评论。顺带查清一处历史成因:
--input -与「payload 送 stdin」是分两次落地的半截修复,中间窗口内所有行内评论静默全败(本次 5/5 即落在该窗口内)。base 版本代码里没有这段注释,head 版本补上了——即本 PR 补的正是这一半。需要维护者知晓(不影响本 PR 正确性)
robin-review.yml:62的注释列出了「取默认分支版本」的两个配置文件(.github/robin.yml/code-reviewer.md),但未列review_from_diff.py脚本自身,而它恰是最常被 PR 改动的那份。建议后续把脚本自身也写进那句括号,避免下一个人误以为改它能立刻生效。R86:落实 rt334 的第二条评审(触发面 / 链路面)—— 含一处收紧与一处需维护者配置
R85 处理了 rt334 的第一条评论(4 项)。他随后在 04:31:08Z 又留了第二条(
#5863394570,3 项)。本节逐条核验后全部成立,其中 ①是本 PR 自己引入的回归。①
ready_for_review会造成空跑假绿 —— 恢复触发器(收紧,非放松)voting.yml的on.pull_request_target.types被我改成过[opened, reopened, synchronize, ready_for_review],同时把closed删掉了。两处都是错的:payloadAction的表达式${{ github.event_name == 'pull_request_review' && 'synchronize' || '' }}对pull_request_target事件求值为空串;src/main.ts:190-192)是core.getInput('payloadAction') ? ... : github.context.payload.action,空串是 falsy ⇒ 回落到payload.action,即'ready_for_review';switch (payloadAction)(main.ts:223-248)只认opened/reopened/synchronize/closed且没有default分支 ⇒ 什么也不做、job 成功。即:触发一次
ready_for_review就会产出一条并非真实评估的绿色 check —— 正是本 PR 声称要消灭的那类空跑假绿,我却在types里亲手加了一条。同时被删的
closed对应 action 的close()分支(写「Voting is closed」并清理旧 check),删掉它等于让关闭投票的清理路径永不触发。处置:恢复为 base 的
[opened, synchronize, reopened, closed],并在文件里写明为什么不能加ready_for_review,以及将来要支持它必须同时把payloadAction表达式扩成${{ github.event.action == 'ready_for_review' && 'synchronize' || '' }}—— 两处一起改,只改一处就造出新的假绿路径。②
GITHUB_TOKEN合并不触发下游on: push—— 引入AUTOMERGE_TOKEN(需维护者配置)rt334 指出「用
GITHUB_TOKEN做的 push 不会触发on: push工作流」。核验本仓实际情况后确认,且比他说的更具体:commitTest.yml是on: [push, pull_request, workflow_dispatch],其中两步的关键条件是即「Delete old pre-releases」+「Create pre-release」。人工点合并时 push 事件由真人产生,正常触发;一旦改成 token 自动合并,该 push 不会产生新 run ⇒ jar 不会构建、预发布不会更新,且全程零报错。症状是「PR 合上了但没有新预发布」。
为什么 job 级
permissions: contents: write救不了:GitHub 会把 fork 来源的pull_request/pull_request_review注入的GITHUB_TOKEN降级为只读,且这条安全规则盖不住显式声明的 job 权限。本仓近 12 个 PR 全部isCrossRepository=true⇒ 这一步在跨仓 PR 上是结构性 403。这一点本 PR 早前已用单变量对照实测(同一 PR、同一头提交,只换事件类型)。处置(依据用户授权「可以用 key,必须实现功能」):
GITHUB_TOKENAUTOMERGE_TOKEN(Actions Secret)GITHUB_TOKEN必被降级;且 PAT 的 push 会触发on: push,正好保住commitTest.yml的预发布链路维护者需要做的唯一一步:
secrets而不是vars:vars只能读到 Configuration variables,读不到 Actions secrets。写成${{ vars.AUTOMERGE_TOKEN }}会恒为空串,而代码侧对空串是 fail-closed 的 —— 于是症状是「配了 secret 但永不合并」,且没有任何报错提示。VOTE_MERGE_DELETE_FORK_BRANCH用vars是正确的(那个确实是仓库变量),两者语义不同、不可混用。head 仓信息缺失时有意按同仓处理(保守方向):拿不到拓扑时不能断定「跨仓」,误判成跨仓会逼维护者去配一把其实用不上的凭据;误判成同仓的最坏结果只是吃一个 403,而 403 分支会明写成因,排查方向不会跑偏。
同时修正了已过时的诊断文案:原 403 分支写着「
contents: write只对本仓有效,跨仓写不通」—— 跨仓场景现在已改用 PAT,这句话不再成立,改为指向AUTOMERGE_TOKEN的处置办法。③ 复核结论只
print→ 写$GITHUB_STEP_SUMMARY核验:全仓
.github/下GITHUB_STEP_SUMMARY命中 0 处。详细诊断只在 run 日志里,而日志要逐层展开才能看到;checks 页面显示的是 Step Summary。处置:新增
summary()+p()+_SUM缓冲,把同一份诊断同步写进 Step Summary,覆盖全部 5 个判定出口(PR 非 open / 是草稿 / 门槛阻塞 / 合并被拒 / 已合并)。三条约束:交付物
vote_merge.py+171 / −57;voting.yml+35 / −1。GITHUB_TOKEN(红 13,含端到端 T59)、Summary 路径恒None(红 9)、注释掉 5 处summary()调用(红 T62/T63,证明 5 个出口全覆盖)、ready_for_review塞回types(红 T66)、去掉closed且secrets→vars(红 3)。对照组(同仓用GITHUB_TOKEN、GITHUB_STEP_SUMMARY缺失)在全部 5 组变异下保持绿。本轮四条工具/方法教训
run()走os.environ.clear()+update(e)(模拟 runner 的干净环境,这个语义本身是对的,正是它让「本地环境残留」无法掩盖问题),代价是测试代码在run()外面设的GITHUB_STEP_SUMMARY根本进不去。症状是 4 条断言同时红且真因完全在测试台。修法是加旁路变量由调用方经env=显式传,而不是去削弱run()的环境语义。env.setdefault表达不了「刻意为空」。想把某变量设成「未配置」时,setdefault会被已有的生产默认值占位;必须显式传""。首版 T59 因此测的是一个已配置凭据的场景,却断言「应当 fail-closed」—— 断言与场景自相矛盾。LAST_MERGE_TOKENS首版是累积的,T61 拿到 23 个元素的列表,症状看着像「凭据选错了」,真因是断言取了全量历史。「值不对」与「取错了范围」要分开查。return的缩进 →IndentationError→ 变异体根本没被执行,输出为空一度看着像「全绿」。凡变异体产出空输出,一律先当失败查。