Skip to content

release: add a script to set up the release-branch ruleset - #651

Open
Philip Lombardi (plombardi89) wants to merge 3 commits into
mainfrom
feat/release-ruleset-script
Open

release: add a script to set up the release-branch ruleset#651
Philip Lombardi (plombardi89) wants to merge 3 commits into
mainfrom
feat/release-ruleset-script

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Release branches carry cherry-picked fixes to shipped versions, including security fixes, so they need the protections main has. 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_checks and code_scanning rules verbatim. The required check contexts, their integration IDs and the reviewer team therefore cannot drift from main's, and --update re-syncs after main changes.

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.yaml on the default branch already triggers on release-*. Every required check comes from that workflow, and the pull_request branch filter is matched against 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, 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:

$ ./hack/scripts/setup-release-ruleset.sh --dry-run
Checking that CI runs on release branches...
error: ci.yaml on the default branch does not trigger on release-* branches.
  Creating this ruleset now would require status checks that can never run on a
  release branch, blocking every pull request to it permanently.

It refuses if the source ruleset has 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 --update is given.

Two findings that shaped it

The rulesets list endpoint returns neither conditions nor rules, 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_ID rather than guessing. Verified against this repository: it matches 14882559 and skips the disabled one.

The four deliberate differences

Applied and explained in place:

ref pattern release-* rather than the default branch
do_not_enforce_on_create: true inert on a branch that is never created; load-bearing on one that is, or creation is gated on checks recorded against that exact SHA
no merge queue a release branch takes occasional cherry-picks; a queue is latency for no benefit
bypass_actors: [] set explicitly rather than inherited, so a bypass added to main is never mirrored onto release branches as a side effect

Review fixes

Four corrections in 1f9451d3, all found in review.

The multi-match guard could not fire. This is the one that mattered. IFS is set to newline and tab, so set -- $MATCHES never split the space-separated ids being counted - two matches still gave $# == 1, and the id passed on was the whole concatenated string:

ids=2 ==> $#=1 ; $1=[14882559 99887766 ]

gh does not reject that. It returns the first ruleset:

$ gh api "repos/Azure/unbounded/rulesets/14882559 19447001 " --jq '.id'
14882559

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:

$ NUM_MATCHES=2 ./hack/scripts/setup-release-ruleset.sh --dry-run
error: several default-branch rulesets require status checks (111, 222); pass --from RULESET_ID to choose

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 --from it 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:

$ FAIL_LIST=1 ./hack/scripts/setup-release-ruleset.sh --dry-run --from 111
error: could not list rulesets for Azure/unbounded (admin access is required)

bypass_actors was 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.yaml cannot 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:

Inherited from "main", and worth knowing before the first cherry-pick:

  - a branch must be up to date with the base before it can merge
  - the most recent push must be approved by someone other than whoever pushed it
  - unattributed changes need an additional approval

A cherry-pick normally records a committer different from the original author,
which counts as unattributed, so an urgent fix can need more approvals than
expected. Plan for that rather than discovering it mid-incident.

Verification

Derivation and payload construction were run against the real main ruleset. Output carries all five expected rules with merge_queue dropped, do_not_enforce_on_create: true, bypass_actors: [], the release-* 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, --from bypasses resolution while still detecting an existing ruleset, --update with nothing to update refuses, and a failed list exits 3.

No tests committed: this runs once or twice, --dry-run shows exactly what would be sent, and standing up a fixture suite in hack/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 the jq.

Usage

# See what it would do
hack/scripts/setup-release-ruleset.sh --dry-run

# Do it
hack/scripts/setup-release-ruleset.sh

# Re-sync after main's required checks change
hack/scripts/setup-release-ruleset.sh --update

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.
@plombardi89
Philip Lombardi (plombardi89) requested a review from a team August 21, 2026 20:28
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant