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

7.0 KiB
Raw Blame History

分支保护与合并硬门禁: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

{
  "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 实际状态不一致即为偏差)。