A dispatchable workflow that freezes — or unfreezes — the branches of some or all Spring Cloud projects, so nothing can be pushed to them while a release is being staged.
An OSS release is built on a release/<version> branch in the commercial repo, and post-release later merges those commits back into the OSS .x branch and pushes the tag there. Anything that lands on .x in between turns that merge into a conflict — most sharply on the version lines, since update-versions may have moved the branch to the next snapshots in the meantime. Freezing the branches for the duration removes the problem rather than managing it.
It:
- Builds a matrix from
config/projects.json— one entry per repository, carrying every branch to freeze - Creates, updates or deletes each repository's
Release Freezeruleset - Writes a summary table and fails the run if any repository could not be done
A repository ruleset named Release Freeze, holding exactly the branches the run froze:
{ "name": "Release Freeze", "target": "branch", "enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/main", "..."], "exclude": [] } },
"rules": [ {"type":"creation"},
{"type":"update","parameters":{"update_allows_fetch_and_merge":false}},
{"type":"deletion"},
{"type":"non_fast_forward"} ] }Between them those four rules reject a push, a force push, a delete, and a re-create. Unlocking deletes the ruleset outright.
retire-branch also locks branches, using a long-lived ruleset of its own called Locked Branches that accumulates retired branches permanently. This workflow never touches that one. Rulesets are additive — GitHub aggregates every ruleset matching a ref and applies the most restrictive — so the two coexist, and a branch that is both retired and frozen stays locked when the freeze is lifted.
Keeping them apart is what makes this workflow's writes whole-object: a single POST to freeze and a single DELETE to unfreeze. Sharing one ruleset would mean a read-modify-write of a list another workflow appends to, with all of the attendant hazards — dropping somebody else's refs, having to echo bypass_actors back on every write, and an unfreeze that could un-retire a retired branch. None of those are possible here.
Only the Release Freeze ruleset is managed. A branch can still be locked afterwards by the Locked Branches ruleset, by an organization-level ruleset, or by classic branch protection — none of which this workflow reads or writes. If a push is still rejected after a successful unlock, that is where to look.
| Input | Description | Required | Type |
|---|---|---|---|
projects |
Comma-separated project names, bare — no -commercial suffix, repo_type carries that. Empty processes every project. |
No | string |
unlock |
Unlock instead of lock — deletes the Release Freeze ruleset from each repository. |
No | boolean (default: false) |
repo_type |
Which flavours to act on: both, oss, or commercial |
No | choice (default: both) |
dry_run |
Report what would change without creating, updating or deleting anything | No | boolean (default: true) |
token |
Token with administration: write on all target repos. Falls back to GH_ACTIONS_REPO_TOKEN. |
No | string |
Note the projects names are bare here, unlike post-release and update-versions where the -commercial suffix decides the flavour. This workflow takes repo_type instead, matching ci-status-report and rollout-deploy-docs.
The workflow is also callable (workflow_call), so the unlock can be wired into a release workflow later. workflow_call has no choice type, so repo_type is a plain string on that side and is validated in the setup job.
No bypass_actors is set, so a frozen branch rejects every push, including the one GH_ACTIONS_REPO_TOKEN makes. post-release cannot merge the release branch back, push the version bump, or push the tag to a frozen branch — and since publishing the GitHub release now depends on that tag arriving, a forgotten unfreeze stops the release, visibly.
So the unlock has to run before post-release, not after it. The summary of a successful lock says so explicitly. Post-release checks each branch before merging and lists any still-frozen ones under Branches frozen, with the projects list to unlock. A dry run warns about them, so running one first catches a forgotten unlock.
Every branch under the chosen section's branches in projects.json — the deduped union of branches.scheduled and branches.default. As of writing that is 79 branches across 34 repository sections.
The union rather than just scheduled because a freeze that let commits through on the default branch would be worse than no freeze at all. default is a subset of scheduled everywhere in the file today, but nothing enforces that.
All of a repository's frozen branches live in a single ruleset, so the fan-out is per repository and each leg makes one API call. A per-branch fan-out would have several legs writing the same object at once.
For the same reason across runs, the workflow declares concurrency: { group: lock-branches, cancel-in-progress: false } — two overlapping runs in opposite directions would otherwise resolve by whichever write landed last. It queues rather than cancels: stopping a run mid-write would leave some repositories frozen and others not.
Unlocking deletes the whole Release Freeze ruleset, which unfreezes every branch in it — not just the ones named by that run. That matches the filter being per project rather than per branch, and the ruleset only ever contains what a previous lock run put there.
To unfreeze part of the estate, filter by projects.
dry_rundefaults totrue. A dry run prints the exact request body it would send and makes no write call.- A repository already in the requested state is reported and skipped, with no API call — so a re-run is free, and the summary distinguishes "froze it" from "was already frozen".
- A leg never fails. Every API call is checked; failures are recorded and reported, and the summary job is what exits non-zero. A leg that died silently would drop that repository from the table, which for a freeze reads as success.
fail-fast: falseso one bad repository does not abandon the rest, andmax-parallel: 8keeps the fan-out from saturating the runner pool.- An unknown name in
projectsfails the run, listing the known ones. Processing nothing and reporting success is the wrong answer when the question was "is everything frozen?".
- Dry run, one project:
projects: spring-cloud-config,repo_type: oss. Check the branch list and the printed ruleset body. - Dry run, everything: check the summary covers what you expect.
- Real run:
dry_run: false. Confirm withgh api repos/spring-cloud/spring-cloud-config/rulesets --jq '.[] | select(.name=="Release Freeze")'. - When the tags exist: re-run with
unlockchecked, before post-release.
- post-release.yml — merges the release branch back and pushes the tag; needs the branches unfrozen first
- update-versions.yml — the snapshot bump, which also pushes to these branches
- retire-branch.yml — the other user of branch-locking rulesets, permanent rather than temporary