# Gitea Actions 平台语义参考

> **版本**: v1.0
> **最后更新**: 2026-05-16
> **适用范围**: 全体研发（含 AI 助手协作），尤其是写 / 改 / 调试 `.gitea/workflows/*.yml` 和 `scripts/ops/` 内 Gitea API 脚本时
> **沉淀来源**: 2026-04 → 2026-05 累计 28 条 `.learnings/` 反复踩坑

---

## 0. 为什么需要这份文档

Gitea Actions 的 yaml schema、API 字段、触发模型与 GitHub Actions **大体兼容但不完全等价**，且 Gitea 1.25 / 1.26 在关键关键字（如 `concurrency:`）上有版本差异。**这些差异普遍是静默的**——yaml parser 不报错、API 不告警、CI runner 不警告——只有事故复发后回头查才看得见。

本文档把项目反复踩过的 28 类语义陷阱集中沉淀，作为：

- 写新 workflow / 改触发条件前的**事实源**
- 调试 "CI 莫名其妙" 类问题时的**第一查询入口**
- AI 助手生成 Gitea API 调用代码前的**对齐参考**

**修法层面**：本文档不重述 28 条 learning 的"问题与修复"——所有代码层修复（`ai-review-runner.sh` 加 diff hash 幂等、`gitea-pr-merge.py` 删除、`gitea promote` CLI、`startsWith` 防自触发等）都已在仓库落地。本文档是**给后人看的"为什么这样设计 + 怎么用"** 参考。

---

## 1. 触发事件矩阵

不同 event 触发时，**workflow file 来源**与**代码 checkout 来源**不一致。这是最常踩的语义陷阱。

| Event | workflow file 来源 | actions/checkout 默认 SHA | 写 commit status |
|---|---|---|---|
| `pull_request: opened/synchronize/ready_for_review/reopened` | **PR head ref** 的 yml | PR head SHA | ✅ 写到 PR head |
| `issue_comment: created` | **default branch (develop)** 的 yml | default branch HEAD | ✅ 写到 default branch HEAD（**不是 PR head**） |
| `workflow_dispatch` | 调用时指定 `ref` 的 yml | `ref` 指定的分支 HEAD | ❌ **不写 commit status** |
| `push` | push 目标分支的 yml | 推送的 SHA | ✅ 写到 push SHA |
| `schedule` (cron) | default branch 的 yml | default branch HEAD | ✅ 写到 default branch HEAD |

### 关键推论

1. **评论 `/ai-review` 不会重跑 PR head 的代码**——它跑的是 develop 上的 workflow + develop HEAD 的代码。要重跑 PR head 必须推一个 commit（或 `synchronize` 触发）。
2. **`workflow_dispatch` 写不了 PR 的 required check**——dispatch 完成后 PR 上不会出现 `(workflow_dispatch)` 后缀的 status。要补 required check 必须走 `pull_request` 触发。
3. **"required check 修自己"是真死锁**：required check 本身有 bug，修复 PR 引用了该 check 的代码。PR 触发 → 用 PR head 的 fixed code → 写 `(pull_request)` status → 满足 required ✅；如果走 `workflow_dispatch` 跑同一份代码 → 不写 status → 永不满足。
4. **修 ai-review workflow 自己**：必须靠"先合一个 dry-run 不阻断的状态"，让修复 PR 在 ai-review 跑挂时仍能合并；正常状态下，要么开 escape branch 直推（项目禁止），要么 evaluate `do-not-auto-merge` label + 人工合。

### 工作流文件来源混淆的诊断

如果你看到一个 run 让你困惑（commit 在 PR head，但 yml 行为像旧版），先检查 run 的 `display_title` 和触发 event：

```bash
GET /api/v1/repos/<owner>/<repo>/actions/runs/<id>
# 看 .event 字段：
#   pull_request → 用 PR head 的 yml
#   issue_comment → 用 default branch 的 yml
#   workflow_dispatch → 用调用时指定 ref 的 yml
```

---

## 2. Commit Status 与 Required Check

### 2.1 status context 命名规则

Gitea 给 commit status 写的 `context` 字段固定格式：

```
<workflow-name> / <job-name> (<event-name>)
```

例：
- `ai-review / ai-review (pull_request)`
- `quality-gates / build-check (pull_request)`
- `quality-gates / verify-agent-assets (pull_request)`

**event 后缀决定能否当 required check 用**：

| event | 后缀 | 能当 required check |
|---|---|---|
| `pull_request` | `(pull_request)` | ✅ |
| `workflow_dispatch` | (无 status) | ❌ |
| `issue_comment` | `(issue_comment)` 且写到 default branch HEAD | ❌（PR 上看不到） |
| `push` | `(push)` 写到 push SHA | ⚠️ 不在 PR 视图里 |

**实操含义**：required check 配置时必须用 `(pull_request)` 后缀；分支保护 API 字段：

```bash
PATCH /api/v1/repos/<owner>/<repo>/branch_protections/<branch>
{
  "status_check_contexts": [
    "ai-review / ai-review (pull_request)",
    "quality-gates / build-check (pull_request)",
    ...
  ]
}
```

### 2.2 `enable_status_check` 卡 false 风险

旧脚本 `gitea-pr-merge.py`（**已删除**）走"relax 分支保护字段 → merge → restore"。任何一次 restore 失败 / 脚本中途被杀，`enable_status_check` 字段就卡在 `false`，**contexts 列表照样更新但开关 off 时新 context 不生效**。

**当前状态**：`gitea-pr-merge.py` 整文件已删（PR #304），代之以：
- **AI 自动合**（feature/* → develop）：**两台 dedicated runner 上的 systemd timer + auto-merge daemon**（错峰 2 min），AIBot token 走标准 merge，不绕分支保护。架构详 [`docs/ops/03-auto-merge-daemon.md`](../ops/03-auto-merge-daemon.md)。**应急手动入口**保留 `.gitea/workflows/auto-merge.yml` 的 `workflow_dispatch:`
- **人工合**：在 Gitea web UI 由另一位团队成员 Approve + Merge
- **env-promote PR**：`gitea promote uat|prod` CLI，自动 self-approve（合并动作机械，**例外政策**）

**绝不要**：写"临时改分支保护 → 合并 → 改回"模式的脚本，会卡 `enable_status_check`。

### 2.3 同 SHA 多 PR 的 status 覆盖

Gitea commit status 主键是 `(sha, context_name)`，**后写覆盖前写**。多个 PR 共用同一 head SHA（如同一分支同时 PR 到 develop 和 staging）时，PR UI 显示的是该 SHA 当前每个 context 的最新状态，**不区分由哪个 PR / run 写入**。

诊断真相：

```bash
# 看具体 run 的 job conclusion
GET /api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/jobs

# 看该 SHA 全部 status 历史（按 updated_at 排序判断哪次 run 写的）
GET /api/v1/repos/<owner>/<repo>/commits/<sha>/statuses?limit=50
```

测试 `if:` 条件时**用两个不同 SHA**（每个测试 PR 单独推空 commit 拉 SHA），避免覆盖陷阱。

---

## 3. Concurrency 与并发控制

### 3.1 Gitea 1.26+ 才支持 `concurrency:`

GitHub Actions 的 `concurrency:` 关键字在 Gitea 1.26.0（2026-04-18 release）才合入（[Gitea PR #32751](https://github.com/go-gitea/gitea/pull/32751)）。1.25 及更早版本 **yaml parser 静默忽略**，不报错也不警告。

**项目当前 Gitea 版本**：1.26.1（PR #277 升级，含 OOM / runner 兼容 saga，详见 `.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md`）。

**经验**：凡是依赖 CI 平台特性做安全保证的，**必须在第一次启用时手动验证生效**（人为制造冲突看是否串行）。无法验证的就不要依赖，改用**平台无关的机制**（文件锁、环境变量、外部协调服务）。

### 3.2 共享测试容器用 flock 替代 concurrency

`quality-gates::backend-integration` 和 `deploy-uat::post-deploy-regression` 共用 `ffoa-test-postgres` / `ffoa-test-redis` 全局容器。即使升级到 1.26 支持 `concurrency:`，平台无关的 flock 锁更可靠：

```bash
exec 200>/tmp/ffoa-test-containers.lock
if ! flock -x -w 1800 200; then
  echo "❌ 1800s 内未取到锁，疑似前序 job 卡死"
  exit 1
fi
# ... 跑测试 ...
```

详见 `testing/scripts/run-backend-integration.sh`。

### 3.3 批量合并触发的 runner 抢占式取消

Gitea Actions 的取消机制：**新 push 到同分支时，runner 会 cancel 同 workflow 上正在运行的旧 run**（即使 yaml 里没写 `concurrency:`）。批量合并 5 个 PR 到 develop（间隔 1-2 分钟）会让前 4 个 CI 全部被后续 push 抢占取消。

**当前缓解**：
- AI 自动合（host 上 auto-merge daemon，错峰 `*:0/5` + `*:2/5`）effective 2-3 min 一扫 + 每 tick 最多合 1 + update 1 + 严格 verdict=pass + 所有 required check 通过 → 不会"一分钟一个" 抢占
- 人工合通过 Gitea web UI，跨 PR 节奏天然慢
- **不要写脚本批量 merge**

---

## 4. Runner 与负载

### 4.1 `capacity: 0` ≠ 不接活

act_runner v0.6.0 / v1.x 的 `capacity: 0` 实测**只是降低优先级**，不是排除——**软优先级**。

| 行为 | 期望 | 实测 |
|---|---|---|
| 重型 job（build-check）路由 | 走 cap > 0 的 dedicated-runner-1 / -2 | ✅ |
| 轻量 job（verify-agent-assets）路由 | 跳过 cap=0 的 workspace-runner | ❌ 仍可能落到 |

**想让 runner 真正不接活**：必须 `sudo systemctl stop act-runner.service`，光改 config 不够。

### 4.2 Gitea host 永远不跑 build job

Gitea 主机（43.130.59.228）总内存只有 **1.9 GB**：
- Gitea web 占 ~220 MB
- 几乎没余量给 build-check（next.js build ~3-4 GB）或 backend-integration（起 Postgres + Redis + jest）

**事故记录**：2026-05-01 一次批量 push 触发 OOM Killer 把 Gitea web + SSH + runner 全杀，整机 reboot（kernel panic）。

**当前状态（2026-05-18）**：
- workspace-runner (Gitea host) `systemctl disable + stop`，长期 offline
- 所有 CI 走 **dedicated-runner-1 (43.166.205.48, 4c16G) + dedicated-runner-2 (43.166.182.155, 8c30G)** 双主力 host runner（每台 capacity=3，详 `docs/ops/02-gitea-config.md § 3`）
- workspace-runner 不会再启动

**未来如果需要更多兜底 runner**（双 dedicated 都挂了）：
- 加 systemd cgroup 内存上限（`MemoryMax=512M`）防止 OOM 拖死整机
- 或不同 label（如 `ubuntu-light:host`）只接超轻量 job

### 4.3 升级前必查资源

`free -h | head -2  # 至少 4 GB 才升 minor / major`
`df -h              # 至少 5 GB free`
`uptime             # load < 2`

单服务部署 + 1.9 GB 这种 spec 升级**任何**软件都是高风险——必须先扩容再升级。

---

## 5. API 字段与命名陷阱

### 5.1 `/actions/runs` 字段：`started_at` 不是 `created_at`，`conclusion` 不是 `status`

普通资源（PR、issue、commit）都有 `created_at`。`/actions/runs` 的 run 对象**没有 `created_at` 字段**：

| 字段 | 含义 |
|---|---|
| `started_at` | 启动时刻 |
| `completed_at` | 结束时刻 |
| `status` | 对所有完成的 run **永远是 `"completed"`** |
| `conclusion` | **真实结果**：`success` / `failure` / `cancelled` / `skipped` |

**典型 bug**：

```python
# 错（静默 0 命中）
ts = _safe_parse(r.get("created_at"))
if r.get("status") == "failure": ...

# 对
ts = _safe_parse(r.get("started_at")) or _safe_parse(r.get("completed_at"))
if (r.get("conclusion") or "").lower() == "failure": ...
```

### 5.2 URL 里的 run 编号是 `display number` 不是 API id

```
http://43.130.59.228/FFAIWorkspace/workspace/actions/runs/1329/jobs/5
```

`1329` 是 `run_number`（仓库范围内 PR/push 触发递增），**不是 `id`**（API 内部主键，全局递增）。

```bash
# 错（静默命中另一个 run）
GET /api/v1/repos/.../actions/runs/1329
# → 返回的是 API id=1329 的 run（display number=956）

# 对
GET /api/v1/repos/.../actions/runs?limit=50
# 在结果里找 run_number=1329，再用它的 id 查详情
```

API 内部 id 总是远大于 run_number，混用不会报错，**会静默命中另一个 run**。

### 5.3 Secret / PAT 不透明

Gitea 设计上**不可读 secret value**。

```bash
GET /api/v1/repos/.../actions/secrets
# 每个 secret 只有 name + created_at + updated_at；value 字段不存在
```

且 **写入后 `updated_at` 不刷新**——`PUT /actions/secrets/{name}` 返回 `204 No Content` 表示成功，但仓库 secrets 列表里的 `updated_at` 字段不会更新。

**唯一成功凭据**：HTTP code（`201 Created` 首次新建 / `204 No Content` 覆写已有）。**别看 list 输出判断"是否刚才覆写成功"**。

### 5.4 PAT 自己不能列自己的 token

```bash
GET /api/v1/users/{me}/tokens
# 用 token auth → 401
# 必须用 basic auth (password + 2FA)
```

**Gitea PAT 只能用 basic auth（password + 2FA）来管理 token**——防止 compromised token 复制扩散的安全特性。

**含义**：无法用 API 查"当前 token 的 scope"。**唯一办法**：调一个需要某 scope 的接口看是否返 403。

### 5.5 `GITEA_*` / `GITHUB_*` 前缀的 secret 名不让用

```
PUT /api/v1/repos/.../actions/secrets/GITEA_API_TOKEN_FOR_AI
→ 400 "invalid variable or secret name"
```

Gitea Actions 把 `GITEA_*` 和 `GITHUB_*` 留作内置 env var 命名空间。**改名为 `AI_REVIEW_GITEA_TOKEN` 即可**——workflow yml 里 env 字段用任意名字消费 secret 都行。

### 5.6 `/branches` API 已 resolve `commit.author.username`

不要自己 parse `commit.author.email` 当 Gitea username。Gitea 在接受 push 时已经做了 email → user 映射：

```json
{
  "commit": {
    "author": {
      "name": "chentao.jia",
      "email": "4+chentao.jia@noreply.localhost",
      "username": "chentao.jia"        ← Gitea 已 resolve
    }
  }
}
```

映射不到的（CI bot、NOPASSWD 自动 push 等）这个字段是空字符串 `""`，可用来识别 orphan 提交。

---

## 6. Workflow `if:` 守卫

### 6.1 `contains()` 是子串匹配，不是 `startsWith()`

**踩坑实例**：`.gitea/workflows/ai-review.yml` 早期用 `contains(github.event.comment.body, '/ai-review')` 想"用户敲 `/ai-review` 触发"。AIBot 自评论里写文件路径 `scripts/ops/ai-review-runner.sh` —— 子串里恰好有 `/ai-review` → 守卫通过 → 自触发循环。

**修法**（已在 ai-review.yml 落地）：

```yaml
if: github.event_name == 'issue_comment' && 
    startsWith(github.event.comment.body, '/ai-review')
```

**经验**：所有 `contains()` 用于"用户敲了某个 slash command"判定时，**默认改用 `startsWith()`**，且评论 body 第一个非空字符必须是 `/`。

### 6.2 `issue_comment` 的 `head_sha` ≠ PR head

`issue_comment` 触发时，`github.event.comment` 的 `head_sha`、checkout 默认 ref 都指**default branch HEAD**，不是评论所在的 PR head。写到 commit status 时也是写到 default branch HEAD，**PR 上看不到**。

**含义**：评论 `/ai-review` 不能给 PR 补一个 required check 通过——必须靠 `pull_request` 触发。

---

## 7. PR Auto-close Keywords

Gitea（跟 GitHub 一样）只识别**特定关键词** + `#N` 才会在 PR/commit merge 时自动关 issue。

### 支持的 closing keywords（不分大小写）

- `Closes #310` / `Closed #310` / `Close #310`
- `Fixes #310` / `Fixed #310` / `Fix #310`
- `Resolves #310` / `Resolved #310` / `Resolve #310`

### 不算

- ❌ Markdown link：`[#310](url)`
- ❌ 纯文本引用："针对 #310"、"#310 收尾"、"针对 W20 周报（issue #310）"
- ❌ Commit message 里的 `Closes`：**Gitea 只读 PR body**
- ❌ 标题里的 `#310`

### 一次关多个

```markdown
Closes #310, fixes #311 and resolves #312
```

### 跨仓库

```markdown
Closes owner/repo#310
```

### 规则

给 issue 做实施的 PR 必须在 description 顶部用 closing keyword（详见 [`13-pr-description-spec.md`](./13-pr-description-spec.md)）。

---

## 8. AI Review 专项约定

### 8.1 触发面 + 计算面双层去重

**触发面**（`.gitea/workflows/ai-review.yml`）：

```yaml
types: [opened, synchronize, ready_for_review, reopened]
```

`synchronize` 让 push 新 commit 自动重跑——避免"修完 fix 自己 required check 仍 block"的死锁。

**计算面**（`scripts/ops/ai-review-runner.sh`）：

```bash
CURRENT_DIFF_HASH=$(md5sum < "$DIFF_TMP" | cut -d' ' -f1)
# 从上轮 state header 解出 PREV_DIFF_HASH
if [ "$CURRENT_DIFF_HASH" = "$PREV_DIFF_HASH" ] && [ pull_request 触发 ] && [ 无 FORCE ]; then
  echo "PR diff hash 未变化，跳过"
  exit 0
fi
```

合 develop 进 PR / rebase / 无内容 force-push 都自动跳过 claude——避免 ~47% PR 跑 ≥2 次浪费。

### 8.2 PR body 必须注入 prompt

AI Review 看不到 PR description 时会**反复指控作者已 ack 的项**。`scripts/ops/ai-review-runner.sh` 第 70 行起提取：

```bash
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
PR_BODY_LEN=${#PR_BODY}
if [ "$PR_BODY_LEN" -gt 12000 ]; then
  PR_BODY="${PR_BODY:0:12000}..."
fi
```

12000 字符截断（实测 PR body 多在 2-8K，覆盖绝大多数）。

**作者 rationale 处理规则**（注入 prompt 的指令段）：
- 合理解释 → 不列 finding；如需提示降级为 suggestion 并加 "PR body 已 ack：<原话>" 前缀
- 牵强解释 → 仍列 finding，message 必须引用 body 原话说明为何 rationale 不成立
- body 是作者**声明**，不是契约面事实源——冲突时仍以 `docs/` 和代码为准

### 8.3 Dry-run 渐进开关上线模式

任何**可能阻断关键路径**的自动化（AI review / lint / 安全扫描 / 合规检查）适用三步走：

**Step 1：影子模式（Dry-run）**

```yaml
env:
  TOOL_DRY_RUN: '1'   # 工具内部读，不调写接口
```

工具内部规范：
- 不调 Gitea/GitHub API 的写接口（POST/PUT/PATCH）
- **可以**调读接口（拉 PR diff 等输入）
- 把"本来要写的内容"完整打到 stdout，加 `[DRY RUN]` 前缀
- exit code 跟正式跑一样——转正只需删 dry-run 开关

**Step 2：观察期**

跑 1-2 周或 N 个 PR：
- 高频工具（每个 PR 都跑）：≥ 30 个 PR
- 低频工具（PR → staging/production 跑）：≥ 5 个 PR

观察**输出质量**（不是有没有跑出来，是跑出来的内容对不对）+ **误报集中点** + **漏报**（故意提一个有真问题的 PR）+ **耗时 / 资源**。

**Step 3：转正**

删 dry-run 开关；同时 PATCH 分支保护把 context 加入 `status_check_contexts`。

---

## 9. 共享代码与 CLI

### 9.1 `scripts/ops/_gitea_api.py` 共享 helper

提供 `Api` class / `get_token()` / `detect_repo()` 等。所有 ops 脚本（`weekly-review.py` / `weekly-retro-issue.py` / `sweep-remote-stale.py` 等）通过 `from _gitea_api import ...` 复用。

**禁止在新脚本里重抄一遍 Api class**。如发现新需求（比如 pagination 工具），加到 `_gitea_api.py` 里供所有脚本复用。

### 9.2 `scripts/ops/gitea` CLI

AI / 团队成员日常 Gitea 操作**优先用该 CLI**，而不是手写 curl + python heredoc。子命令：

| 命令 | 用途 |
|---|---|
| `gitea issue <num>` | 看 issue 详情 |
| `gitea issue list --label X` | 按 label 列 issue |
| `gitea issue comment <num> --body "..."` | 发评论 |
| `gitea label add/remove <num> <name>` | 加/删 label（**name-based**，不硬编码 id） |
| `gitea pr <num>` | 看 PR 详情 |
| `gitea promote uat\|prod [--dry-run]` | 环境升级（FF-only，写死 merge style） |

支持 `--json` 机读模式、`--dry-run`、结构化错误。详见 [`15-cli-design-spec.md`](./15-cli-design-spec.md)。

**`gitea promote` 的关键设计**：
- `Do=fast-forward-only` 写死，无法被改
- compare API 拒绝 `base 比 head 多 commit`
- CI 全绿是硬门槛（combined status `success`）
- 自动 self-approve（**例外政策**：env-promote PR 允许自合，详见 §10）
- post-merge 校验 `base tip == head sha`

### 9.3 PR auto-merger（feature/* → develop）

**两台 dedicated runner 上的 systemd timer + daemon**（`scripts/ops/auto-merge-develop.py`），错峰 `*:0/5` + `*:2/5`（effective 2-3 min 反应 + 双机 HA）。`.gitea/workflows/auto-merge.yml` 仅保留 `workflow_dispatch:` 应急入口（两台 daemon 同时挂时人能从 Gitea UI 触发）。

每 tick 评判 7 条件 + 分类执行（**每 tick 最多 1 merge + 1 update**，防 CI 雪崩）：
- **7/7 满足 + mergeable=True** → 双重 read 校验 → 合（squash）+ 在 PR 留审计评论
- **7/7 满足 + mergeable=False/None** → `POST /pulls/N/update?style=merge`（解决 PR 队列：PR-A 合后 PR-B 自动追上 develop）
- **其他** → skip

**严格 verdict=pass** + 所有 required check 通过 + 无 `REQUEST_CHANGES` + 无 `do-not-auto-merge` label。AIBot 不是 PR 作者，不违反 no-self-merge 规则。完整架构 + 故障模式见 [`../ops/03-auto-merge-daemon.md`](../ops/03-auto-merge-daemon.md)。

**作者逃生口**：想自己再补 commit / 想人工 review 的 PR，加 label `do-not-auto-merge`。

**配套配置**：`develop` 分支保护 `block_on_outdated_branch=True`（2026-05-19 起，跟本 daemon 同 PR 翻），把"PR-B 必须基于含 PR-A 的最新 develop 才能合"变成硬门禁。daemon 的 `update_branch` 自动消化 outdated 状态，PR 作者无感。详见 [§10.1](#101-默认规则禁止自合)。

---

## 10. No-self-merge 政策与例外

### 10.1 默认规则：禁止自合

**分支保护实际配置**（2026-05-19 API 实测）：

| 分支 | `required_approvals` | `enable_status_check` | required check 数 | `block_on_outdated_branch` |
|---|---|---|---|---|
| `develop` | **0** | True | 6 | **True**（2026-05-19 起，配合 auto-merge daemon 的 auto-update）|
| `staging` | **1** | False | 0 | **True** |
| `production` | **1** | False | 0 | **True** |

**develop = 0 approvals + `block_on_outdated_branch=True` 的设计理由**：
- AI auto-merge（AIBot token）走 standard merge，**不需要 PR 作者 self-approve**
- 真正的合并门禁是 `enable_status_check=True` + 6 个 required check（包含 `ai-review (pull_request)` verdict）
- AIBot 不是 PR 作者，不违反 no-self-merge 规则
- 人工兜底合并时，要求另一位团队成员手工 Approve → Merge（约定级，非 API 强制）
- `block_on_outdated_branch=True` 把 "PR-B 必须基于含 PR-A 的最新 develop 才能合" 变成硬门禁——防止 PR-A + PR-B 各自单独 CI 过、合在一起逻辑冲突的"幽灵 bug"。auto-merge daemon (docs/ops/03-auto-merge-daemon.md) 的 `update_branch` 自动消化 "outdated" 状态，对人无感

**staging / production = 1 approval + `block_on_outdated_branch=True` 的设计理由**：
- `gitea promote` CLI 走 **self-approve**（**例外政策**，详见 §10.2）
- 不开 `enable_status_check`——promote 是机械动作（FF-only base==head），合并门禁是 CLI 自己 enforce
- `block_on_outdated_branch=True` 强制 promote 前 head 必须是最新

**不允许通过脚本临时放宽这些字段**——旧 `gitea-pr-merge.py` relax-dance 模式已在 PR #304 删除（详见 `.learnings/2026-05-11-no-self-merge-policy.md`）。

> 📌 **事实源 vs 实测**：以上表格基于 2026-05-16 API 实测，与 CLAUDE.md / AGENTS.md「PR 合并规则」段同步。后续若分支保护配置变更，**两份入口文档（CLAUDE.md + AGENTS.md）+ 本文档表格**必须同步更新。

**合并路径**（按 PR 类型区分）：

1. **feature/* → develop**：
   - **AI 自动合（默认）**：`.gitea/workflows/auto-merge.yml` 每 5 分钟扫，verdict=pass + 全 required check 通过 + 无 REQUEST_CHANGES + 无 `do-not-auto-merge` label → squash 合
   - **人工兜底（备用）**：自动合判定不过时，另一位团队成员在 Gitea web UI 点 Approve → Merge
2. **develop → staging / staging → production**：`gitea promote uat|prod` CLI（FF-only，详见 §11）
3. **任何 PR 作者**：**严禁自合自己的 feature PR**（env-promote 例外见 §10.2）

### 10.2 例外：env-promote PR 允许 self-approve

`develop→staging` / `staging→production` 这类 PR 由 `gitea promote` CLI 创建并自动 self-approve，因为：

| 维度 | feature PR | env-promote PR |
|---|---|---|
| 合并动作 | **决策**（合不合？什么 style？squash 信息丢失？） | **机械**（FF base==head，无变量） |
| review 价值 | 高（防止逻辑错误） | 低（commits 早在 develop 阶段已 review 过） |
| 谁能调用 | 任何 PR 作者 | `merge_whitelist`（chentao.jia / hongwei.zhang） |
| 风险窗口 | review 漏 = 合错代码 | 几何不变（FF-only 写死 + base==head 校验） |

**feature PR 仍严禁自合**——这条不能松。

---

## 11. FF-only 环境升级

### 11.1 为什么 FF-only

`develop → staging → production` 升级合并不能用 squash（squash 把多个 commit 压成一个，新 hash 跟 develop 上对不上，下次升级 base 不是 head 祖先 → 几何不可达）。

**FF-only 保证**：staging / production 的 git 历史是 develop 的真子集（指针推进，不引入新 commit），所有环境升级是"指针前移"而非"合并"。

### 11.2 Gitea 的 FF-only 限制

Gitea 的 FF-only 是**仓库级**配置，**没有 per-target-branch 强制 merge style** 字段：

```json
GET /api/v1/repos/<owner>/<repo>
{
  "allow_merge_commits": false,
  "allow_rebase": false,
  "allow_rebase_explicit": false,
  "allow_squash_merge": true,        // develop 用
  "allow_fast_forward_only_merge": true,  // staging/production 用
  "default_merge_style": "squash"
}
```

**Gitea 控制的是"PR 合并按钮下拉里能选哪些 style"**，不是"目标分支必须用某 style"。

### 11.3 应对：CLI 收敛 + 自动 self-approve

走 `gitea promote uat|prod` CLI（详见 §9.2）。**禁止 Gitea web UI 手点合并**——UI 默认 merge style 是 squash，曾误点造成 PR #383 事故（production 与 staging 历史分叉，事后 force-reset 修复）。

### 11.4 Promotion PR 卡 outdated 没有 "Update branch" 按钮

promotion PR 的 head 是 `develop`（promote uat）或 `staging`（promote prod），三个保护分支都**禁直推**，而 `staging` / `production` 还开了 `block_on_outdated_branch=True`。**"Update branch" 需要对 head 分支有 push 权限**——保护分支不行。

```bash
POST /pulls/{id}/update → 403
```

解法：开**反向 sync-back PR**。

- 卡 develop→staging promotion → 开 `staging → develop` 的 sync-back PR
- 卡 staging→production promotion → 开 `production → staging` 的 sync-back PR

把 base 上的新 commit 回吸到 head 之后，原 promotion PR 自动 up-to-date。

---

## 12. PR push 远端 Update Branch 后的本地 amend 陷阱

在 Gitea PR 页面点 **"Update branch"** 会**自动**把 base merge 到 feature 分支（产生 merge commit）。**本地完全感知不到这次 merge**。

后续在本地 amend 旧 commit + `git push --force-with-lease`：

```bash
! [rejected]  (stale info)
```

force-with-lease 比对"我记忆中的远端 tip"和"实际远端 tip"发现不一致 → 拒推。**这正是 force-with-lease 设计的目的**——救了你一次，比 `--force` 安全。

**安全恢复路径**（**绝不要** `git push --force` 硬覆盖）：

```bash
git fetch origin <branch>
git reset --hard origin/<branch>      # 放弃本地 amend
# 重新手工应用改动
git add -p && git commit -m "..."     # 作为 NEW commit
git push                              # fast-forward，无需 force
```

**元规则**：
- 远端 merge commit ≠ "脏历史"——它是分支保护机制的一部分，不能随便覆盖
- amend 仅适用于 commit 还**没推**或者**推出去后远端没人动过**的窗口
- 一旦 Gitea 自动加了 merge / 别人在 UI 上动过你的分支，amend 流程作废，改走"新增 commit"流程

---

## 13. 工程师调试 cheatsheet

```bash
# 1. CI 红时先看是否阻塞合并
curl -sS -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/FFAIWorkspace/workspace/branch_protections/develop" \
  | jq '.status_check_contexts'

# 2. 看具体 run 的 conclusion
curl -sS -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/.../actions/runs/<id>/jobs"

# 3. 看 SHA 全部 status 历史（多 PR 同 SHA 时排查）
curl -sS -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/.../commits/<sha>/statuses?limit=50"

# 4. 看 PR mergeable 状态
curl -sS -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/.../pulls/<num>" | jq '.mergeable, .mergeable_reason'

# 5. 看 secret 是否存在（HTTP code 是唯一凭据）
curl -sS -o /dev/null -w "%{http_code}\n" -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/.../actions/secrets/<name>"
# 200 = 存在 / 404 = 不存在；value 永远拿不到
```

---

## 14. 相关 learning 索引（28 条）

> 本文档把这些 learning 的事实归纳到上方各章节。原 learning 保留作为复盘历史，**新踩坑请先查本文档**。

**触发与 checkout**：
- `2026-05-11-gitea-actions-trigger-checkout-status-semantics.md`
- `2026-05-11-ai-review-no-synchronize.md`
- `2026-05-11-ai-review-synchronize-with-diff-hash-dedup.md`

**Status / required check**：
- `2026-04-29-branch-protection-stale-required-checks.md`
- `2026-04-29-gitea-commit-status-sha-overwrite.md`
- `2026-05-11-gitea-enable-status-check-stuck-false.md`
- `2026-05-13-ci-red-but-not-blocking.md`

**Concurrency / 共享容器**：
- `2026-05-09-ci-flock-shared-test-containers.md`
- `2026-04-29-batch-merge-runner-cancellation.md`

**Runner / OOM / 升级**：
- `2026-04-30-runner-migrate-and-capacity-zero.md`
- `2026-04-30-runner-migrate-claude-cli.md`
- `2026-05-01-gitea-host-runner-oom.md`
- `2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md`

**API 字段陷阱**：
- `2026-05-09-gitea-actions-run-display-number-vs-api-id.md`
- `2026-05-09-gitea-actions-runs-api-field-quirks.md`
- `2026-05-10-gitea-secret-pat-api-opaqueness.md`
- `2026-04-29-ai-review-enablement.md`（GITEA_* secret name 禁用）
- `2026-05-11-gitea-branches-author-username-pre-resolved.md`

**Workflow if 守卫**：
- `2026-05-15-ai-review-self-trigger-loop.md`

**PR auto-close keywords**：
- `2026-05-11-pr-body-closes-keyword-missing-leaves-weekly-retro-stale.md`
- `2026-05-14-gitea-pr-auto-close-keywords.md`

**AI Review 设计**：
- `2026-04-29-dryrun-progressive-rollout-pattern.md`
- `2026-05-04-ai-review-runner-json-parse-bug.md`
- `2026-05-11-ai-review-runner-first-pr-no-prior-comment.md`
- `2026-05-14-ai-review-must-read-pr-body.md`

**No-self-merge + FF-only**：
- `2026-05-11-no-self-merge-policy.md`
- `2026-05-12-ff-only-rollout-reset.md`
- `2026-05-12-gitea-ff-only-is-repo-level.md`
- `2026-05-12-promotion-pr-no-update-button.md`
- `2026-05-15-gitea-promote-cli-rollout.md`
- `2026-05-16-amend-after-remote-update-branch.md`

**JSON / CLI 细节**：
- `2026-05-11-gitea-api-chinese-quotes-json.md`
- `2026-05-14-gitea-label-id-hardcoding-trap.md`
- `2026-05-15-gitea-cli-verb-num-order.md`
