# 批量合并 PR 触发 runner 抢占性取消 + 自审批脚本默默跳过 CI 检查

**日期**: 2026-04-29
**类别**: CI / 自审批合并 / runner 资源
**严重度**: 高（合并的 5 个 PR 里 4 个的 CI 被取消，但因为脚本 relax 了 `enable_status_check`，没人发现）

## 现象

一轮批量合并 5 个 PR 到 develop（间隔每个 1-2 分钟）。事后查 commit status：

| PR | sha | quality-gates | deploy-dev |
|---|---|---|---|
| #163 docs | c14f18d4 | cancelled | cancelled |
| #157 IAM | 61b74fa1 | cancelled | cancelled |
| #158 auth | 3321875f | cancelled | cancelled |
| #162 test | d7698471 | **success** ✅ | **success** ✅ |
| #155 CI | e45e50e5 | cancelled | cancelled |

只有 #162 完整跑过 CI，因为它后面恰好没紧跟下一个合并。其他 4 个的 CI 被后续 push 触发的新 run 抢占取消。

更严重的是：**所有 5 个合并都"成功"了**——`gitea-pr-merge.py` 在 relax 三字段时把 `enable_status_check` 也关了，Gitea 不再要求 status check 通过。CI 被取消没拦住合并，也没人事后查。

## 根因（两层叠加）

### 1. Self-hosted Gitea Actions runner pool size = 1

参 ERR-20260427-018。`runs-on: ubuntu-latest` 在本仓库映射到 act_runner 的单一 worker。每个 push develop 触发 quality-gates + deploy-dev 两个 workflow，跑一次至少 5-8 min。AI 节奏下 1-2 min 合一个 PR 远快过 CI。

**Gitea Actions 的取消机制**：新 push 到同分支时，runner 会 cancel 同 workflow 上正在运行的旧 run（即使 yaml 里没写 `concurrency:`）。跨 PR 连续 push 等于自相残杀。

### 2. `gitea-pr-merge.py` 默认 relax `enable_status_check`

脚本原版的"三字段 dance"为了"省事"把 status check 一起关了——本意是解决 `hongwei.zhang` 自审批问题（`required_approvals=1` 卡死）+ outdated branch 阻断（`block_on_outdated_branch=true`），但顺手把 CI 门禁也拆了。

合并瞬间 Gitea 不查 status，合完立刻 restore。CI 跑成什么样脚本根本不关心，调用者也看不到——直到事后人工拉 commit status 才暴露。

## 修复

### 立即（已做）

改 `scripts/ops/gitea-pr-merge.py` 的默认行为：

1. **合并前 poll PR head 的 commit statuses**，等所有 status 进入终态：
   - 全 `success` / `skipped` → 继续合
   - 任何 `failure` / `error` → 立即退出（exit 4），打印失败的 context
   - `pending` → 等到超时（默认 30 min）
2. **默认只 relax `required_approvals` + `block_on_outdated_branch`，保留 `enable_status_check`** —— 即使 poll 通过，也让 Gitea 自己最后再校验一道。
3. 加 `--bypass-ci` 显式 opt-in：跳过 poll + 同时 relax `enable_status_check`，仅用于纯 docs / 紧急 hotfix / CI 系统本身故障。
4. merge 调用对 `"try again later"` 自动 retry（mergeable 缓存陈旧，原本就要 sleep 30s）。

副带好处：合并前等 CI 过 = 只有"已经跑完"的 sha 才被合，自然解决 runner 抢占问题——没有"半成品 push 被新 push 干掉"的窗口。

### 后续（待做）

- **runner pool size**：长期看 1 个 worker 不够 AI 节奏，但加 worker 涉及服务器运维（ERR-20260427-018 已记）
- **workflow 加 concurrency 显式控制**：让"取消"行为可见、可配，而不是 runner 隐式抢占
- **合并节奏**：批量合并时哪怕脚本等 CI 过了，连续 push 仍可能造成 deploy-dev 后台任务排队。考虑加最小间隔。

## 启示

- **"自审批 dance"绝不能顺手把 status check 也 dance 掉**。三字段里只有 `required_approvals` 是为了解决"自己不能批自己"，另外两个不是同一类问题，要分别处理。
- **Self-hosted CI runner 抢占是默认行为，不是 yaml 配置**。Gitea Actions 在新 push 时取消旧 run 没有显式开关，唯一的对抗办法是放慢 push 节奏或加 runner。
- **AI 高速合并下，"合并 OK"不等于"代码 OK"**。人工时代一天合 1-2 个 PR，CI 失败立刻被人看见；AI 一小时合 10 个，CI 报告堆积无人看。**任何"省时间"的脚本都要先问：它跳过了什么人工的检查？**
- **commit status API 把 cancelled 报告为 `failure`** —— 排查时容易误判为真实失败。判断真相要拉 actions runs API 看 conclusion。

## 关联

- ERR-20260427-018：Gitea 多 workflow 在 single-runner 上自动串行（同根因，不同表现）
- ERR-20260429-001：Gitea 主机 CI 高负载时 SSH/HTTP/API 全部 timeout
- 本批 PR：#155 / #157 / #158 / #162 / #163 / #177
