Skip to content

ci: 投票闸门支持自动更新与自动合并(修 pull_request_review 空跑假绿) - #68

Open
Xiaobei09 wants to merge 17 commits into
SiliconMod:testfrom
Xiaobei09:fix/vote-automerge
Open

Xiaobei09 wants to merge 17 commits into
SiliconMod:testfrom
Xiaobei09:fix/vote-automerge

Conversation

@Xiaobei09

@Xiaobei09 Xiaobei09 commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

票况(2026-09-29,rt334 评审后复核):票已达标 —— rt334 于 2026-09-28T15:40:05Z 投出第三张 APPROVED(落在当前 head 5885cc3)。用 vote_merge.py 计票逻辑复核:numVoters=3 赞成=3 赞成率=100%,人数/赞成率/窗口三项门槛全部满足(ARCloud217 的旧票早于 head 更新已作废)。本 PR 不再差票。 剩余阻塞仅两项操作:① 维护者手动 squash 合并一次(自举);② 配置 AUTOMERGE_TOKEN(跨仓自动合并)。详见下方清单。github 合并按钮当前应已解锁(democracy 与 buildJar 均 SUCCESS)。

一句话讲清「双轨计票」——为什么 democracy 绿 ≠ vote_merge.py 绿

计票口径 时间过滤 #68 当前
democracy(action,required context) 每人最后一次 review 的立场,作者合成 +1 无 —— PR 更新前的历史票全算 ARCloud217 旧票(09-27)也计入 ⇒ 4 👍
vote_merge.py(本 PR 引入的严格门,automerge 前置) 同上 + 更新后旧票作废、作者合成票保留 有 —— 早于 head 更新的票作废;窗口锚点=头提交时间 新鲜票 by514 + rt334 + 作者 = 3/3

两者数字不能并排比:democracy 的「4 👍」把 09-27 的历史票也算进去了;vote_merge.py 的「3/3」只认 head 更新后的新鲜票。两条计数此刻都达标(democracy=SUCCESS、vote_merge 逻辑复核 3/3 100%),只是口径不同。判定「严格门是否放行」看 vote_merge.py 的输出,不要看 democracy 的颜色。

⚠️ 合并前必读(会挡合并的 3 件事)

① 自举:本 PR 自己不能靠 automerge 合入,需维护者手动 squash 一次

automerge job 把 checkout 钉在 ref: ${{ github.event.pull_request.base.sha }}(复核脚本必须来自 base,不能是 PR 作者提供的版本),而 .github/scripts/vote_merge.py 正是本 PR 才引入的:base bd44662 无此文件、head 5885cc3 有。run 36391962061 日志原文: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 会恒为空串且无任何报错):

Settings → Secrets and variables → Actions → Secrets → New repository secret
名称 AUTOMERGE_TOKEN,值 = 一把对本仓有 contents: write 的令牌(classic PAT 勾 Contents: read & write 即可)

③ vote_merge.py 不是 required check —— 二选一,维护者定

required context 目前只有 buildJar + democracy;严格门跑在 automerge job 里,而 automerge 不是 required context → 手动合并可绕过严格门(本 PR 当前正是靠「维护者自觉」守这道门)。rt334 评审第 3 条给出的选项:

  • 选项 A:把 vote_merge 做成 required status check(需让 Voting 工作流产出同名 check-run 并加入规则集 22253791;属后续代码改动,会 invalidate 本 PR 已收的 3/3 票 —— 见「合并后建议」)。
  • 选项 B:接受「靠人自觉」——本 PR 按现状手动合并 + 人工复核落地。

⚠️ 合并前需维护者决策的 3 项 fail-open(挂起清单,本 PR 不擅自改)

rt334 评审建议在顶部单开清单,避免淹没在正文里;三项的完整论证分别在正文 R82 / R83 / R84 节:

# 缺陷 后果 推荐
R83 .voters.yml 负权重 一条 -1 权重能抹掉一条 CHANGES_REQUESTED(三张 +1 + 一张 -1 → 赞成率 150% 放行),形状与 PR #50(3+1=66.7%、卡 23 天)完全重合 选项 1:负数即报错(最干净)
R84 配置解析三处比 action 更宽 重复键后者胜 / 嵌套外层带整数 / BOM 吞首个投票人;前两项叠加 R83 的负权重能造出真实放行 选项 1:遇缩进行直接拒绝
R82 负数门槛值静默归 0 minVotersRequired: -1 让人数维度消失,action 侧可直接放行 选项 1:负数即报错

合并前维护者必须知道的(4 条,均为固有事实)

  1. 合并前,voting.yml 读的是 base 版本(pull_request_target 恒读 base):钉死 SHA、job 级 permissions、两处 ref: base.sha、整个 automerge job 在合并前零生产执行。且合并前跑的是 Xiaobei09/git-democracy@main 浮动 ref —— 那个个人 fork 的 main 已在合并前就位于上游 CI 的信任边界里;合并后才收窄成钉死 commit。请勿把「已实测」用在合并前路径(R73 三节有完整取证)。
  2. 两个开关不配就是默认关 / fail-closed:VOTE_MERGE_DELETE_FORK_BRANCH(vars,不配不删 fork 分支)、AUTOMERGE_TOKEN(secrets,不配跨仓不合并);vars / secrets 写错会恒为空串且无报错。
  3. 回归台 265/265 只覆盖纯函数;main() 的实时 API 路径(取 head.sha、取时间、调 merge、写 Summary、删 fork 分支)只能在实跑里验证,而实跑要等合并之后 ⇒ 本 PR 合并时,其最关键的合并动作路径从未在生产配置下跑过。
  4. 合并后建议立即用一个低风险 PR 走一遍完整 automerge,不要等一个真实重要的 PR(rt334 评审第 10 条)。

合并后建议(按序)

  1. 配置 AUTOMERGE_TOKEN(对应必读 ②)。
  2. 手动 squash 合并本 PR(自举,对应必读 ①)。
  3. 立即用低风险 PR 走一遍完整 automerge —— 这是该代码路径的第一次生产执行(回归台只覆盖纯函数)。
  4. 之后在维护者决策的节奏上落地:vote_merge 变 required check(必读 ③ 选项 A)与/或修 R82/R83/R84 三项 fail-open —— 任一代码改动都会 invalidate 相应 PR 已收的票,需按新 head 重新收集。

背景

当前 Voting 工作流实际上无法自动更新票数、也从不合并。PR #67 上三个 open PR 的 mergeStateStatus 全是 BLOCKED,卡在 required context democracy 上。

查明的 4 个缺陷(均附实测证据)

1. 投完票票数不刷新 —— 「自动更新」的根因

投票载体是 PR review(Approve / Request changes)。action 遍历 pulls.listReviews(reactions.ts)。

但 src/main.ts:213 的 switch 只认 payloadAction ∈ {opened, reopened, synchronize, closed},没有 default 分支:

switch (inputs.payloadAction) {
  case 'opened': case 'reopened': case 'synchronize': await startOrUpdate(...)
  case '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):

context isRequired conclusion startedAt
buildJar true SUCCESS 14:43:04
democracy true FAILURE 14:43:04
democracy true SUCCESS 14:58:25

两条 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;新增独立 automerge job(permissions 补 contents: write)。
  • .github/scripts/vote_merge.py(新增):合并前独立复核票数。

为什么 automerge 必须独立成 job:若并入 democracy job,合并失败会把 required check democracy 弄红,PR 反而永远合不掉(自死锁)。automerge 不是 required context,失败不阻塞。

票数复核规则(vote_merge.py)

严格复刻 action 语义(每人最后一次 review 覆盖前一次;weight = voters.get(user) ?? 0;PR 作者被 action 自动记 +1),并在此基础上往更严的方向加:

情形 action 行为 本脚本
0 票 0/0 = NaN,NaN < 100 为 false → 静默放行 显式判失败
PR 更新后的旧票 循环里无任何时间过滤,历史票全部计入 早于最近一次更新的票一律作废;作者自票保留
投票窗口起点 head.repo.pushed_at(头仓任意分支的 push,别人推别的分支也会顶掉) 头提交时间(本 PR 的真实更新时间)

合并闸门的三条设计约束

① 退出码必须能区分「判定不合并」和「无法判定」

0 = 做出明确判定(已合并 / 判定不合并:草稿、非 open、票未达标、窗口未满)
1 = 无法判定或动作失败(环境变量缺失、取不到 head.sha 或更新时间、合并请求被拒)

原先 draft / closed 退出 1 而「票未达标」退出 0 —— 同样是「决定不合并」却颜色不一致。合并不可逆的代码里,「拿不到数据」必须 fail-closed(退出 1、不合并),绝不静默放行。

每条路径都打印一行以 合并结果: 开头的指纹(已合并 / 未合并(<原因>) / 合并被拒:<原因>),便于在 run 日志里 grep 确认到底走了哪条 —— 不能只看 job 的 conclusion。

② 合并时把 sha 钉死到本次复核过的头提交

原先用 gh pr merge,它合的是调用那一刻的当前 head,不是我们复核过的那个 —— 复核通过到合并生效之间若被推了新提交,那个未复核的提交会被合入。现改用 REST PUT /pulls/{n}/merge 带 sha;头被改动时 GitHub 返回 409,不会把未复核的提交合进去。

③ 合并成功之后不许再失败

分支清理阶段吞掉全部异常并降级为提示行。否则「PR 已合并」却报退出码 1,维护者会看到红叉以为合并失败 —— 这种「事后失败」比直接报错更有误导性。

安全性

  • 未登记用户无法影响票数:weight = voters.get(user) ?? 0,陌生人投多少票权重都是 0。自动合并由「≥3 位登记投票者 approve」把守。
  • 不依赖上一个 job 的退出码:合并不可逆,复核在 workflow 脚本里独立重算全部 review,不信任 core.setFailed 的结果。
  • 只 squash(ruleset 22253791 的 allowed_merge_methods 仅 ["squash"]),并显式传 merge_method: "squash"。
  • 跳过 draft PR 与非 open 状态 PR。

⚠️ 坦白一处:试验田配置掩盖了一个 P0

第一版脚本在 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 的 democracy job。而该 action 来自个人 fork 的浮动 ref(Xiaobei09/git-democracy@main):任何能往那个 fork 推东西的人,都会在每个 PR 上拿到本仓的 contents: write。

现在只有真正要合并的 automerge 持 contents: write;democracy 只有 contents: read + pull-requests: write + issues: write。

⚠️ 注意:job 级 permissions 会把未列出的权限一律置为 none,所以 contents: read 必须显式列出,否则连 actions/checkout 的 clone 都会失败。

② 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 错误)
日志里唯一的 error 注解 是投票未过(1 < 3),无任何授权错误 ✓(原文写作 GitHub Actions 的 error 注解前缀字面量,为免被日志处理器误解析,此处改用文字描述)
checkout 实际检出 7372a48ee0... = base 提交 ✓ 显式 base.sha 生效
automerge job skipped ✓ fail-closed

本轮(R71)又补掉的三处「比 action 更宽 / fail-open」漏洞

上一轮我把脚本与 action 的分歧总结成「三处有意分歧,都是往更严的方向」。这次逐行审
边界分支时,发现还有三处不满足这个前提,其中第一处是安全相关的绕过。已修,并把
测试台从 47 条扩到 73 条。

# 缺陷 方向 后果
1 latest_reviews 只比 submitted_at 的字符串,而它只有秒级精度 🔴 比 action 更宽 同一秒内先 APPROVED 再 CHANGES_REQUESTED 时,本脚本保留先投的赞成而放行;action 的 result.set 是后写覆盖,保留后投的反对而拦住
2 tally 的时间过滤写作 t is not None and t <= update_time 🔴 fail-open 时间戳解析失败时整道过滤被跳过,条件退化成「解析失败 = 最新票」—— 恰好是本函数要防的「PR 更新前的旧票被算进来」
3 load_yaml_map 是平坦解析器,.voters.yml 若写成嵌套会静默拍平内层键 🔴 比 action 更宽 本脚本认出一批 action 认不出的投票人(action 的 Voters 类只收顶层 number 值),与「分歧一律往更严」的前提直接冲突

修法

  1. 破平改用 reviews 的单调递增 id(rid),与 action 的遍历顺序一一对应:
    rid = r.get("id") or 0
    prev = latest.get(login)
    if prev is not None and (prev[1] > sub or (prev[1] == sub and prev[2] >= rid)):
        continue
    latest[login] = (vote, sub, rid)
    断言 [K] 特意写成「同一对 review 反转 API 返回顺序后结论必须完全相同」,
    确认破平只看 id、不受返回顺序影响。
  2. 解析不出来的决定性票(APPROVED / CHANGES_REQUESTED)按过期票作废
    (fail-closed);vote == 0(PENDING / COMMENTED / DISMISSED)不参与任何
    门槛判定,仍不受影响(断言 [M] 钉住这一点,避免为收紧而误伤)。
  3. 权重非整数就 fail-closed 并点名是哪几项;顺带把 .voting.yml 各门槛的裸
    int() 包起来,从 ValueError 崩溃变成可读的 fail-closed 说明。
    实测真实配置(5 人各权重 1、percentageToApprove: 100)不受影响。

另补一条可观测性:头提交时间取自提交者本地时钟,可能被顶到未来(机器时钟不准、
或提交日期本身写了未来)。此时每张票都早于 update_time → 全部作废,且窗口终点也在
未来 → 永远合不上。原先日志里只看得见「无任何有效票」,无从定位真正原因。现在这个
原因被写进 合并结果: 指纹(断言 [T20])。

一处我特意没改:负权重投票人(如 .voters.yml 里写 -1)会让 total 被压低甚至
变负,方寸算出来很怪。但 action 的 weightedVoteTotaling 用的是同一套原值累加,
语义相同,改了就等于制造一处新的「本脚本 ≠ action」分歧。当前
minVotersRequired: 3 也足以兜住(负权重者不计入 numVoters)。保持与 action 一致
优先,记录在此备查。

附带修掉的一个误诊:合并被拒必须按 HTTP 状态码分流

pull_request_target + 写权限的场景下,「合并被拒」有三种成因完全相反的路径,而 GitHub 对其中两种都只回一句含义模糊的 message:

状态码 真实成因 正确处置
403 令牌缺 contents: write(workflow 权限配错,或头在 fork 上跨仓写不通) 改 workflow 权限 / 联系维护者,不用重投票
405 ruleset / required check 挡住(票数没问题) 查 democracy 是否在当前头提交上重跑过
409 头提交在复核后被改过(TOCTOU) 安全行为,非故障;新 head 需重走一轮投票

上一版把三者一律解释成 ruleset 挡住。若真出现 403,维护者会被引导去查投票数和 required check,而正确处置根本不涉及重投票——排查方向完全跑偏且一直查不出结果。现已按状态码四路分流,报告行带上状态码。

附:近期 9 条 democracy run 的实测体检

PR 开始时间 conclusion 真评估?
#67 15:22:58 success ✗ 空跑(voting failure message 计数 0)
#67 14:58:25 success ✗ 空跑
#67 14:43:04 failure ✓ 真评估(仅「票数不足」)
#66 14:06:22 success ✗ 空跑
#66 06:59:11 failure ✓ 真评估(仅「票数不足」)
#64 13:51:21 success ✗ 空跑
#64 13:49:17 success ✓ 真评估且通过(voting failure message: '',3 票 + 作者)
#64 13:48:54 success ✗ 空跑
#64 13:46:42 success ✗ 空跑

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)。本次只修机制,不动治理规则。

  • ⚠️ action 会把 PR 作者自动记一票赞成(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:24Z
    action 用的窗口起点 head.repo.pushed_at 2026-09-27T16:22:55Z
    偏差 101 分钟

    那次 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#66 06: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、同一个头提交,只换事件类型,结论相反

run 事件 结局
36357503139 pull_request_review 日志首行是一条 error 注解,内容为 error while running action: HttpError: Resource not accessible by integration(原文写作 GitHub Actions 的 error 注解前缀字面量,为免被日志处理器误解析,此处改用文字描述)
36337473071 pull_request_target 无任何授权错误,跑到真实计票,只因票数 1 < 3 失败

两个 run 的 head 都是 a56c600,唯一变量是事件类型。头仓 Xiaobei09/Silicon
经 API 确认 fork=true, parent=SiliconMod/Silicon。

根因

  1. GitHub 会把 fork 来源的 pull_request / pull_request_review 事件上的
    GITHUB_TOKEN 降级为只读。显式写 permissions: 不能覆盖这一点。
  2. 计票本身只需要读权限(listComments / listReviews / pulls.get)。
  3. 但 action 的 findOrRecreateVotingComment 会 deleteComment + createComment
    —— 必然 403。
  4. 致命之处是顺序:main.ts 里 findOrRecreate 排在 evaluateVote 之前。
    异常在「还没计票」时就抛出去,于是 required context democracy 变红,
    而红的原因被写成「授权错误」而不是票数。运维会去查权限,真正的原因被掩盖。

为什么这是 P0

pull_request_review 触发器正是为了实现「投完票票数自动刷新」而加的
(即本 PR 的核心诉求)。而上游近 12 个 PR 全部是跨仓 PR,也就是说:
这条链路在修复前从未成功运行过一次。

修复

上游 fork Xiaobei09/git-democracy commit 03bd2fc:

  • 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.ts 7 条断言(全部 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 是只读的。同样的降级也适用于
automerge job——它需要 contents: write 才能执行合并 PUT。

推论(待验证 + 验证方法):即使票数达到 3、由 pull_request_review 触发的
那次 run 里,automerge 的合并请求也会拿到 403。验证方法:当 PR #68 上出现第 3
张赞成 review 时,检查该 pull_request_review run 的 automerge job 日志里有无
Resource not accessible by integration / HTTP 403(注意用词边界 grep,裸
grep 数字会命中时间戳微秒)。

能走通的三条路,都需要维护者决策:

  1. 仓库设置打开 Send write tokens to workflows from pull requests
    (即 default_workflow_permissions);
  2. 用 PAT 或 GitHub App(pull_request_review 下 secrets.* 不可用,
    github.token 才是被降级的那个);
  3. 接受「只在 pull_request_target 触发的 run 里合并」——即票投满后需要再有一次
    push。这是目前唯一无需任何配置变更就能生效的路径。

R73 新增(一):投票门槛在真实数据上的可达性 —— 量化结果

用 API 真值复刻 readReactionsCounts + weightedVoteTotaling + evaluateVote 的完整算法,对上游最近 25 个 PR 全量模拟(判据来自 pulls/{n}/reviews 与 .voters.yml,不靠印象):

判定 数量
✅ 通过(3 人 + 100%) 6 / 25(24%)
❌ 仅人数不足(numVoters < 3) 16 / 25(主导阻塞)
❌ 仅百分比不足(有人 CHANGES_REQUESTED) 2 / 25
❌ 两者都不足 1 / 25

配置真值(读自 origin/test):percentageToApprove: 100、minVotersRequired: 3、minVotingWindowMinutes: 10;.voters.yml 只有 5 人。

三点结论:

  1. 门槛本身可达(24% 的 PR 达到),不是「永远合不掉」的数值。真实瓶颈是人类评审太少——近 25 个 PR 里多数只有 0–1 条真人 review。
  2. percentageToApprove: 100 意味着零容忍反对票:forIt/(forIt+againstIt) 只要有一个登记投票人最新状态是 CHANGES_REQUESTED,就再也到不了 100%。这是设计意图(共识制),不是缺陷,但维护者应知道它把「有人认真提了反对意见」等同于「PR 永久阻塞」。
  3. 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 的 PENDING review(有人开了 review 草稿但没提交)submitted_at 为 null、state 被映射成 0、id 又比已提交的 review 大、排在列表末尾。于是:

某登记投票人 approve                     → 票 +1,numVoters++
同一人稍后开了个 review 草稿、没提交就离开 → result.set(login, 0)
                                        → 那张赞成票被静默抹掉
                                        → numVoters 跌破 3
                                        → 闸门报「票不够」,原因与票无关,且不会自行恢复

反向同样成立:提出反对的人只要开一个草稿就等于撤销了反对。

同时修掉一个次要问题:「取 PR 作者」的 pulls.get 原本写在分页循环体内(N 页调 N 次,且作者的 +1 会被后续页里作者的 review 盖掉)。现移到循环之后,作者 +1 在所有 review 之后生效一次。

修复位置:上游 fork Xiaobei09/git-democracy commit a0c7f646b1ffd614a73175e63f9c66fdb806a153(src/reactions.ts +29/−15),本 workflow 的 uses: 已钉到该 commit。

测试:新增 __tests__/readReactions.test.ts 10 条断言全过(纯 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.js 2000671 → 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],且实际值显示该投票人的票整张消失(numVoters 2 → 1)——「静默吞票」在合并闸门自己的测试台里被复现。

严重性如实标注:全量扫描上游 45 个 PR / 71 条 review,state=PENDING 与 submitted_at 为 null 的条数均为 0 ⇒ 本缺陷在上游尚未发作,属潜在缺陷。修它的理由是「一旦有人留草稿就会静默锁死投票且不自行恢复」,而不是「它正在造成故障」。

R73 新增(三):⚠️ 更正 R72 的一处结论 —— 本 PR 自身的配置从未被任何 run 执行过

R72 我写了「钉到 03bd2fc 后行为正确(生产验证 run 36367078443)」。那句话的结论对、但归因错,必须更正:

  • 上面这个 R73 的验证 run 36368626834(head 64a2d543)日志里,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。
  • 本 PR 分支的 voting.yml 里 @main 出现 0 次(权威判据 grep -nE '^\s+uses:' = 1 行、已钉 commit)。既然日志显示 @main,就证明执行的不是我这份文件。
  • 事件分布实测:Voting 的 pull_request run 0 条;3 条 pull_request_review run 全部早于钉死提交(最新 2026-09-27T23:05Z vs 钉死于 2026-09-28T01:42Z),因此也无从判断 pull_request_review 读哪个版本。

因此,以下内容目前零生产执行证据:钉死的 SHA、job 级 permissions、两处 ref: base.sha、以及整个 automerge job(该 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 正是把这个边界收窄。

待验证 + 验证方法(单账号无法自行触发,需第二账号或维护者):

  1. pull_request_review 事件读的是 PR 版本还是 base 版本 —— 由第二账号在 PR ci: 投票闸门支持自动更新与自动合并(修 pull_request_review 空跑假绿) #68 上提交一次 review,查该 run 日志的 Download action repository 行显示的是钉死 SHA 还是 @main。
  2. 本 PR 配置的端到端行为 —— 合并后在 test 分支上产生一个真实 run;届时 automerge job 才会第一次执行,并同时接受「跨仓 PR 写权限」这一关(见下节)。

为什么本 PR 不自己加 pull_request 触发器来补这个验证:合入后 pull_request 与 pull_request_target 会在同一事件上各产生一条同名 democracy check。同一 context 名存在多条 CheckRun 时,任一条 failure 就让 statusCheckRollup 整体判红、后到的 success 不覆盖 —— 这会让「可执行性验证」的收益被一个更隐蔽的判定风险抵掉。是否加该触发器是维护者的取舍(收益:PR 自身配置可提前验证;代价:双 check 同名),不在「本次修复」范围内,故不在本 PR 里动。

R74 新增:「票已够但卡在投票窗口、且之后无事件重跑」——机制成立,但上游 0 次发生(含一处自我更正)

我一度报了一个不存在的 P0,先更正

R73 的 25 PR 全量模拟里,「仅窗口不足」占 5 条。R74 我按此追查「活性故障」,写了分类器扫全部 Voting run,得到:

按这个证据,我准备上报「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/ 下)加权后的逐人状态:

  • ARCloud217 weight=1 APPROVED → +1
  • Xiaobei09 weight=1 CHANGES_REQUESTED → −1
  • github-actions[bot] weight=0 COMMENTED → 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)的阻塞源恰好相反

  • ARCloud217 weight=1 APPROVED → +1
  • Xiaobei09(作者)weight=1 合成 +1 → +1

forIt=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 按 action a0c7f646 的 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)

  1. 胜者选择键改为 id:latest_reviews 只保留每人 id 最大的已提交 review,显式跳过 state==PENDING && submitted_at==null 的草稿(与 action 的 not counting unsubmitted PENDING review draft 逐字对齐);
  2. 补 PENDING 常量;docstring 重写选择键约定;
  3. 回归台 75 → 77:新增 [O1]/[O2] 反演族断言;反向变异验证双红(把选择键改回按时间 → [O1] 实得 (0,0,0)、[O2] 实得 (1,1,0),两断言同时失败,证明断言真的钉住了该口径);
  4. 差分对拍 200/200 归零(含反演与无反演两组各 200)。

影响评估

上游 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

  • 全量 ruleset 扫描:整个仓库只有一个 ruleset #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 的生产 run 36372216746(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。同头 buildJar conclusion=success。
  • base 无漂移:base.sha == test 当前 HEAD == bd44662b,strict_required_status_checks_policy 维度已满足。
  • tally 作者旧票往返:作者自己的陈旧反对票会被合成 +1 覆盖、且不留在 dropped 列表,与 action 展示完全一致(回归台 [B2] 早已钉住,无需修改)。
  • 真实数据交叉验证(PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50):以 PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 当前头提交时间跑 tally —— 其 8 条 review 全部早于头提交 ⇒ 全被当旧票丢弃(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.date vs head.repo.pushed_at)

PR 头提交 头提交时间 pushed_at 偏差
#67 7e936a3 2026-09-27T14:42:24Z 2026-09-28T03:15:15Z +752.9 分钟
#50 d62f6b6 2026-09-27T07:32:41Z 2026-09-27T07:37:09Z +4.5 分钟
#68 6bfe04a 2026-09-28T03:15:12Z 2026-09-28T03:15:15Z +0.1 分钟
#65 f2f752e 2026-09-27T15:18:44Z 2026-09-27T15:18:44Z +0.0 分钟

三条结论

  1. 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 分钟。
  2. 推论:活跃 fork 会撞上活性死锁。窗口开启的条件实际上是「该 fork 最近 10 分钟内没有向任何分支 push」。对持续迭代的 fork(10 分钟内一直在 push),窗口起点被持续推进 ⇒ 窗口永不合拢。方向是更严(不会错误放行),故本次不改行为,仅把事实与推论写进诊断注释。
  3. fail-open 担忧证伪: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):

守卫 行 场景
权重非整数 / 嵌套 / 缺值 258 T16 / T17 / T18
门槛值非整数 264 T19
PR 已关闭 278 T6
PR 是草稿 281 T5
取不到 head.sha 288 T8
update_time 解析不出 292 T9
投票人不足 348 T3 / T4b
无任何有效票(0/0=NaN) 350 T4 / T4b
赞成率不足 352 无 ← 本轮补
头提交时间在未来 354 T20
窗口未满 363 T2
合并被拒 403/405/409/未识别 381+ T12 / T13 / T14 / T15
清理阶段异常 — T11

这不是无关紧要的盲区:percentageToApprove: 100 配上一条未撤销的 CHANGES_REQUESTED 正是 PR #50 已卡 23 天的真实阻塞源(forIt=2 / againstIt=1 / numVoters=3 / 66.7%),而回归台里所有「票够」场景都是 3 人全赞成 = 100%。

新增 3 个场景(按 PR #50 的真实拓扑)

  • T21 作者=rt334、ARCloud217 赞成、Xiaobei09 一条未撤销的 CHANGES_REQUESTED ⇒ numVoters=3 / 赞成=2 / 反对=1 / 66.7% ⇒ 判不合并、不调用 merge 端点、指纹点名「赞成率 66.7% < 门槛 100%」、计票表把对象者标为反对。
  • T22 同一条 review 流,仅把门槛改成 50% ⇒ 通过并合并。这是 T21 的反证:证明 T21 判不合并确实是被百分比这条门槛卡住的,而不是别的原因。
  • T23 票数与百分比同时不足 ⇒ 指纹里两条理由都在(分流诊断不能只报一条)。

反向变异(证明新断言咬得住)

  • 把 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 的对应关系

src 测试 expect 数
comments.ts comments.test.ts 11
config.ts config.test.ts 6
main.ts main.test.ts 0(上游自带冒烟测试,还需真实 INPUT_TOKEN)
reactions.ts reactions.test.ts 5
voters.ts voters.test.ts 1
voting.ts 【无】 —

voting.ts 里的 evaluateVote 是 action 的唯一判定点:三条失败消息(百分比 / 人数 / 窗口)与「通过 / 不通过」全部由它产出。也就是说,上游的闸门判定逻辑此前没有任何一条自动化断言。

新增 12 条断言(__tests__/voting.test.ts,fork commit d5582a7)

  • 通过路径:票够 + 100% + 窗口已过 ⇒ 返回空串。
  • 三个失败维度各自触发:百分比(用 PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 的 66.7% 形状)、人数、窗口未满(断言点名「Earliest time to end window is」)。
  • 三维度同时不足 ⇒ 三条理由都在。
  • 两条口径边界(本文件最有价值的部分):
    • 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 → < -1 3 红
votingEarliestEnd > new Date() → > new Date(0) 6 红
numVoters < min → <= min 6 红

三次变异后 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 的 pin

git 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 + 零票 是否可被利用
action 归 0 ⇒ 人数检查 0 < 0 假;百分比因 0/0=NaN 不触发;voteStartedAt=null 时窗口被跳过 ⇒ 返回空串 = 放行 可以(新测试 【口径边界】minVotersRequired: -1 -> 归 0 -> 任意人数都通过 已钉住)
vote_merge.py 保留 -1 ⇒ num_voters < -1 恒假,但独立的「无任何有效票」守卫先拦下 不可以(行为惰性)

vote_merge.py 侧为什么惰性:total > 0 蕴含「至少一张正票」⇒「该人权重 > 0」⇒ numVoters >= 1 恒成立,所以 need=-1 与 need=1 在任何可达状态上等价;唯一能让人数闸门起区别的状态(零有效票)已被「无任何有效票」守卫独立拦下。回归台 [T24b] 用「零票 + 门槛 −1 仍被无票守卫拦住」钉住这一点——去掉 total == 0 守卫时 [T4]/[T24b] 会同时变红(有区分力的验证,不是空转断言)。

提案(三选一,需维护者拍板)

  1. 两侧都改成「负数即报错」(与 percentageToApprove 一致)——最干净,但会让现在写着 -1 的配置直接失败。
  2. 只让 action 侧报错,保留归 0 兼容 —— 折中。
  3. 保持现状,仅在文档里写明「-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 commit 572c57a),并把 fail-open 标注进测试名。只改测试命名与断言,src/ 零改动。

本轮两个「测试自身出问题」的现场(都记下来了)

  • T24 首版是空转断言:写成「指纹不再出现某条」+ else True,恒真。反向变异 abs(need_voters) 也没能让它变红(场景里只有 1 张票,-1 与 1 都放行)——对照也通过就说明测试量不出东西。已改为正面断言「1 张票真的合并了」并补 [T24b] 取得区分力。
  • 提交信息出现 U+FFFD 乱码:shell 内联中文 message 出现 2 个替换字符。已用 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')对照。

穷举出的对照表(全部实测,非推理)

变体 action(js-yaml) vote_merge.py(手写平坦解析器) 方向
重复键 抛 duplicated mapping key 后者静默覆盖前者 🔴 本脚本更宽
嵌套(外层留空) 抛错 bad_weights 拦下 一致(都拒)
嵌套(外层带整数值) 只认外层键 拍平后内层真人权重被认作 🔴 本脚本更宽
BOM + --- 首行 剥 BOM,正常解析 垃圾键 → bad_weights 拦下 一致(都拒)
BOM + 无 --- 首行 剥 BOM,正常解析 首个投票人消失 🔴 本脚本更宽
浮点 / 科学计数 / 十六进制权重 接受为 number bad_weights 拦下 本脚本更严
~ 键 归一成 "null" 保留 "~" 无害(都不是登录名)
空字符串键 保留 "" 跳过 无害
制表符缩进 抛错 产生垃圾键 → 拦下 一致(都拒)

三处「更宽」的机制与后果

① 重复键 ⇒ 后者静默胜出(可与 R83 的负权重叠加)

Xiaobei08: 1
Xiaobei09: 0
Xiaobei09: -1     # 本脚本取这一行;action 根本读不了这个文件

实测 [T31]:3 人全赞成、唯一那条 CHANGES_REQUESTED 因后写的 -1 被减成负数 ⇒ 反对=-1 ⇒ 放行合并。日志里没有任何「配置有重复键」的提示。

② 嵌套写法的防线只挡住一半 —— 本轮更正一条被证伪的旧说法

此前(本文件与源码注释都)写着「load_yaml_map 会拍平内层键、认出一批 action 认不出的投票人 —— 这是唯一一处比 action 更宽的漏洞,与其拿着读错的配置放行,不如停下来说明」。R84 实测证明:后一句的防线只挡住一半。

写法 拍平结果 bad_weights 结果
voters:(留空,最常见) {'voters': '', 'rt334': 3} 命中 voters 退出 1(fail-closed)✓
voters: 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 ⇒ 他在名单里消失 ⇒ 他的票也一起消失。

文件 计票 判定(门槛 2 人、100%)
带 BOM numVoters=2 赞成=2 反对=0 100.0% 放行(合并)
不带 BOM(唯一变量) 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」。

提案(三选一,需维护者拍板)

  1. load_yaml_map 遇缩进行直接拒绝(判据从「值是不是整数」换成「结构是不是平坦」)—— 一次性堵住 ① 之外的全部结构性问题,最干净。
  2. 改用真 YAML 解析(PyYAML 或 yaml.safe_load)与 action 对齐 —— 重复键/BOM/嵌套的行为自然与 js-yaml 一致,但引入新依赖,且会同时把「更宽」变成「更严」。
  3. 保持现状,把本节写进运维文档,要求「.voters.yml 只用 --- 开头、无 BOM、无重复键、纯平坦整数」。

本 PR 不擅自改:三处都改闸门语义(收紧),属维护者决策。回归台用候选修复 1 本身做反向变异(拒绝缩进行)—— 它让 5 条断言转红,其中一条暴露了实现的缺陷:ValueError 未被捕获会变成 CRASH 而非 fail-closed,正确写法需并入 sys.exit 的诊断路径。

交付物(commit 6df6059,判定逻辑零改动,只有注释与报错文案)

  • 回归台 117 → 170/170(新增 T30–T37 共 31 条断言),反向变异 4 次全部咬住:剥 BOM 10 红、重复键改「首者胜出」2 红、拒绝缩进 5 红、只删文案而留注释 1 红。
  • 修掉回归台自身一个盲区:run() 只重定向了 stdout,fail-closed 分支走 sys.exit("…"),消息落在 ex.code 上且从未返回 ⇒ 「无法判定时到底点名了哪个键」这类断言无处可查。已加模块级 LAST_ERR 旁路(不改 3 元组返回值,避免动 40 多处调用点)。
  • 更正 PR 描述早先那条「嵌套写法比 action 更宽、会多认投票人」的说法(见本节 ②)。

本轮四个「测试自身出问题」的现场

  • run() 的 voters= 参数是整体覆盖:T33 首版只写了一行 rt334,另两个人权重 0 ⇒ numVoters=1 而非 3。断言红 ≠ 代码错,是我把名单写漏了。
  • 又一次算错真实拓扑:我以为「拍平多认个假投票人」会导致放行,其实假键名(voters)不会有真人投票,危害不成立;真正的危害是内层真人权重被顶到顶层(②)。
  • LAST_ERR 用得太晚:对照断言放在了 run() 之前,跑到 NameError: out35c。变量必须先定义后使用,哪怕只差几行。
  • 候选修复的 CRASH 不是 fail-closed:变异 3 用 raise ValueError 实现「拒绝缩进」,实测变成 CRASH:ValueError —— 与本项目已有教训「fail-closed 守卫可能是死代码,依赖被调函数返回哨兵值而非抛异常」同族。

R83:提案(需维护者决策) —— .voters.yml 里的负权重能抹掉一条反对票,两侧同构

审计:权重解析是安全属性,而 voters.ts 只有 1 条断言

src 分支数 原有断言
voters.ts 3(if (obj) 短路 / typeof val === 'number' 过滤器 / !instanceof Object 抛错) 1(只测「正常文件能读」)

实测出来的 4 件事(不是推理)

  1. -1 权重不被任何一侧拦下。action 的 Voters 构造器只按 typeof val === 'number' 过滤(-1 是 number,原样进入);vote_merge.py 的 bad_weights 只查「非整数」(isinstance(v, int),-1 是整数,放行)。⇒ 两侧同构,没有口径分歧,但共有一个 fail-open。

  2. 后果:against_it += -vote * w 让反对票变成负数。 三名 1 权重赞成者 + 一名 -1 权重反对者:

    权重 numVoters forIt againstIt 赞成率 判定
    全 1 4 3 1 75.0% 拦下
    反对者 -1 3 3 −1 150.0% 放行

    一个负权重就能把一条 CHANGES_REQUESTED 从票数里减掉。 这与 PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 的形状完全一致(3 人赞成 + 1 人反对 = 66.7%、已卡 23 天)——若 Xiaobei09 的权重是 -1,PR [a0.13.0.0] feat: 卫星实体化与信号语义重构——信号与卫星·第一阶段 #50 会直接放行。

  3. 「超过 100% 的赞成率」本身就是可观测信号:vote_merge.py 的计票行会打出 赞成率=150.0%、反对=-1。运维看到 >100% 就知道 .voters.yml 混进了负权重(正常路径下这个值不可能出现)。

  4. -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 的同一道守卫有同样的漏洞。

提案(三选一,需维护者拍板)

  1. 两侧都改成「负权重即报错」——与 percentageToApprove 的处理一致,最干净;现网全部权重为 1,无实际影响。
  2. 只让 action 侧 Voters 构造器拒绝负权重,vote_merge.py 侧改为 w = w if isinstance(w, int) and w > 0 else 0(负数按未登记处理)——折中,一行改动。
  3. 保持现状,仅在文档里写明「权重必须为正整数」。

本 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 一行,构成单变量对照。
  • 回归台 102 → 117/117(新增 T27/T27d/T27b/T28/T29 共 15 条断言),反向变异 2 次全部咬住。
  • 修正一处测试名与实际不符(R82 那条教训的再次应验):原「空文件」用例的数据文件内容是 ---,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 不等于授权清理别人的工作分支。

改法:须维护者设置仓库变量才删:

Settings → Secrets and variables → Actions → Variables
新建变量 VOTE_MERGE_DELETE_FORK_BRANCH,值填 1

变量不存在时 ${{ 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 / 429 重试 同上;429 是唯一的可重试 4xx
其它 4xx(含 403 / 409) 不重试,原样冒泡 语义不同;409 必须原样到达 merge_pr 的分流诊断
幂等方法(GET/HEAD) 允许重试 重发安全
带 body 的 PUT(merge) 不重试 第一次可能已经合并成功、只是响应没回来;重发会撞 405/409 让诊断失真

上限 2 次(共 3 次请求),退避 min(2 × (n+1), 10) 秒。每次重试打一行可 grep 的指纹:

- API 重试 1/2(GET /repos/o/r/pulls/1,上次失败:502)

断言 [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 并打 ⚠️ 警告点名键名:

⚠️ .voting.yml 缺键 `minVotersRequired`(拼错?),按文档默认值 3 判定。
   缺键时若静默取更小的值会把闸门放宽,与「分歧一律往更严」相反。

为什么选兜底而不是 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 里的某一项:

- 计票: numVoters=2 赞成=1 反对=1 赞成率=50.0%
- 门槛: 赞成率>=100% 最少投票人>=3 窗口>=10 分钟
- 因早于最近一次更新而作废的票: carol

| 投票人 | 立场 | 权重 | 依据的 review |
| --- | --- | --- | --- |
| `Xiaobei08` | 赞成 | 1 | APPROVED,review #5332576449,提交于 2026-09-27T16:30:00Z |
| `by514` | 反对 | 1 | CHANGES_REQUESTED,review #9001,提交于 2026-09-27T16:20:00Z |
| `nobody` | 赞成 | 0 | 作者自投(action 无条件合成的 +1,不受时间过滤影响) |

不合并:投票人 2 < 门槛 3;赞成率 50.0% < 门槛 100%
合并结果: 未合并(投票人 2 < 门槛 3;赞成率 50.0% < 门槛 100%)

- 阻塞明细:
  · 人数: 还差 1 名登记投票人(门槛 3,现有 2)。未计入的登记投票人: `ARCloud217`, `carol`(票已作废), `rt334`
    · `carol` 的 APPROVED(review #5332500000,2026-09-26T16:31:00Z)早于本次更新,已作废
  · 赞成率: 反对票来自 `by514`(CHANGES_REQUESTED,权重 1)
    要放行只能由他重新 review 或 dismiss 那张票;本闸门无过期机制。

三项全过时打印「`- 阻塞明细: 无(人数 / 赞成率 / 窗口三项门槛全部满足)」」,免得维护者去找一个不存在的阻塞源。

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。
  • 回归台 170 → 214/214(新增 T38~T53 共 44 条断言)。
  • 反向变异 8 组全部咬住:去掉 fork 分支默认关(红 2)、恢复旧默认值 1(红 2)、只恢复窗口默认值 0(红 2)、删掉缺键警告(红 1)、4xx 也重试(红 3)、非幂等方法也重试(红 3)、去掉 urlopen 超时(红 1)、删掉整段阻塞明细(红 3)。
  • 第 8 组第一次零区分力(只改了 print("- 阻塞明细:") 那一行标题,没有任何断言锚在它上面,全套件依然 214/214);换成按块删除后 T43/T44 才变红。反向变异没红时,先确认它真的改到了有断言锚点的位置(R82 教训的又一次应验)。

本轮三条工具/方法教训

  • 在测试里「重写一份 mutant」不构成反向变异。第一版 [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

-def run_gh(args, timeout=60):
+def run_gh(args, timeout=60, stdin_data=None):
         p = subprocess.run(["gh", "api", *args],
+            input=stdin_data,
...
-        ])
+        ], stdin_data=json.dumps(payload))

而 Robin Review 在本 PR 上的审查(run 36724596029)里,行内评论 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)'
base(12b96ef,实际执行的那份) 0
本 PR head(ed9631b) 1

run_gh(args, timeout=60, stdin_data=None) 不传 ⇒ subprocess.run(input=None) ⇒ gh api --input - 收到空 body ⇒ GitHub 判定请求体不是 object。复现命令(返回与 CI 逐字相同的报错):

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({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 事件求值为空串;
  • action 侧(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],其中两步的关键条件是

if: github.event_name == 'push' && github.ref == 'refs/heads/test'

即「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,必须实现功能」):

拓扑 合并凭据 理由
同仓 PR GITHUB_TOKEN 权限完整,不需要外部凭据(少一把凭据更好)
跨仓 fork PR AUTOMERGE_TOKEN(Actions Secret) GITHUB_TOKEN 必被降级;且 PAT 的 push 会触发 on: push,正好保住 commitTest.yml 的预发布链路
跨仓且未配置 fail-closed:不合并 不拿一把注定 403 的令牌去打 API,直接报出配置位置

维护者需要做的唯一一步:

Settings → Secrets and variables → Actions → Secrets → New repository secret
名称 AUTOMERGE_TOKEN,值 = 一把对本仓有 contents: write 的令牌
(classic PAT 勾 Contents: read & write 即可,仓库归属本仓即可,不必属于 PR 作者)

⚠️ 必须写成 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 / 是草稿 / 门槛阻塞 / 合并被拒 / 已合并)。三条约束:

  • 零权限成本(不调任何 API,纯本地写文件);
  • 写不进去不算失败 —— Summary 是副本,日志才是主记录。写失败只打印一行提示并继续,绝不把一次诊断输出失败变成「合并失败」的红叉;
  • 不静默改语义 —— 门槛、返回值、stdout 格式均未改动。

交付物

  • vote_merge.py +171 / −57;voting.yml +35 / −1。
  • 回归台 214 → 265/265(新增 T54~T67 共 51 条断言)。
  • 反向变异 5 组全部咬住:跨仓也用 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 个元素的列表,症状看着像「凭据选错了」,真因是断言取了全量历史。「值不对」与「取错了范围」要分开查。
  • 变异体跑挂 ≠ 测试通过。MUT3 首版用正则删整行(连同缩进),破坏了 return 的缩进 → IndentationError → 变异体根本没被执行,输出为空一度看着像「全绿」。凡变异体产出空输出,一律先当失败查。

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

4 similar comments
@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

Xiaobei09 added a commit to Xiaobei09/Silicon that referenced this pull request Sep 28, 2026
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 行为。
@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

5 similar comments
@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@rt334

rt334 commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

逐条对照了 vote_merge.py 与 voting.yml。先说结论:这套闸门的设计取向我认同——fail-closed、分歧一律往更严、把 sha 钉死到复核过的头提交堵住 TOCTOU、权限下沉到 job 且只给 automerge 一个 contents: write、uses 钉 commit、checkout 显式钉 base.sha、以及用 review id(而非秒级 submitted_at)破平以对齐 action 的 result.set 覆盖语义——这些都是同类闸门里常踩的坑,这里都堵上了。

下面三条是建议,都不是阻塞项:

1. delete_head_branch 会去删贡献者 fork 上的分支(vote_merge.py:215-237)

api("/repos/%s/git/refs/heads/%s" % (repo, urllib.parse.quote(ref)), token, method="DELETE")

repo 取自 head.repo.full_name,跨仓 PR 时它就是作者自己的 fork。只要作者勾了 "Allow edits by maintainers",base 仓的 token 就能删掉对面 fork 上的分支——合并后顺手删别人的分支,有些作者并不希望(还要留着 rebase、或同一分支还挂别的 PR)。建议:跨仓时只打印提示,或加一个默认关闭的 workflow 输入(delete_head_branch)由维护者显式开启。

2. api() 没有超时(vote_merge.py:57-71)

urllib.request.urlopen(req) 默认无限等待。GitHub 侧静默挂起时 job 会一直挂着直到 workflow 上限,而闸门卡住会让 PR 一直 BLOCKED——这正是本 PR 想消灭的那类"没有事件来重新评估"。建议 urlopen(req, timeout=30),并对网络错误/5xx 做有限次重试(403/409 不重试,语义不同)。

3. 默认值的放宽方向(vote_merge.py:272-274)

need_voters  = _int_cfg("minVotersRequired", 1)
window_min   = _int_cfg("minVotingWindowMinutes", 0)

键缺失或拼错时门槛会放宽(人数 1、窗口 0)。当前 .voting.yml 三个键都在,所以现在没有实际影响;但按本文件"分歧一律往更严"的原则,建议缺键直接 sys.exit,或把默认值取成文档里的门槛(3 / 10)——这样"配置写错"和"票不够"就不会长得一样。


一个实测反馈(可以当作闸门语义的自检):拿当前 test 上的配置(voters.yml = Xiaobei08/09、rt334、ARCloud217、by514 各 1 票)对 PR #50 走一遍你这套语义——作者合成票 + ARCloud217 的 APPROVED = numVoters = 2(差门槛 1 人);而 Xiaobei09 在 9/4 留过一次至今未撤销的 CHANGES_REQUESTED,latest_reviews 按 id 取到它 → 计为 against → 赞成率不足 100%。也就是说 #50 在这个闸门下不会自动合并,除非那次 review 被重投或 dismiss。

如果这是预期行为(旧 review 不该静默放行),那建议在输出里补一行**"当前阻止合并的 review 来自谁"**——现在只有 未合并(<原因列表>),维护者还得去翻 review 列表才能定位到具体是谁的哪一票。

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@rt334

rt334 commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

再补两条触发面/链路上的(都是对着本 PR 的 diff 核过的,不需要外部知识):

1. ready_for_review 与本文件自己的警告直接冲突(voting.yml:367-370)

vote_merge.py 顶部的注释写得很清楚:

action 只认 payloadAction ∈ {opened, reopened, synchronize, closed},没有 default 分支,任何落不进这四个值的事件都会「什么都不做 → 空跑成功」,产出并非真实评估的绿色 check

而 on.pull_request_target.types 这次改成了:

-    types: [opened, synchronize, reopened, closed]
+    types: [opened, reopened, synchronize, ready_for_review]

ready_for_review 事件进来时 github.event.action == "ready_for_review",回落到 action 的 payloadAction 也就是这个值——恰好落不进那四个。而且这次新增的 payloadAction: ${{ github.event_name == 'pull_request_review' && 'synchronize' || '' }} 只覆盖 pull_request_review,对 ready_for_review 留空,于是仍回落到原始 action 值。这正是本 PR 要修的那类「空跑成功 → 污染 required check rollup」;草稿 PR 转为就绪时会踩到。建议 types 里把它改写成能落进真值分支的形式(比如只保留 opened/reopened/synchronize,就绪状态交给 pull_request 的其它触发),或确认 action 已新增该分支。

顺带:原 types 里的 closed 这次被删了,action 的 closed 分支从此不再触发(例如清理残留 check 的逻辑),描述里没提——如果是刻意为之,建议在注释里记一句。

2. GITHUB_TOKEN 的合并不会触发下游 workflow(GitHub 的事件抑制规则)

从「人工点合并」换成 vote_merge.py 调 REST merge 之后,由 GITHUB_TOKEN 造成的 push 事件不会触发 on: push 的工作流。若 test 分支上还有依赖 push 事件的下游流程(部署、release、通知之类),合并后会静默不跑。建议确认一遍 .github/workflows 里有没有 on: push 的目标,必要时改用 PAT/App token 触发,或显式 workflow_dispatch 接力。

3.(很小)复核结论目前只 print:脚本跑出的那张票数表在跨仓 PR 上仍不会出现在 PR 页面(action 用同一只读 token 也发不出评论)。顺手写一份 $GITHUB_STEP_SUMMARY 是零权限成本的——这样「票数自动更新」在 checks 页面就能看到,不必点进 run 日志。

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

1 similar comment
@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

@Xiaobei09

Xiaobei09 commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

⚠️ 自举死锁:本 PR 的 automerge job 现在跑不起来(已查明根因)

Robin/Voting 里那条 automerge FAILURE 不是票的问题,是路径问题。run 36387717976 的日志原文:

python3: can't open file '/home/runner/work/Silicon/Silicon/./.github/scripts/vote_merge.py'
          [Errno 2] No such file or directory
(日志尾行为一条 error 注解,内容为)Process completed with exit code 2.

原因

voting.yml 的 automerge job 把 checkout 显式钉在 base 上(注释写明「复核脚本必须来自 base,不能是 PR 作者提供的版本」——这个安全考虑是对的),而复核脚本也从 base 取。

但脚本还不在 base 上:

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 才走自动化。不需要为此改代码。

两点澄清

  1. 这个 FAILURE 不挡合并。 规则集只要求 buildJar + democracy(两者当前都 SUCCESS),automerge 不在 required 列表里,mergeStateStatus=UNSTABLE 而非 BLOCKED。
  2. 当前票况(用 vote_merge.py 的真实函数跑的,不是目测):head 5885cc3,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。)

@by514

by514 commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

/robin

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🏹 Robin

✅ Review done. I flagged 1 thing worth a look.

Push fixes whenever you like, then comment /robin for another pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏹 Robin

Heads up: this is a point-in-time review. Push fixes freely, then comment /robin whenever you want another pass.

⚠️ 1 Medium

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

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")

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/robin

1 similar comment
@by514

by514 commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

/robin

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🏹 Robin

✅ Review done. I flagged 16 things worth a look.

Push fixes whenever you like, then comment /robin for another pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏹 Robin

Heads up: this is a point-in-time review. Push fixes freely, then comment /robin whenever you want another pass.

⚠️ 6 Medium | 🔵 7 Low | 💡 3 Suggestions

Summary

本次 PR 新增了独立复核投票并自动 squash 合并的闸门脚本与 workflow,安全设计相当扎实:合并 sha 钉死规避 TOCTOU、严格 fail-closed 的退出码约定、action 引用钉 commit、权限下沉到 job 级、票数与门槛只从 base 检出、逐条枚举与上游 action 的口径分歧并配诊断输出。主要问题集中在「事件驱动 + 投票窗口」组合导致门闸在最常见路径下永不重评估、若干失败路径的诊断与真实成因不符(尤其是跨仓缺凭据这一几乎必然发生的路径),以及若干被自身防御性代码(bad_weights)覆盖却未纳入校验的输入形态。


📄 Findings Not Posted Inline

⚠️ 1 (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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

投票窗口未满时脚本只 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: medium

权重只校验了「是不是整数」(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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

跨仓缺凭据时 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)。

Comment thread .github/scripts/vote_merge.py Outdated
summary(["## 自动合并复核:合并被拒(HTTP %s)" % code, ""] + _SUM)
sys.exit(1)
report("已合并 %s(头提交 `%s`)" % (detail or "", sha))
p("- 贡献者分支清理: %s" % delete_head_branch(head, repo, token))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: medium

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 那张票;本闸门无过期机制。")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — 🔵 LOW · confidence: high

dropped_detail(因早于本次更新而作废的票明细)只在 num_voters < need_voters 分支里打印。若作废的票导致的是 total == 0 或赞成率不达标(这是更常见的场景),「我明明投了票为什么不算数」的答案就完全不出现在输出里,与本 PR 为 rt334 建议 4 增加阻塞明细的初衷相悖。

Recommendation: 把 dropped_detail 的打印提到三个阻塞分支之外(或在 reasons 非空时统一打印一次),与 reasons 的具体构成解耦。

Comment thread .github/scripts/vote_merge.py Outdated
return latest


def tally(reviews, voters, author, update_time=None, now=None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — 🔵 LOW · confidence: high

tally() 声明了 now=None 参数但函数体内从未使用(时间相关的判断都在 main 里做),属于误导性接口:读调用点会以为计票内部用到了「当前时间」。

Recommendation: 删除 now 参数并同步修改 570-571 行的调用(tally(reviews, voters, author, update_time)),或明确注释它是为将来窗口判定预留的。

@rt334

rt334 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

复核了当前 head(ed9631b),两点:

1. 上一轮我要求改的那一处已经修掉了

voting.yml 的 types 恢复为 [opened, synchronize, reopened, closed],并且文件里补了一条明确的警告注释
(说明为什么不能加 ready_for_review、以及将来若要支持必须同时把 payloadAction 表达式一起扩)。
这正是我那次 REQUEST_CHANGES 的唯一条件,条件已满足——票已回置为 approve,不再构成阻塞。

2. 新发现:本 PR 动了 mod.hjson 的版本号

-version: a0.12.4.4
+version: a0.12.4.5

这是个纯 CI PR,但 version 这一行与 #50(第一阶段) 撞线——#50 已把版本定为 a0.13.0.0:

合并顺序 结果
#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 现在仍合不进去——合并前建议确认一遍。

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

审查归因(Robin Review / workflow_run 二段式)

现象:本 PR 的 Robin Review 审查(run 36724596029)解析出 5 条发现,汇总 review 成功发出,但行内评论 0/5 成功。汇总 review 正文自报 共 5 条发现,0 条已发为行内评论。

日志中的报错:

warning: inline comment on .github/scripts/vote_merge.py:622 failed (rc=1):
{"message":"Invalid request.\n\nNo subschema in \"oneOf\" matched.\nFor 'oneOf/0', nil is not an object. ...

根因(已复现,四条证据交叉):review_from_diff.py 的 run_gh(args, timeout=60, stdin_data=None) 在行内评论处只传了 --input -,没有把 payload 以 stdin_data 送进 stdin,于是 subprocess.run(input=None) 让 gh api --input - 收到空 body,GitHub 判定请求体不是 object,逐条报错。用空 stdin 打真实 API 可得到与 CI 逐字相同的报错:

  • base 分支版本 grep -c 'stdin_data=json.dumps(payload)' → 0
  • 本 PR 版本 → 1

历史成因:--input - 与「payload 送 stdin」是分两次落地的半截修复,中间窗口内所有行内评论静默全败。本 PR 的 stdin_data=json.dumps(payload) 正是补上这半截(源码注释亦自述此事)。

为何本 PR 的修复当前不生效:Robin Review 是 workflow_run 两段式的第二段,actions/checkout@v4 未指定 ref,故执行的是默认分支(test)的代码,而非本 PR head。PR 合并后,下一次审查才会带上这 5 条行内评论。

验证方法(供复核者复现):

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({commit_id, path, line, body} 四字段)配真 commit_id 可成功返回 201,说明请求结构本身合法,问题仅在 stdin 为空。

@rt334

rt334 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

审查结论:当前 blocked,且闸门自身也会拒绝

按 head ed9631b 重新核了一遍票与 ruleset。先说两条与 PR 描述不一致的事实。

高:闸门当前只有 2 票(门槛 3)

.voters.yml(base 版)= Xiaobei08 / Xiaobei09 / rt334 / ARCloud217 / by514,各 1 票。

投票人 最新 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 最新结论:buildJar success、democracy success ✓
  • 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 —— 均已落地。

建议

  1. 先确认 blocked 是否来自 require_extra_approval_for_unattributed_changes(若是,需额外一名维护者 approve,或调整 ruleset / 用 bypass);
  2. 向 ARCloud217 或 by514 索取针对 ed9631b 的重投(rt334 的票已有效);
  3. mod.hjson 那一行移出本 PR,或注明合并时重放;
  4. 窗口 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 转红。
@by514

by514 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

/robin

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🏹 Robin

✅ Review done. I flagged 19 things worth a look.

Push fixes whenever you like, then comment /robin for another pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏹 Robin

Heads up: this is a point-in-time review. Push fixes freely, then comment /robin whenever you want another pass.

🚨 1 High | ⚠️ 6 Medium | 🔵 10 Low | 💡 2 Suggestions

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([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: medium

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: medium

门槛判定用整数运算 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: medium

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — 🔵 LOW · confidence: medium

注释剥离用 line.split("#", 1)[0],会把键名或值里合法出现的 # 一起截断(未加引号的键如 user#1: 3、带 # 的路径/分支式名字),静默得到一个错误的键。这属于「自己都没读对配置」却按可信配置继续判定,与本文件反复强调的 fail-closed 取向不一致。

Recommendation: 只把「第一个 #之前不含引号」的行视为注释(简单实现可要求 # 前只允许空白),或在文件头注明「不支持值内含 #」。

continue
try:
out[k] = int(v)
except ValueError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — 🔵 LOW · confidence: medium

429 与 5xx 走同一条线性退避(2s、4s),没有读 Retry-After 响应头。在被限流时这很可能在 GitHub 给出的冷却窗口结束前就把剩余重试耗尽,把瞬时限流变成确定性的 403/失败。

Recommendation: 在 HTTPError 分支取 e.headers.get("Retry-After"),存在则按它 sleep(封顶 60s),否则回退到现有退避。

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Current Voting Result Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
4 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

@by514

by514 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

/robin

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🏹 Robin

🔄 This review run was replaced by a newer Robin run.

No action needed — the newer run posts its own result when it finishes.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动审查结果(Robin Review / workflow_run 二段式)

本 PR 为 CI 侧改动(投票闸门脚本 + workflow 收紧 + 修复行内评论 payload 未送 stdin),mod 本身无内容变更,版本号 a0.12.4.4→a0.12.4.5 递增正确。发现一处可能导致跨仓 PR 全部失败的 API 用法问题(用 base 仓路径查 fork 的头提交 SHA),以及若干诊断一致性与 fail-closed 路径未输出指纹的问题。

共 5 条发现,0 条已发为行内评论。

@by514

by514 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

/robin

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🏹 Robin

✅ Review done. I flagged 6 things worth a look.

Push fixes whenever you like, then comment /robin for another pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏹 Robin

Heads up: this is a point-in-time review. Push fixes freely, then comment /robin whenever 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():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`,把一次
# 成功的删除报成「清理异常(不影响合并)」——分支真删了,维护者却看到

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' || '' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Current Voting Result Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
4 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

两处都是文档/注释修正,不改任何运行时行为。

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,而是把修复落到实际生效的产物上,
因此刻意单独成一笔。
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Current Voting Result Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
4 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

上游 `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。
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Current Voting Result Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
4 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动审查结果(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` 唯一性)。
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Current Voting Result Voting
Vote on this by posting a PR with approval or needs changes.

Vote Summary:
4 👍
0 👎

Acceptance Criteria:

  • 100% of weighted votes needs to be to approve
  • 3 minimum # of unique voters required
  • at least 10 minutes of voting

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动审查结果(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 条已发为行内评论。

@Xiaobei09

Copy link
Copy Markdown
Collaborator Author

/review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants