# gitea promote CLI 落地：FF-only 政策的可执行兜底

**Date**: 2026-05-15
**Tags**: gitea, ff-only, env-promotion, cli, policy
**Related**: PR #384 / PR #383 事故 / ERR-20260515-002

## 背景

PR #351 立 FF-only 政策（develop→staging / staging→production 必须 fast-forward），但执行靠 reviewer 在 Gitea web UI 手动选 "Create a fast-forward-only merge"。PR #383 出事故：merger 误点 squash，production 与 staging 历史分叉，事后 force-reset 修复（ERR-20260515-002）。

## 调查发现：Gitea 不支持 per-target-branch 强制 merge style

实测 Gitea API：

- **仓库级** `default_merge_style`：全局一个值（当前 `squash`），不区分 base 分支
- **分支保护规则**字段集（`approvals` / `status_check` / `push_whitelist` / `block_on_outdated_branch` ...）**完全没有** merge-style 相关字段
- `allow_fast_forward_only_merge=true` 只控制"允许 FF 选项"，不控制"必须 FF"

把全局默认改成 FF 不可行——会污染 develop 流程（develop 每天大量 squash PR，默认改 FF 后 reviewer 要手动切回 squash，错误率反向升高）。

## 解法：CLI 收敛 + policy 强制

`scripts/ops/gitea promote uat|prod`：

1. **Do=fast-forward-only 写死**，merge style 无法被改
2. compare API 拒绝 `base 比 head 多 commit`（E_NOT_FF_ABLE）
3. CI 全绿是硬门槛（combined status `success`），`--force-anyway` escape hatch
4. 自动 self-approve（**例外政策**：env-promote PR 允许自合）
5. post-merge 校验 `base tip == head sha`，不等则 E_POST_MERGE_DRIFT
6. dry-run 0 副作用，连 PR 都不创建

## self-approve 例外的合理性

CLAUDE.md 原则禁止自合，因为 review 价值在"另一双眼"。env-promote 例外：

| 维度 | feature PR | env-promote PR |
|---|---|---|
| 合并动作 | 决策（合不合？什么 style？squash 信息丢失？） | 机械（FF base==head，无变量） |
| review 价值 | 高（防止逻辑错误） | 低（commits 早在 develop 阶段已 review 过） |
| 谁能调用 | 任何 PR 作者 | merge_whitelist（chentao.jia / hongwei.zhang） |
| 漂移风险 | 中（reviewer 误点 squash） | 低（CLI 强制 FF） |

self-approve 在 env-promote 场景是**降低出错率**（机械操作让人手参与反而增加 PR #383 类型的事故）。

## 元教训

### 1. policy 必须有可执行兜底

「reviewer 必须手动选 X」类政策是**纸面执行**——靠人记忆 + UI 默认。事故是必然的，区别只是何时发生。**纸面政策必须配 CLI / workflow / lint 等技术兜底**，否则等于没有。

### 2. 平台能力不足时往 wrapper 上游走

Gitea 不支持 per-branch merge style → 不要在平台上钻牛角尖，往上一层（CLI / 工作流）做。Wrapper 拿到的是 platform 真值（API），无 UI 默认值粘性问题。

### 3. CLI 子命令应该有"机械化"的接口

`gitea promote uat` 不是 `gitea pull-request merge --do fast-forward-only --pr <n> --self-approve` — 后者把决策（选什么 do）扔给了 caller，前者把决策做死（uat = FF-only develop→staging）。**机械化接口 = 错的入口塞不进去**。

### 4. 政策与实现要在同一个 PR

PR #384 同时改了：CLI（`scripts/ops/gitea`）+ 政策（`CLAUDE.md` / `AGENTS.md` / `docs/standards/`）。本来 high-risk path 应拆 PR，但**强耦合**——CLI 没政策不强制用，政策没 CLI 没法执行；分两 PR 必有窗口期是"政策已立但 CLI 没合 / CLI 已合但政策没说必须用"。用户授权 + PR body 显式声明合并理由，绕过拆 PR 规则。

## 验证

- `gitea promote uat --dry-run` → UP_TO_DATE（当前 develop=staging）
- `gitea promote prod --dry-run` → UP_TO_DATE
- L1 测试 40/40 pass
- 真实 FF 路径需等下次 UAT 升级才能验
