Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/instruction-surfaces.json
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@
"review_owner": "z-shell maintainers",
"canonical_for": ["repository-bootstrap"]
},
{
"id": "runbook-branch-protection",
"path": "runbooks/branch-protection.md",
"kind": "runbook",
"authority": "canonical-detail",
"consumers": ["codex", "claude-code", "copilot", "gemini-cli", "human"],
"tasks": ["repository-bootstrap", "branch-protection"],
"file_patterns": ["**"],
"required": true,
"review_owner": "z-shell maintainers",
"canonical_for": ["branch-protection"]
},
{
"id": "runbook-onboarding",
"path": "runbooks/onboarding.md",
Expand Down
4 changes: 4 additions & 0 deletions decisions/0008-branching-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ public factual-reconciliation change.
repositories; the live tag-trigger exceptions above are accepted alongside
it. For other classes the merge validates but does not mint a release
(consistent with ADR-0007).
- This ADR sets the policy; `runbooks/branch-protection.md` covers the
repository-settings and ruleset provisioning that enforces it (added after
an audit found `src` and `zsh-eza` both missing parts of it).

## Alternatives considered

Expand All @@ -117,5 +120,6 @@ public factual-reconciliation change.

- `decisions/0007-release-publication-flow.md` — repository classes this builds on.
- `decisions/0003-conventional-commits.md` — commit/branch naming conventions.
- `runbooks/branch-protection.md` — enforcement checklist for this model.
- [Issue #454](https://github.com/z-shell/.github/issues/454) — dated live audit
and maintainer decision record.
109 changes: 109 additions & 0 deletions runbooks/branch-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Runbook — Branch Protection for `next` → `main` Repositories

Use this runbook when provisioning or auditing branch rulesets and repository
settings for a repository that uses the `next` → `main` branch model
(`decisions/0008-branching-model.md`). It exists because `z-shell/src` and
`z-shell/zsh-eza` were both found, independently, with the same three gaps
during an audit — none of them were visible from the ruleset UI alone.

## Why this exists

A repository can have a correctly configured `main`/`next` ruleset pair and
still let `main` and `next` diverge, or lose the `next` branch outright,
because the gaps are in _repository settings_ and _automation defaults_ that
rulesets do not cover. Two incidents motivated this runbook:

1. `src` and `zsh-eza` both had `renovate.json` with no `baseBranches`
override. Renovate defaulted to the repository's default branch (`main`),
opening routine dependency-update PRs that bypassed `next` entirely. Over
time, enough of these (plus a few manually-merged feature branches) landed
directly on `main` to diverge it from `next` by more than a dozen commits
in `zsh-eza`'s case, with real merge conflicts to resolve before `next`
could be promoted again. `dependency-management.md` already documents the
`baseBranches` override as an example — the actual gap was that nothing
audited whether a `next`-model repository had actually applied it.
2. Promoting `zsh-eza`'s `next` into `main` via a PR merge (`next` as the PR's
head branch) triggered GitHub's "Automatically delete head branches" repo
setting, which deleted `next` — the repository's persistent development
branch, not a disposable feature branch — immediately after the merge.
**The ruleset's `deletion` rule did not stop this.** The merge itself ran
under an organization-admin ruleset bypass (needed because the sole
`CODEOWNERS` entry was also the PR author, so the required code-owner
review could never be satisfied by anyone else), and the automatic
post-merge deletion inherited that same bypass context.

## Checklist

Run every item below for a repository whose `decisions/0008-branching-model.md`
row is `next` → `main`. Skip repositories that are trunk-on-`main`.

- [ ] **`delete_branch_on_merge` is `false` at the repository level.**
`gh api repos/<org>/<repo> --jq .delete_branch_on_merge`. If `true`,
any PR that uses `next` as its head branch (i.e. every `next` → `main`
promotion) risks GitHub deleting `next` right after merge, regardless of
the ruleset's `deletion` rule. Disable it:
`gh api -X PATCH repos/<org>/<repo> --field delete_branch_on_merge=false`.
This is the single highest-value check in this runbook — it is the one
that caused actual data loss (recovered from the merge commit's known
SHA in this case, but that is luck, not a safety net).
- [ ] **`renovate.json` has `"baseBranches": ["next"]`** if the repository
uses Renovate. See `dependency-management.md` for the full config
example. Check `.github/dependabot.yml`'s `target-branch` too — it is
easy to fix Dependabot's target and assume Renovate inherited the same
fix; they are independent configs.
- [ ] **`main` and `next` are each governed by exactly one Repository Ruleset**,
not a mix of a ruleset and legacy classic branch protection. Classic
protection and rulesets both apply when both are present, and their
settings can silently contradict each other (observed: classic
protection allowing force-pushes while the ruleset's `non_fast_forward`
rule blocked them — harmless only because the stricter rule wins, but
confusing to audit and a sign the branch was migrated incompletely).
List them with `gh api repos/<org>/<repo>/rulesets`; check for lingering
classic protection with `gh api repos/<org>/<repo>/branches/<branch>/protection`
(a `404` means none exists, which is correct).
- [ ] **A required status check blocks PRs into `main` whose head is not
`next` or `hotfix-*`.** Rulesets have no native "restrict PR source
branch" condition, so this has to be a CI check wired in as
`required_status_checks`. See `.github/workflows/main-branch-guard.yml`
in `z-shell/src` or `z-shell/zsh-eza` for the reference implementation
(a single `run:` step reading `github.head_ref`, no third-party
actions needed). The check must run at least once on a real PR against
`main` before GitHub will accept its context name in
`required_status_checks`.

## Squash-merge trailers

When squash-merging a `next` → `main` promotion PR without an explicit
`--body`, GitHub synthesizes one by aggregating the squashed commits'
trailers — which reliably reintroduces `Co-authored-by` and `Signed-off-by`
trailers even when no individual commit you authored had one. Only
`Co-authored-by` is organization-disallowed (`AGENTS.md`); letting a
synthesized body reintroduce it violates that policy regardless of which
squashed commit it came from. Always pass both `--subject` and an explicit
one-line `--body` (e.g. `gh pr merge <n> --squash --subject "..." --body "..."`) to suppress
the synthesized body. Verify with
`gh api repos/<org>/<repo>/commits/<sha> --jq .commit.message` before
considering the promotion done.

## Reference ruleset shape

Both `main` and `next` should be a single Repository Ruleset each, scoped by
`refs/heads/<branch>`, with `bypass_actors` granting `OrganizationAdmin` and
the repository's admin/maintain/write roles `bypass_mode: always` (self-review
deadlock is expected and intentional: the only `CODEOWNERS` entry is often
also the person merging, so bypass is how promotions and fixes actually land;
`gh pr merge --admin` is the normal path here, not an escape hatch).

`main`: `deletion`, `required_linear_history`, `pull_request` (code-owner
review required, approving-review count `0`), `non_fast_forward`,
`copilot_code_review`, `required_status_checks` (the guard workflow above).

`next`: `deletion`, `non_fast_forward`, `required_signatures`, `pull_request`.
`required_linear_history` is deliberately absent from `next` — ordinary merge
commits (not squashes) are the convention for feature/fix PRs landing there.

## See also

- `decisions/0008-branching-model.md`
- `runbooks/dependency-management.md`
- `runbooks/new-repository.md`
7 changes: 7 additions & 0 deletions runbooks/dependency-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ exception, such as a non-default target branch:
Custom managers and specialized package grouping also belong in the repository
that needs them.

For any repository whose `decisions/0008-branching-model.md` row is `next` →
`main`, this `baseBranches` override is not optional: without it Renovate
opens routine update PRs against `main` directly, bypassing `next`. This was
found live (not theoretical) in two repositories — see
`runbooks/branch-protection.md` for the full audit checklist.

## Migrating a repository

Do not remove Dependabot version updates until Renovate coverage is proven.
Expand Down Expand Up @@ -121,3 +127,4 @@ If Renovate cannot access or process a repository:
- `decisions/0012-hybrid-dependency-management.md`
- `renovate-config.json`
- `runbooks/new-repository.md`
- `runbooks/branch-protection.md`
13 changes: 12 additions & 1 deletion runbooks/new-repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,19 @@ Follow `runbooks/dependency-management.md`:
3. Enable the dependency graph, Dependabot alerts, and Dependabot security
updates in GitHub settings.
4. Add `renovate.json` only for a repository-specific exception such as a
`next` target branch.
`next` target branch. If `decisions/0008-branching-model.md` assigns this
repository the `next` → `main` model, this exception is mandatory, not
optional — see `runbooks/branch-protection.md`.
Comment thread
ss-o marked this conversation as resolved.
5. Do not add `.github/dependabot.yml` for routine version updates.

## Step 5a — Provision branch rulesets

If this repository uses the `next` → `main` model
(`decisions/0008-branching-model.md`), follow `runbooks/branch-protection.md`
in full before opening the bootstrap pull request. Trunk-on-`main`
repositories still need a `main` ruleset, but can skip the `next`-specific
items (the guard workflow, `renovate.json` override).

## Step 6 — Verify before publication

Before opening the bootstrap pull request:
Expand Down Expand Up @@ -146,6 +156,7 @@ Reusable screenshot and terminal-demo generation is tracked separately in

- `AGENTS.md`
- `PATTERNS.md`
- `runbooks/branch-protection.md`
- `runbooks/dependency-management.md`
- `runbooks/labels.md`
- `runbooks/project-tracker.md`
Expand Down
Loading