- 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.
7.0 KiB
分支保护与合并硬门禁: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 当前定价页为准, 本文件不复述具体价格):
- 升级到 Pro / Team / Enterprise:私有仓库可用完整的 required_pull_request_reviews 与 required_status_checks。
- 公开仓库:公开仓库免费可用分支保护。但公开是不可逆影响 可见性的决定:公开前必须确认仓库里没有密钥、内部设计、私有 数据与历史提交里的敏感内容;本轨绝不擅自公开仓库。
- 保持现状 + 纪律 + 记风险:不开保护,靠硬门禁的
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 实际状态不一致即为偏差)。