# 分支保护与合并硬门禁: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 实际状态不一致即为偏差)。