Skip to content

feat(release-branch-freeze): add code-freeze action for release branches#159

Merged
Piotr1215 merged 2 commits into
mainfrom
devops-1052/release-branch-code-freeze
Jul 3, 2026
Merged

feat(release-branch-freeze): add code-freeze action for release branches#159
Piotr1215 merged 2 commits into
mainfrom
devops-1052/release-branch-code-freeze

Conversation

@Piotr1215

Copy link
Copy Markdown
Contributor

What

Adds a reusable release-branch-freeze composite action that applies or lifts a code freeze on a release branch by managing a GitHub repository ruleset.

  • operation: freeze upserts one reusable ruleset per repo (default name release-branch-code-freeze) onto the branch being released, with the Restrict updates rule and a bypass team. Only that team can merge into the branch.
  • operation: unfreeze disables the ruleset, so the branch returns to its standing rules.
  • The same ruleset is re-pointed at each new release branch, so only the branch in its release window is frozen; other release lines keep their normal rules.
  • enforcement input supports active (default), evaluate (dry run, logs would-be blocks without blocking), and disabled.

Closes DEVOPS-1052.

Why this mechanism

Classic branch protection cannot express manager-only merges: its "restrict who can push" setting gates direct pushes but not the PR merge button, so any writer could still merge. A ruleset with the Restrict updates rule does gate merges, because a PR merge is a ref update. The org already runs on rulesets, so a per-branch freeze ruleset layers on top of the standing protection and removing it restores the exact prior state.

Repo-level (not org-level) so a repo-admin token or GitHub App can manage it in CI without org-admin scope.

Integration test (vClusterLabs-Experiments)

The end-to-end ruleset behavior was validated against a throwaway repo, vClusterLabs-Experiments/devops-1052-freeze-test, before writing the action:

  • State A, ruleset active with an empty bypass list: a PR merge was BLOCKED, including for an org owner. Confirms the freeze stops everyone not on the bypass list.
  • State B, same ruleset with a bypass actor added: the PR merge SUCCEEDED via the REST merge API. The reported "merge button greyed out for bypass users" quirk did not reproduce.

Bypass in that run used the admin repository role, since the experiments repo has no team; GitHub evaluates bypass actor types the same way at merge time, so the Team bypass this action uses behaves identically.

Tests

  • make test-release-branch-freeze runs the bats suite against src/freeze.sh with a stubbed gh, covering input validation, freeze create and update (upsert), enforcement modes, custom ruleset name, unfreeze disable, unfreeze no-op, and error propagation.
  • shellcheck clean, actionlint and zizmor clean, docs generated with auto-doc.

Follow-ups (separate PRs, not in this one)

  • Caller workflows in vcluster-pro (branch pattern v*.*, stable tag v*.*.0) and loft-enterprise (branch pattern release-*), each wiring freeze on branch create and unfreeze on the stable tag. The two repos have different branch naming, so the callers differ; the action itself is repo-agnostic.
  • Provision the CODE_FREEZE_TOKEN secret (Administration: read and write on the target repos).
  • Confirm the bypass team: default is Eng-Tech-Leads (id 16898535).
  • Tag the action release-branch-freeze/v1 after merge.

Comment thread .github/actions/release-branch-freeze/src/freeze.sh Outdated
Comment thread .github/actions/release-branch-freeze/src/freeze.sh Outdated
Comment thread .github/actions/release-branch-freeze/test/freeze.bats
Comment thread .github/actions/release-branch-freeze/test/freeze.bats Outdated
Comment thread .github/actions/release-branch-freeze/action.yml Outdated
Piotr1215 added a commit that referenced this pull request Jul 2, 2026
…and create path

Findings from the PR review swarm on #159, all correctness or test-coverage
gaps in the freeze action:

- find_ruleset_id listed rulesets without --paginate. The REST API caps a page
  at 30 rulesets, so on a repo with more than one page the freeze ruleset could
  sit on a later page and be missed: freeze would then create a duplicate and
  unfreeze would report "nothing to unfreeze" while the branch stayed frozen.
  Paginate the list, as sibling gh-CLI actions (sticky-pr-comment) already do.

- The create path parsed the POST response with jq '.id', which emits the
  literal "null" when the 2xx body has no id. [ -n "null" ] is true, so we wrote
  ruleset-id=null and exited 0, reporting a freeze that may not have applied.
  Use '.id // empty' and fail loudly, matching find_ruleset_id's own guard.

- The unfreeze test asserted only the ::notice:: log line, never that the PUT
  carried enforcement=disabled, and no unfreeze test checked ruleset-id reached
  GITHUB_OUTPUT. The gh mock now records -f key=value fields so the test can
  assert both.

- The error-propagation test only failed the initial GET (read side). Added a
  write-failure knob (GH_MOCK_FAIL_WRITE) so a test proves the PUT path
  surfaces errors, plus a no-id knob (GH_MOCK_POST_NO_ID) covering the new
  create guard.

- Output description Id -> ID; README regenerated via make generate-docs.
@Piotr1215 Piotr1215 requested a review from sydorovdmytro July 2, 2026 08:59
Piotr1215 added a commit that referenced this pull request Jul 3, 2026
…and create path

Findings from the PR review swarm on #159, all correctness or test-coverage
gaps in the freeze action:

- find_ruleset_id listed rulesets without --paginate. The REST API caps a page
  at 30 rulesets, so on a repo with more than one page the freeze ruleset could
  sit on a later page and be missed: freeze would then create a duplicate and
  unfreeze would report "nothing to unfreeze" while the branch stayed frozen.
  Paginate the list, as sibling gh-CLI actions (sticky-pr-comment) already do.

- The create path parsed the POST response with jq '.id', which emits the
  literal "null" when the 2xx body has no id. [ -n "null" ] is true, so we wrote
  ruleset-id=null and exited 0, reporting a freeze that may not have applied.
  Use '.id // empty' and fail loudly, matching find_ruleset_id's own guard.

- The unfreeze test asserted only the ::notice:: log line, never that the PUT
  carried enforcement=disabled, and no unfreeze test checked ruleset-id reached
  GITHUB_OUTPUT. The gh mock now records -f key=value fields so the test can
  assert both.

- The error-propagation test only failed the initial GET (read side). Added a
  write-failure knob (GH_MOCK_FAIL_WRITE) so a test proves the PUT path
  surfaces errors, plus a no-id knob (GH_MOCK_POST_NO_ID) covering the new
  create guard.

- Output description Id -> ID; README regenerated via make generate-docs.
@Piotr1215 Piotr1215 force-pushed the devops-1052/release-branch-code-freeze branch from affb5af to a56e759 Compare July 3, 2026 13:47
Piotr1215 added 2 commits July 3, 2026 15:58
DEVOPS-1052. When a release branch is cut, the window between
feature-complete and the stable tag must stay manager-only so late
changes are deliberate and reviewed, then relax once the stable tag
lands. There was no repeatable, automatable way to do this.

Classic branch protection cannot express it: its push restriction gates
direct pushes but not PR merges. A GitHub ruleset with the "Restrict
updates" rule does gate merges (a PR merge is a ref update), so this
action manages one reusable ruleset per repo. freeze upserts the ruleset
onto the branch being released with a bypass team; unfreeze disables it.
Only the branch in its release window is affected, so other release
lines keep their normal rules.

Kept repo-level so a repo-admin token manages it without org-admin
scope. Verified end to end against a throwaway repo in
vClusterLabs-Experiments: a non-bypass merge is blocked, a bypass merge
succeeds via the PR merge API.
…and create path

Findings from the PR review swarm on #159, all correctness or test-coverage
gaps in the freeze action:

- find_ruleset_id listed rulesets without --paginate. The REST API caps a page
  at 30 rulesets, so on a repo with more than one page the freeze ruleset could
  sit on a later page and be missed: freeze would then create a duplicate and
  unfreeze would report "nothing to unfreeze" while the branch stayed frozen.
  Paginate the list, as sibling gh-CLI actions (sticky-pr-comment) already do.

- The create path parsed the POST response with jq '.id', which emits the
  literal "null" when the 2xx body has no id. [ -n "null" ] is true, so we wrote
  ruleset-id=null and exited 0, reporting a freeze that may not have applied.
  Use '.id // empty' and fail loudly, matching find_ruleset_id's own guard.

- The unfreeze test asserted only the ::notice:: log line, never that the PUT
  carried enforcement=disabled, and no unfreeze test checked ruleset-id reached
  GITHUB_OUTPUT. The gh mock now records -f key=value fields so the test can
  assert both.

- The error-propagation test only failed the initial GET (read side). Added a
  write-failure knob (GH_MOCK_FAIL_WRITE) so a test proves the PUT path
  surfaces errors, plus a no-id knob (GH_MOCK_POST_NO_ID) covering the new
  create guard.

- Output description Id -> ID; README regenerated via make generate-docs.
@Piotr1215 Piotr1215 force-pushed the devops-1052/release-branch-code-freeze branch from a56e759 to b5ab133 Compare July 3, 2026 13:58
@Piotr1215 Piotr1215 merged commit 3533e13 into main Jul 3, 2026
5 checks passed
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.

3 participants