release: add a script to set up the release-branch ruleset - #651
Open
Philip Lombardi (plombardi89) wants to merge 3 commits into
Open
release: add a script to set up the release-branch ruleset#651Philip Lombardi (plombardi89) wants to merge 3 commits into
Philip Lombardi (plombardi89) wants to merge 3 commits into
Conversation
Release branches carry cherry-picked fixes to shipped versions, including
security fixes, so they need the protections main has. Creating that
ruleset needs repository admin rights, so it cannot live in a workflow.
The protections are not written here. The script reads the default
branch's ruleset and reuses its pull-request, status-check and
code-scanning rules verbatim, so the required checks and reviewers cannot
drift from main's; --update re-syncs after main's change. Writing them out
by hand would have rotted the first time main's checks moved.
Three guards, which are most of the point:
- It refuses unless ci.yaml on the default branch already triggers on
release-*. Every required check comes from that workflow, and trigger
lists match the pull request's BASE branch, so requiring those checks
on a branch where ci.yaml never runs blocks every pull request to it
permanently. That ordering constraint has been carried in prose across
an issue and a workflow comment; it is now executable. It correctly
refuses today, since that change has not landed.
- It refuses if the source ruleset contains a `creation` rule, which
would be inherited and would stop create-release-branch.yaml from
creating a branch at all.
- It refuses to clobber an existing release-* ruleset unless asked.
Two findings shaped it. The rulesets list endpoint returns neither
conditions nor rules, so candidates have to be fetched one at a time. And
more than one ruleset can target the default branch - this repository has
a disabled Copilot-review one alongside the real protections - so the
discriminator is requiring status checks, and it refuses to guess if that
still matches several.
The three deliberate differences from the source are applied and explained
in place: the ref pattern; do_not_enforce_on_create, which is inert on a
branch that is never created and load-bearing on one that is; and no merge
queue for a branch taking occasional cherry-picks.
Follows hack/scripts/setup-deploy-environment.sh for flags, exit codes and
the confirmation prompt. --dry-run prints the payload and changes nothing.
Part of #627.
The multi-match guard could not fire. IFS is set to newline and tab, so `set -- $MATCHES` never split the space-separated ids it was counting: two matches still produced $# == 1, and the id passed on was the whole concatenated string. `gh api repos/.../rulesets/<a> <b>` does not reject that, it returns the first ruleset, so a guard written to refuse an ambiguous repository instead picked one silently and reported it as the mirror source. Count with an array instead. Fold the second scan, which looked for an existing release ruleset, into the first. That call had no `|| api_die`, so a failed list looked like "no release ruleset exists" and would have created a duplicate rather than refusing; with --from it was also the first list call made. One pass now answers both questions and halves the per-ruleset fetches. Set bypass_actors explicitly and document it as a fourth deliberate difference. The source has none today so nothing changes now, but --update sends a full PUT: inheriting the field would carry a bypass added to main onto release branches silently, and would equally wipe one added to the release ruleset by hand. Also correct the comment above the CI guard. The pull_request branch filter does match the base branch, but the workflow comes from the branch's own copy, which is what makes adding release-* as a branch's first commit an effective remedy. Exit 1 rather than 3 when ci.yaml cannot be read, since that is an unmet prerequisite and not an API failure, and print the inherited strictness that meets cherry-picks head-on: unattributed changes need an extra approval, and a cherry-pick normally records a committer other than the author.
This was referenced Aug 24, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release branches carry cherry-picked fixes to shipped versions, including security fixes, so they need the protections
mainhas. Creating that ruleset requires repository admin rights, so it cannot live in a workflow - but it can live in a script that anyone with those rights can run and anyone can review.Independent of #648. Its only prerequisite is #643.
It mirrors rather than restates
The script reads the default branch's ruleset and reuses its
pull_request,required_status_checksandcode_scanningrules verbatim. The required check contexts, their integration IDs and the reviewer team therefore cannot drift frommain's, and--updatere-syncs aftermainchanges.The version of this I first wrote for #627 hardcoded all of it, and would have rotted the first time
main's checks moved.The guards are most of the point
It refuses unless
ci.yamlon the default branch already triggers onrelease-*. Every required check comes from that workflow, and thepull_requestbranch filter is matched against the pull request's base branch - so requiring those checks on a branch whereci.yamlnever runs blocks every pull request to it permanently, with no remedy but removing the ruleset again. That constraint has been carried in prose across an issue comment and a workflow comment; this makes it executable.It correctly refuses today, since #643 has not merged:
It refuses if the source ruleset has a
creationrule, which would be inherited and would stopcreate-release-branch.yamlfrom creating a branch at all.It refuses to clobber an existing
release-*ruleset unless--updateis given.Two findings that shaped it
The rulesets list endpoint returns neither
conditionsnorrules, so candidates must be fetched one at a time.And more than one ruleset can target the default branch - this repository has a disabled "Code Quality Copilot review" alongside the real protections. The discriminator is requiring status checks, and if that still matches several the script refuses and asks for
--from RULESET_IDrather than guessing. Verified against this repository: it matches14882559and skips the disabled one.The four deliberate differences
Applied and explained in place:
release-*rather than the default branchdo_not_enforce_on_create: truebypass_actors: []mainis never mirrored onto release branches as a side effectReview fixes
Four corrections in
1f9451d3, all found in review.The multi-match guard could not fire. This is the one that mattered.
IFSis set to newline and tab, soset -- $MATCHESnever split the space-separated ids being counted - two matches still gave$# == 1, and the id passed on was the whole concatenated string:ghdoes not reject that. It returns the first ruleset:So a guard written to refuse an ambiguous repository instead picked one silently and reported it as the mirror source - it failed open, which is worse than not having the guard, because the summary then states a confident and wrong "Mirroring:" line. Counting with an array fixes it, and the refusal now actually fires:
A failed ruleset list was silently treated as "no release ruleset exists". The second scan had no
|| api_die, so a list failure would have produced a duplicate ruleset rather than an error; with--fromit was also the first list call made. Both scans are now one pass, which fixes the unchecked call and halves the per-ruleset fetches. It now exits 3:bypass_actorswas an undeclared difference, now explicit and documented as above.The comment above the CI guard misstated the mechanism. The branch filter does match the base branch, but the workflow comes from the branch's own copy - which is what makes adding
release-*as a release branch's first commit an effective remedy for a branch cut from an older tag.Plus: exit 1 rather than 3 when
ci.yamlcannot be read, since that is an unmet prerequisite and not an API failure; and the run now ends by printing the inherited strictness that lands differently on a release branch, read back from the payload rather than hardcoded:Verification
Derivation and payload construction were run against the real
mainruleset. Output carries all five expected rules withmerge_queuedropped,do_not_enforce_on_create: true,bypass_actors: [], therelease-*condition, and all eight required checks derived rather than typed.The guard paths were exercised against a stubbed
gh: two matching rulesets refuse, one proceeds,--frombypasses resolution while still detecting an existing ruleset,--updatewith nothing to update refuses, and a failed list exits 3.No tests committed: this runs once or twice,
--dry-runshows exactly what would be sent, and standing up a fixture suite inhack/scripts/- which has no test convention today - was not judged proportionate. Worth noting that the reviewer's suggested payload-derivation test would not have caught the bug above, which was in the match counting rather than thejq.Usage