Solo-Maintainer PR-Merge Ceremony¶
Canonical reference for self-merging PRs under the project's branch-protection invariant. When the maintainer is the sole contributor with write access, GitHub's branch-protection rules
enforce_admins=true+required_approving_review_count=1create a self-merge deadlock that this page documents the resolution for.
1. The Deadlock¶
The repository's main branch carries the protection set authored at Phase 09C:
required_pull_request_reviews.required_approving_review_count: 1enforce_admins: true
These rules are correct for multi-maintainer scenarios (every change reaches main through a peer-reviewed PR; admins are not exempt). They produce a deadlock for solo maintainers: GitHub's policy disallows self-approval on a PR you authored, and enforce_admins=true blocks the gh pr merge --admin override of the review-approval requirement.
Three resolution paths exist:
| Path | When to use |
|---|---|
| Temp-toggle ceremony (canonical for this repo) | Default for solo-maintainer self-merges; preserves the protection invariant outside the merge window |
| Provision a CI-bot identity with write access | Adopt only if multi-maintainer onboarding is imminent; bot-approval undermines the human-review intent of the protection |
Permanently set required_approving_review_count: 0 |
Adopt only if the repo will remain solo-maintainer indefinitely; reverses the protection's review intent |
This repository ratifies the temp-toggle ceremony as the canonical path. The other two are documented escape hatches, not the default.
2. The Temp-Toggle Ceremony¶
The ceremony's invariant: the protection state at the start of the ceremony equals the protection state at the end. The relaxation lives only inside the merge window.
Manual form:
# 1. Open the PR, push branch, wait for CI green.
gh pr create --base main --head <feature-branch> --title "..." --body "..."
gh pr checks <pr-number> # poll until all green
# 2. Relax approver-count to 0.
gh api repos/<owner>/<repo>/branches/main/protection/required_pull_request_reviews \
-X PATCH -F required_approving_review_count=0
# 3. Squash-merge with admin override (admin override now succeeds because the
# review-approval requirement is the only block enforce_admins refuses).
gh pr merge <pr-number> --squash --delete-branch --admin
# 4. Restore approver-count to 1.
gh api repos/<owner>/<repo>/branches/main/protection/required_pull_request_reviews \
-X PATCH -F required_approving_review_count=1
Wrapped form: see scripts/dev/admin_merge.py for the atomic Python wrapper that executes steps 2 → 3 → 4 in a single non-interactive sequence with try / finally rollback safety on step-3 failure.
python scripts/dev/admin_merge.py <pr-number>
3. Invariants¶
- Toggle window minimized. Steps 2 → 3 → 4 execute in a single non-interactive sequence. The protection is in its relaxed state for the merge API call's duration only — under one second on every observed run.
- Restore is unconditional. Step 4 runs even when step 3 fails (the wrapper's
try/finallyclause ensures restoration on any exit path). A failed merge attempt does not leave the protection relaxed. - CI gating preserved throughout.
required_status_checksis unaffected by the toggle; the merge still requires the configured status-check contexts to be green. - Audit trail visible. The
gh pr merge --adminannotation appears in the PR's merge metadata; the protection-API toggles appear in the repo's audit log under the maintainer's identity.
4. When NOT to Use the Ceremony¶
The ceremony is for self-merge of the maintainer's own PRs. When a peer reviewer is available (a contributor with write access), the standard gh pr review --approve + gh pr merge --squash --delete-branch path is correct and the ceremony is unnecessary.
If the repository onboards a second maintainer, retire the ceremony: the standard PR-review flow restores the human-review intent of the protection without any toggle.
5. Auditing a Past Run¶
Every ceremony invocation lands on three durable surfaces that an operator can inspect after the fact:
- The PR's merge metadata.
gh pr view <pr-number> --json mergedBy,mergedAt,mergeCommitreturns the merge actor, the merge timestamp, and the squash commit's SHA. ThemergedByactor matches the maintainer identity that ran step 3. - The repository's audit log.
gh api /repos/<owner>/<repo>/actions/runs --jq '.workflow_runs[] | select(.event=="push")'correlates the post-merge CI run to the merge commit; the repository's protection-rule audit log (gh api /repos/<owner>/<repo>/branches/main/protectionhistory via the Settings → Branches UI) shows the step-2 and step-4 toggles bracketing the merge window. - The maintainer's git history.
git log main --merges --first-parentlists every squash-merge commit onmainwith the corresponding PR number in the commit subject ((#<pr-number>)suffix per thegh pr merge --squashdefault), letting the operator trace any past landing back to its PR and ceremony invocation.
When scripts/dev/admin_merge.py is the entry point, its non-interactive log (stdout) names the three protection-API calls and the merge-API call in sequence; piping the wrapper to tee captures a per-run audit fragment without depending on the GitHub-side log retention.