From c15df2f0bc54c595ff33b922f27e071859f05670 Mon Sep 17 00:00:00 2001 From: Sall Date: Wed, 22 Jul 2026 02:48:57 +0100 Subject: [PATCH 1/3] docs(runbooks): document the post-promotion branch reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class-1 repositories that promote a development branch to a deployed branch diverge after every promotion. When the deployed branch requires both pull requests and linear history, squash and rebase both mint new commits the development branch lacks, and a merge commit — the one strategy that would keep the branches related — is what linear history forbids. There is no configuration that avoids it, so reconciling is a release step rather than an incident. Records the reset-and-force-push procedure, with a tree-equality safety gate so it is only run when it is content-neutral. Also records two traps found while doing this on wiki: Rulesets and classic branch protection are independent, and the effective rule is their union. Classic protection reported required_linear_history false for a branch a ruleset separately enforced it on, which made a merge commit look permissible when it was not. An administrator's push is not refused by a ruleset; it reports 'Bypassed rule violations' and succeeds. Quieting push output hides that warning, so the runbook says to read it and states which single bypass is expected and unavoidable for this operation. --- runbooks/release.md | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/runbooks/release.md b/runbooks/release.md index 608ceb1ee..c9e293893 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -80,6 +80,87 @@ Repositories that should stay out of the first pilot: - `.github` - `zi` +## Post-promotion branch reconcile (class 1) + +Applies to class-1 repositories that promote a development branch to a deployed +branch, such as `wiki` promoting `next` to `main`. + +### Why this is routine, not an incident + +When the deployed branch requires both pull requests and linear history, every +merge method GitHub offers — squash or rebase — creates **new commits on the +deployed branch that the development branch does not have**. A merge commit, +which would keep the branches related, is exactly what linear history forbids. + +So the development branch diverges after *every* promotion, by construction. +There is no branch-protection configuration that avoids it. Reconciling is a +step in the release, not a sign that something went wrong. + +### Do not reconcile with a merge + +A `git merge` creates a merge commit, which violates `required_linear_history`. +An administrator's push is **not refused** — it silently reports +`Bypassed rule violations` and succeeds anyway. Check the branch's rules before +choosing a strategy: + +```sh +gh api repos/OWNER/REPO/rules/branches/BRANCH -q '[.[].type]|join(", ")' +``` + +Note that rulesets and classic branch protection are **independent** systems and +the effective rule is their union. Classic protection can report +`required_linear_history: false` for a branch that a ruleset separately +enforces it on, so check both before concluding a merge commit is allowed. + +### Procedure + +Immediately after the promotion merges, when everything on the development +branch has shipped: + +```sh +git fetch origin + +# 1. Safety gate. The trees must be identical — that is what makes this +# content-neutral. If they differ, the development branch has unmerged +# work and must not be reset. +[ "$(git rev-parse origin/main^{tree})" = "$(git rev-parse origin/next^{tree})" ] \ + && echo SAFE || echo "STOP: next has unmerged content" + +# 2. Realign the development branch onto the deployed branch. +git checkout next +git reset --hard origin/main + +# 3. Publish. A force is required: history is being replaced, not extended. +git push --force-with-lease origin next +``` + +Use `--force-with-lease`, never `--force`, so the push aborts if anyone else +has pushed to the branch since the fetch. + +### What to expect, and what to check + +- **This push bypasses a rule and cannot avoid it.** A direct push carries no + prior status check, so the branch reports + `Required status check "Trunk Check" is expected`. There is no + pull-request route to this operation — a pull request can only add commits, + and realignment rewrites history. Read the push output rather than silencing + it, and confirm the only bypass reported is the expected status check. +- **Never run `git push -q` or pipe push output through `tail` on a protected + branch.** The `Bypassed rule violations` warning arrives at push time and is + easily truncated away. +- Afterwards the two branches should be the *same commit*, not merely the same + content: + + ```sh + [ "$(git rev-parse origin/next)" = "$(git rev-parse origin/main)" ] && echo reconciled + ``` + +### If the safety gate fails + +Do not reset. Differing trees mean the development branch carries work that the +promotion did not include. Promote that work first, or rebase it onto the +deployed branch, and only then realign. + ## Release preparation automation (class 2) The reusable workflow From 314add4b969b5e4be24715b4fb40caa16ac3ddd1 Mon Sep 17 00:00:00 2001 From: Sall Date: Wed, 22 Jul 2026 02:55:40 +0100 Subject: [PATCH 2/3] docs(runbooks): make the reconcile gate abort, and parameterize branches Review feedback on the post-promotion reconcile section. The safety gate only echoed STOP while reset --hard and a force push followed it, so anyone pasting the block would have run straight through a failed check into a destructive operation. The procedure is now a script with set -eu and an explicit exit 1 in the gate, and says to run it as a script rather than paste it line by line, since a gate cannot abort a run when each line is entered separately. Branch names are now DEPLOY and DEV variables instead of hard-coded main and next, so the section matches its stated scope of class-1 repositories generally. 'silently reports Bypassed rule violations' was self-contradictory. The push does print the warning; it is simply not stopped. Both gate paths were verified against a live clone: differing trees exit 1 before step 2, matching trees proceed. --- runbooks/release.md | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/runbooks/release.md b/runbooks/release.md index c9e293893..2c4d29fad 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -85,6 +85,10 @@ Repositories that should stay out of the first pilot: Applies to class-1 repositories that promote a development branch to a deployed branch, such as `wiki` promoting `next` to `main`. +Branch names differ between repositories. The procedure below takes them as +`DEPLOY` and `DEV` variables — set them for the repository you are working on +rather than assuming `main` and `next`. + ### Why this is routine, not an incident When the deployed branch requires both pull requests and linear history, every @@ -99,8 +103,9 @@ step in the release, not a sign that something went wrong. ### Do not reconcile with a merge A `git merge` creates a merge commit, which violates `required_linear_history`. -An administrator's push is **not refused** — it silently reports -`Bypassed rule violations` and succeeds anyway. Check the branch's rules before +An administrator's push is **not refused** — it prints `Bypassed rule +violations` and succeeds anyway. The push is not silent; it is simply not +stopped, and the warning is easy to miss. Check the branch's rules before choosing a strategy: ```sh @@ -115,23 +120,36 @@ enforces it on, so check both before concluding a merge commit is allowed. ### Procedure Immediately after the promotion merges, when everything on the development -branch has shipped: +branch has shipped. + +Save this as a script and run it — do not paste the lines individually. Steps 2 +and 3 reset a branch and force-push it, so the safety gate has to be able to +abort the run, which it cannot do when each line is pasted separately. ```sh +#!/usr/bin/env sh +set -eu + +# Branch names for the repository being reconciled. +DEPLOY=main +DEV=next + git fetch origin -# 1. Safety gate. The trees must be identical — that is what makes this -# content-neutral. If they differ, the development branch has unmerged -# work and must not be reset. -[ "$(git rev-parse origin/main^{tree})" = "$(git rev-parse origin/next^{tree})" ] \ - && echo SAFE || echo "STOP: next has unmerged content" +# 1. Safety gate. The trees must be identical — that is what makes the reset +# content-neutral. Differing trees mean the development branch carries work +# the promotion did not include, so abort rather than destroy it. +if [ "$(git rev-parse "origin/$DEPLOY^{tree}")" != "$(git rev-parse "origin/$DEV^{tree}")" ]; then + echo "STOP: $DEV has content not present in $DEPLOY; do not reset" >&2 + exit 1 +fi # 2. Realign the development branch onto the deployed branch. -git checkout next -git reset --hard origin/main +git checkout "$DEV" +git reset --hard "origin/$DEPLOY" # 3. Publish. A force is required: history is being replaced, not extended. -git push --force-with-lease origin next +git push --force-with-lease "origin" "$DEV" ``` Use `--force-with-lease`, never `--force`, so the push aborts if anyone else @@ -152,7 +170,7 @@ has pushed to the branch since the fetch. content: ```sh - [ "$(git rev-parse origin/next)" = "$(git rev-parse origin/main)" ] && echo reconciled + [ "$(git rev-parse "origin/$DEV")" = "$(git rev-parse "origin/$DEPLOY")" ] && echo reconciled ``` ### If the safety gate fails From aec15211c92c42876d1469d7fee25353b715b2e8 Mon Sep 17 00:00:00 2001 From: Sall Date: Wed, 22 Jul 2026 03:02:00 +0100 Subject: [PATCH 3/3] docs(runbooks): make the reconcile robust against ambiguous refnames Review feedback, plus a second fault found while verifying it. git checkout "$DEV" lands in detached HEAD when a tag shares the branch name, reproduced in a scratch repository. The reset in the next step would then move a detached HEAD rather than the branch. Replaced with git checkout -B against an explicit remote-tracking start point, which also works in a fresh clone where no local branch exists yet. Plain -B sets the branch to track the deployed branch, which would misdirect later pulls, so --no-track is used and the upstream is set explicitly to the development branch. The push had the same ambiguity and failed outright with 'src refspec matches more than one'. It now uses a fully-qualified refspec. This was not in the review; it surfaced only when the corrected procedure was run end to end, and it bit the test setup itself before it bit the script. Verified in a repository carrying both a next branch and a next tag: differing trees abort before any destructive step, matching trees run through to a successful push leaving no detached HEAD and upstream set to origin/next. --- runbooks/release.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/runbooks/release.md b/runbooks/release.md index 2c4d29fad..b9eaf480a 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -145,11 +145,18 @@ if [ "$(git rev-parse "origin/$DEPLOY^{tree}")" != "$(git rev-parse "origin/$DEV fi # 2. Realign the development branch onto the deployed branch. -git checkout "$DEV" -git reset --hard "origin/$DEPLOY" +# -B with an explicit remote-tracking start point creates or resets the +# branch in one step. A bare `git checkout "$DEV"` lands in detached HEAD +# when a tag shares the branch name, and the reset would then move a +# detached HEAD instead of the branch. --no-track stops the branch from +# silently tracking the deployed branch afterwards. +git checkout -B "$DEV" --no-track "origin/$DEPLOY" +git branch --set-upstream-to "origin/$DEV" "$DEV" # 3. Publish. A force is required: history is being replaced, not extended. -git push --force-with-lease "origin" "$DEV" +# Use a fully-qualified refspec: a short name fails with +# "src refspec matches more than one" when a tag shares it. +git push --force-with-lease origin "refs/heads/$DEV:refs/heads/$DEV" ``` Use `--force-with-lease`, never `--force`, so the push aborts if anyone else