Skip to content

Commit 3533e13

Browse files
authored
feat(release-branch-freeze): add code-freeze action for release branches (#159)
* feat(release-branch-freeze): add code-freeze action for release branches 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. * fix(release-branch-freeze): address pr review, harden ruleset lookup 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.
1 parent 2de17e8 commit 3533e13

8 files changed

Lines changed: 653 additions & 2 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Release Branch Code Freeze
2+
3+
Applies or lifts a temporary code freeze on a release branch by managing a
4+
GitHub repository ruleset with the "Restrict updates" rule. During the freeze
5+
only a bypass team can merge into the branch (a PR merge counts as an update, so
6+
everyone else is blocked); lifting the freeze disables the ruleset so the branch
7+
returns to its standing rules.
8+
9+
One reusable ruleset per repo (default name `release-branch-code-freeze`) is
10+
re-pointed at the branch being released, so only that branch is frozen while
11+
other release lines keep their normal rules. Uses the GitHub CLI (`gh`),
12+
pre-installed on hosted runners.
13+
14+
## Inputs
15+
16+
<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->
17+
18+
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
19+
|----------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
20+
| branch | string | false | | Release branch to freeze or unfreeze, <br>e.g. v0.36 or release-4.11. Required for <br>freeze. |
21+
| bypass-team-id | string | false | | Numeric GitHub team id allowed to <br>merge during the freeze (required for <br>freeze). Find it with: gh api <br>orgs/<org>/teams/<slug> --jq .id |
22+
| enforcement | string | false | `"active"` | active | evaluate | disabled. evaluate <br>is a dry run that logs <br>would-be blocks without blocking. Default active. |
23+
| operation | string | true | | freeze (apply the code freeze) or unfreeze (lift it). |
24+
| repository | string | true | | Target repository in owner/name form. |
25+
| ruleset-name | string | false | | Override the ruleset name. Default release-branch-code-freeze. |
26+
27+
<!-- AUTO-DOC-INPUT:END -->
28+
29+
## Outputs
30+
31+
<!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section -->
32+
33+
| OUTPUT | TYPE | DESCRIPTION |
34+
|------------|--------|-----------------------------------------------------------------------|
35+
| ruleset-id | string | ID of the freeze ruleset that <br>was created, updated, or disabled. |
36+
37+
<!-- AUTO-DOC-OUTPUT:END -->
38+
39+
## Usage
40+
41+
### Freeze when a release branch is cut
42+
43+
```yaml
44+
name: Code freeze on release branch
45+
on:
46+
create
47+
48+
permissions:
49+
contents: read
50+
51+
jobs:
52+
freeze:
53+
# `create` fires for every ref; only act on release branches.
54+
if: github.event.ref_type == 'branch' && startsWith(github.event.ref, 'v')
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: loft-sh/github-actions/.github/actions/release-branch-freeze@release-branch-freeze/v1
58+
with:
59+
operation: freeze
60+
repository: ${{ github.repository }}
61+
branch: ${{ github.event.ref }}
62+
bypass-team-id: "16898535" # loft-sh/Eng-Tech-Leads
63+
env:
64+
GH_TOKEN: ${{ secrets.CODE_FREEZE_TOKEN }}
65+
```
66+
67+
Run the first rollout with `enforcement: evaluate` to log who would be blocked
68+
without blocking anyone, then switch to the default `active`.
69+
70+
### Unfreeze when the stable tag is cut
71+
72+
```yaml
73+
name: Lift code freeze on stable tag
74+
on:
75+
push:
76+
tags:
77+
- 'v[0-9]+.[0-9]+.0' # first stable release of a line
78+
79+
permissions:
80+
contents: read
81+
82+
jobs:
83+
unfreeze:
84+
runs-on: ubuntu-latest
85+
steps:
86+
- uses: loft-sh/github-actions/.github/actions/release-branch-freeze@release-branch-freeze/v1
87+
with:
88+
operation: unfreeze
89+
repository: ${{ github.repository }}
90+
env:
91+
GH_TOKEN: ${{ secrets.CODE_FREEZE_TOKEN }}
92+
```
93+
94+
`unfreeze` disables the named ruleset, so it needs neither `branch` nor
95+
`bypass-team-id`.
96+
97+
## Auth
98+
99+
`GH_TOKEN` must be set as an environment variable (not an input). It must be a
100+
Personal Access Token or GitHub App token with **Administration: read and
101+
write** on the target repository, because rulesets are administered at that
102+
level. `secrets.GITHUB_TOKEN` cannot manage rulesets. The token does not need
103+
org-admin scope: the freeze ruleset is repo-level.
104+
105+
The bypass team is referenced by numeric id (find it with
106+
`gh api orgs/<org>/teams/<slug> --jq .id`), so the token needs no org-read
107+
permission at run time.
108+
109+
## Enforcement modes
110+
111+
| Mode | Effect |
112+
|---|---|
113+
| `active` | Freeze is enforced: only the bypass team can merge. Default. |
114+
| `evaluate` | Dry run: would-be blocks are logged in the repo's ruleset insights, nobody is blocked. Use for a first rollout. |
115+
| `disabled` | Ruleset enforces nothing. This is the state `unfreeze` leaves it in. |
116+
117+
## Testing
118+
119+
```bash
120+
make test-release-branch-freeze
121+
```
122+
123+
Runs the bats suite in `test/` against `src/freeze.sh` with a stubbed `gh` on
124+
`PATH`. The end-to-end ruleset behavior (non-bypass merge blocked, bypass merge
125+
allowed) was validated against a throwaway repo in the
126+
`vClusterLabs-Experiments` org.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Release Branch Code Freeze
2+
description: |
3+
Applies or lifts a temporary code freeze on a release branch by managing a
4+
GitHub repository ruleset with the "Restrict updates" rule. During the freeze
5+
only a bypass team can merge into the branch; lifting the freeze disables the
6+
ruleset so the branch returns to its standing rules.
7+
8+
One reusable ruleset per repo is re-pointed at the branch being released, so
9+
only that branch is affected while other release lines keep their normal
10+
rules. Uses the GitHub CLI (gh), pre-installed on hosted runners.
11+
12+
The caller must expose a token with Administration:write on the target repo
13+
as the GH_TOKEN environment variable (job or step env). secrets.GITHUB_TOKEN
14+
cannot manage rulesets.
15+
inputs:
16+
operation:
17+
description: 'freeze (apply the code freeze) or unfreeze (lift it).'
18+
required: true
19+
repository:
20+
description: 'Target repository in owner/name form.'
21+
required: true
22+
branch:
23+
description: 'Release branch to freeze or unfreeze, e.g. v0.36 or release-4.11. Required for freeze.'
24+
required: false
25+
default: ''
26+
bypass-team-id:
27+
description: |
28+
Numeric GitHub team id allowed to merge during the freeze (required for
29+
freeze). Find it with: gh api orgs/<org>/teams/<slug> --jq .id
30+
required: false
31+
enforcement:
32+
description: 'active | evaluate | disabled. evaluate is a dry run that logs would-be blocks without blocking. Default active.'
33+
required: false
34+
default: 'active'
35+
ruleset-name:
36+
description: 'Override the ruleset name. Default release-branch-code-freeze.'
37+
required: false
38+
default: ''
39+
outputs:
40+
ruleset-id:
41+
description: 'ID of the freeze ruleset that was created, updated, or disabled.'
42+
value: ${{ steps.run.outputs.ruleset-id }}
43+
runs:
44+
using: composite
45+
steps:
46+
- name: Manage code-freeze ruleset
47+
id: run
48+
shell: bash
49+
env:
50+
INPUT_OPERATION: ${{ inputs.operation }}
51+
INPUT_REPOSITORY: ${{ inputs.repository }}
52+
INPUT_BRANCH: ${{ inputs.branch }}
53+
INPUT_BYPASS_TEAM_ID: ${{ inputs.bypass-team-id }}
54+
INPUT_ENFORCEMENT: ${{ inputs.enforcement }}
55+
INPUT_RULESET_NAME: ${{ inputs.ruleset-name }}
56+
run: ${{ github.action_path }}/src/freeze.sh
57+
branding:
58+
icon: 'lock'
59+
color: 'orange'
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env bash
2+
# Manage a release-branch code freeze via a GitHub repository ruleset.
3+
#
4+
# The freeze is a single reusable ruleset per repo (default name
5+
# "release-branch-code-freeze") carrying the "Restrict updates" rule. Only
6+
# actors on its bypass list can update (merge into) the targeted branch, and a
7+
# PR merge counts as an update, so non-bypass users cannot merge during a freeze.
8+
#
9+
# freeze upsert the ruleset so it targets refs/heads/<branch> with the
10+
# chosen enforcement and a bypass team. That team is then the only
11+
# one that can merge into the branch.
12+
# unfreeze set the ruleset's enforcement to "disabled" so the branch falls
13+
# back to the repo's standing rules. The object is kept, ready to be
14+
# re-pointed at the next release branch.
15+
#
16+
# freeze re-points the same ruleset at the branch being released, so only that
17+
# branch is affected; other release branches keep their normal rules.
18+
#
19+
# Required env:
20+
# GH_TOKEN PAT or GitHub App token with Administration:write on
21+
# INPUT_REPOSITORY. secrets.GITHUB_TOKEN cannot manage
22+
# rulesets.
23+
# INPUT_OPERATION "freeze" or "unfreeze".
24+
# INPUT_REPOSITORY Target repo, owner/name.
25+
# INPUT_BRANCH Release branch, e.g. "v0.36" or "release-4.11".
26+
# Required for freeze:
27+
# INPUT_BYPASS_TEAM_ID Numeric team id allowed to merge during the freeze
28+
# (e.g. Eng-Tech-Leads). Find it with:
29+
# gh api orgs/<org>/teams/<slug> --jq .id
30+
# Optional:
31+
# INPUT_ENFORCEMENT active | evaluate | disabled (default "active").
32+
# evaluate = dry run: logs would-be blocks in the repo's
33+
# ruleset insights but blocks nothing.
34+
# INPUT_RULESET_NAME Override the ruleset name (default
35+
# "release-branch-code-freeze").
36+
set -euo pipefail
37+
38+
: "${GH_TOKEN:?GH_TOKEN required (Administration:write on the target repo)}"
39+
: "${INPUT_OPERATION:?operation required (freeze|unfreeze)}"
40+
: "${INPUT_REPOSITORY:?repository required (owner/name)}"
41+
42+
REPO="$INPUT_REPOSITORY"
43+
BRANCH="${INPUT_BRANCH:-}"
44+
RULESET_NAME="${INPUT_RULESET_NAME:-release-branch-code-freeze}"
45+
46+
# Echo the id of the freeze ruleset (matched by name), or nothing.
47+
# --paginate so a repo with more than one page of rulesets (30 per page)
48+
# can't hide the freeze ruleset on a later page and make us create a second.
49+
find_ruleset_id() {
50+
gh api --paginate "repos/${REPO}/rulesets" |
51+
jq -r --arg n "$RULESET_NAME" 'map(select(.name == $n)) | (.[0].id // empty)'
52+
}
53+
54+
write_output() {
55+
echo "$1=$2" >> "${GITHUB_OUTPUT:-/dev/stdout}"
56+
}
57+
58+
case "$INPUT_OPERATION" in
59+
freeze)
60+
: "${INPUT_BRANCH:?branch required for freeze}"
61+
: "${INPUT_BYPASS_TEAM_ID:?bypass-team-id required for freeze}"
62+
if ! [[ "$INPUT_BYPASS_TEAM_ID" =~ ^[0-9]+$ ]]; then
63+
echo "::error::bypass-team-id must be numeric (got: ${INPUT_BYPASS_TEAM_ID})"
64+
exit 1
65+
fi
66+
ENFORCEMENT="${INPUT_ENFORCEMENT:-active}"
67+
case "$ENFORCEMENT" in
68+
active | evaluate | disabled) ;;
69+
*)
70+
echo "::error::enforcement must be active, evaluate, or disabled (got: ${ENFORCEMENT})"
71+
exit 1
72+
;;
73+
esac
74+
75+
# Build the payload with jq so branch names are JSON-escaped correctly.
76+
BODY=$(jq -n \
77+
--arg name "$RULESET_NAME" \
78+
--arg ref "refs/heads/${BRANCH}" \
79+
--arg enforcement "$ENFORCEMENT" \
80+
--argjson team_id "$INPUT_BYPASS_TEAM_ID" \
81+
'{
82+
name: $name,
83+
target: "branch",
84+
enforcement: $enforcement,
85+
conditions: { ref_name: { include: [ $ref ], exclude: [] } },
86+
rules: [ { type: "update" } ],
87+
bypass_actors: [ { actor_type: "Team", actor_id: $team_id, bypass_mode: "always" } ]
88+
}')
89+
90+
RID="$(find_ruleset_id)"
91+
if [ -n "$RID" ]; then
92+
echo "::notice::updating ruleset ${RULESET_NAME} (id ${RID}) on ${REPO} -> ${BRANCH} (${ENFORCEMENT})"
93+
gh api -X PUT "repos/${REPO}/rulesets/${RID}" --input - <<<"$BODY" >/dev/null
94+
else
95+
echo "::notice::creating ruleset ${RULESET_NAME} on ${REPO} -> ${BRANCH} (${ENFORCEMENT})"
96+
RID="$(gh api -X POST "repos/${REPO}/rulesets" --input - <<<"$BODY" | jq -r '.id // empty')"
97+
if [ -z "$RID" ]; then
98+
echo "::error::ruleset POST succeeded but response contained no id; freeze may not have been applied"
99+
exit 1
100+
fi
101+
fi
102+
write_output "ruleset-id" "$RID"
103+
echo "::notice::freeze ${ENFORCEMENT}: only team ${INPUT_BYPASS_TEAM_ID} may merge into ${BRANCH} on ${REPO}"
104+
;;
105+
unfreeze)
106+
RID="$(find_ruleset_id)"
107+
if [ -z "$RID" ]; then
108+
echo "::notice::no ruleset named ${RULESET_NAME} on ${REPO}; nothing to unfreeze"
109+
exit 0
110+
fi
111+
gh api -X PUT "repos/${REPO}/rulesets/${RID}" -f enforcement=disabled >/dev/null
112+
write_output "ruleset-id" "$RID"
113+
echo "::notice::unfreeze: ruleset ${RULESET_NAME} (id ${RID}) on ${REPO} set to disabled"
114+
;;
115+
*)
116+
echo "::error::operation must be 'freeze' or 'unfreeze' (got: ${INPUT_OPERATION})"
117+
exit 1
118+
;;
119+
esac

0 commit comments

Comments
 (0)