Files
codex-app-server/docs/merge-gate-branch-protection.md
T
host 61e08a1a8c feat: hard merge gate by default (CI + directory + contract + coordinator + reviews)
- cloud/merge_gate_hard.py (new): pure hard gate; mode resolution is
  env-only (unset/invalid -> hard, fail-closed); five checks plus
  branch protection must be protected; unknown/unprotected block
  even with explicit confirm; no-evidence blocks (fail-closed);
  directory check reuses #209 check_directory_isolation (v0.3 rule);
  blocks codes + merge_gate_blocked audit detail are the track-3
  pushback interface (no pushback state machine in this track).
- cloud/server.py: POST /tasks/{id}/merge uses hard mode by default;
  record/enforce/off only when explicitly set via env (preserving
  #209 semantics); body merge_gate_mode is ignored server-side so
  callers cannot downgrade the gate; every gate block writes a
  merge_gate_blocked audit before returning 409, status unchanged;
  hard-mode body type errors return 400.
- docs/merge-gate-branch-protection.md (new): protection payload
  (PR + strict status checks + no force-push + no deletion +
  enforce_admins), empty required_checks = unconfigured, Pro/public
  prerequisites, 403 causes; states protection is NOT claimed enabled.
- SKILL.md (gate-mechanism wording only): four checks -> five checks;
  body + coordinator + client appendix gate paragraphs updated to
  default hard, merge_gate_blocked audit, payload doc reference.
- tests: 31 new hard-gate tests (pure + endpoint + skill text);
  #209 endpoint tests now set record env explicitly (their intent);
  test_server_merge 200-path calls now carry full hard evidence.
2026-11-03 02:48:55 +00:00

130 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 分支保护与合并硬门禁:GitHub 侧操作说明
状态声明(先读):本仓库当前是否已开启主分支保护,**本文件不声称**。
保护只能由仓库管理员在 GitHub 侧开通,本平台代码(cloud/merge_gate.py、
cloud/merge_gate_hard.py)只能在调用方明确声明保护状态时做门禁,
绝不代替 GitHub 开通、绝不声称“已保护”。服务端硬门禁(默认 hard)
要求 `branch_protection == "protected"` 才会放行;`unknown` 与
`unprotected` 一律 409。
## 1. 分支保护 payload(对默认分支,如 main)
GitHub REST:`PUT /repos/{owner}/{repo}/branches/{branch}/protection`
```json
{
"required_status_checks": {
"strict": true,
"checks": [
{"context": "ci"}
]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_linear_history": false,
"allow_fork_syncing": false,
"block_creations": false
}
```
逐项对应任务要求:
- PR:`required_pull_request_reviews`——必须经 PR 评审才能合并,
本平台技能要求“没有 PR 号不算完成”,与此一致。
- strict status checks:`required_status_checks.strict = true`——
PR 分支必须与目标分支最新状态同步后检查才算通过,防“旧绿灯合新代码”。
- no force-push:`allow_force_pushes = false`(默认分支绝不 force-push,
与技能“绝不对默认分支 force-push”一致)。
- no deletion:`allow_deletions = false`。
- enforce_admins:`enforce_admins = true`——管理员同样受保护规则约束,
不能绕过(注意:仓库所有者/组织所有者在部分场景下仍可通过
设置页调整规则本身,enforce_admins 约束的是“绕过规则合并”,
不是“不能改规则”)。
API 命名注意(新旧两套,别抄错):
- 较新的 API 用 `required_status_checks.checks[]`,每项含 `context`
与可选 `app_id`;较旧文档/客户端用 `contexts[]`(字符串数组)。
两者语义相同:列出必须通过的检查名。调用前以实际 API 返回为准,
不要凭记忆混用。
- `allow_force_pushes` / `allow_deletions` 在新 API 里是顶层布尔值;
旧版曾用 `allow_force_pushes: {enabled: ...}` 对象形,同样以实际
API 为准。
## 2. 空 required_checks = 未配置(硬门禁的同一口径)
- GitHub 侧:`required_status_checks.checks`(或 `contexts`)为空数组,
即使 `strict: true`,也等于“没有必须通过的检查”——任何 CI 状态
都不会被保护规则拦住。不要把“开了保护”理解成“CI 已被强制”。
- 服务端硬门禁侧:`required_checks` 为 `[]` 时判
`ci_required_checks_unconfigured`,409;`required_checks` 缺失时判
`ci_evidence_missing`,同样 409。两者都是 fail-closed,只是
“显式空配置”与“没给证据”的区分,便于第 3 轨推回包定位问题。
- 开通保护后必须做的核对:保护设置页(或 GET 同一 protection 端点)
返回的 checks 列表非空,且与仓库真实 CI 检查名逐一对应;
改名/新增 CI 检查后,保护里的检查名必须同步更新,否则
旧检查名永远等不到、PR 永远合并不了,或新检查根本不在强制列表里。
## 3. Pro / 公开的前提(三选一,用户侧决定,代码不替选)
GitHub 对私有仓库的分支保护规则有版本限制(以 GitHub 当前定价页为准,
本文件不复述具体价格):
1. **升级到 Pro / Team / Enterprise**:私有仓库可用完整的
required_pull_request_reviews 与 required_status_checks。
2. **公开仓库**:公开仓库免费可用分支保护。但公开是不可逆影响
可见性的决定:公开前必须确认仓库里没有密钥、内部设计、私有
数据与历史提交里的敏感内容;**本轨绝不擅自公开仓库**。
3. **保持现状 + 纪律 + 记风险**:不开保护,靠硬门禁的
`branch_protection_unprotected` 阻断 + 审计记录 + 人工流程约束。
这不是“等同于保护”,只是三者中唯一不花钱、不改可见性的选项;
选择它意味着接受“保护规则本身不存在,门禁只拦本平台的合并端点,
直接推默认分支仍可由有写权限者做到”这一残留风险。
在用户做出三选一决定之前,硬门禁默认 hard 会把未声明/未保护的
合并全部拦下(409),这是符合 fail-closed 的预期行为,不是故障。
## 4. 常见 403 原因(按排查顺序)
保护端点返回 403 时,先区分是“版本限制”还是“权限不足”:
- **版本限制**:私有仓库免费版调用含 required_pull_request_reviews /
required_status_checks 的保护设置,GitHub 以 403 拒绝(文案大意为
需要升级)。这是第 3 节三选一要解决的前提,不是 token 写错。
- **调用方不是管理员**:只有仓库管理员(Admin 权限)能改分支保护;
Maintainer/Write 权限调该端点会 403。检查调用身份的仓库角色。
- **GitHub App 权限不足或未安装**:App 需要 Repository administration
(写分支保护)权限,且必须安装在目标仓库上;权限是安装时授予的,
之后在 App 设置里加权限不会自动生效,必须由安装方重新接受新权限。
App token 的权限缩小、过期或被吊销同样表现为 403/401。
- **OAuth token 权限不足**:个人 token 需含 `repo` 作用域(私有仓)
才能写保护;细粒度 token 需含对应的仓库管理权限。
- **组织/企业策略**:组织规则集(rulesets)或企业策略可能禁止
修改、或强制覆盖仓库级保护,导致写入被拒或写入后不生效;
此时以组织规则集的实际生效状态为准,不要只看仓库设置页。
排查时只读核对:`GET /repos/{owner}/{repo}/branches/{branch}/protection`
返回 404 表示该分支未设保护(不是“请求错了”),200 则返回当前规则;
不要用“试一次合并看拦不拦”来反推保护状态。
## 5. 与服务端硬门禁的对应关系
| GitHub 侧状态 | 服务端硬门禁(默认 hard) |
| --- | --- |
| 保护已开,checks 非空且全绿 | 调用方声明 `protected` + CI 证据全绿时放行(其余四项也须过) |
| 保护已开,但 checks 为空 | 调用方 `required_checks: []` → `ci_required_checks_unconfigured`,409 |
| 保护未开 | 调用方声明 `unprotected` → `branch_protection_unprotected`,409(确认无效) |
| 保护状态未声明 | `branch_protection_unknown`,409(unknown 不等于 protected) |
服务端门禁不向 GitHub 求证:调用方声明 `protected` 但 GitHub 实际
未开时,服务端无法从声明本身发现谎报——这正是“保护必须由管理员
在 GitHub 侧开通并用 GET 核对”的原因,也是第 3 轨审计要覆盖的
核对项(审计结论与 GitHub 实际状态不一致即为偏差)。