Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/actions/release-branch-freeze/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Release Branch Code Freeze

Applies or lifts a temporary code freeze on a release branch by managing a
GitHub repository ruleset with the "Restrict updates" rule. During the freeze
only a bypass team can merge into the branch (a PR merge counts as an update, so
everyone else is blocked); lifting the freeze disables the ruleset so the branch
returns to its standing rules.

One reusable ruleset per repo (default name `release-branch-code-freeze`) is
re-pointed at the branch being released, so only that branch is frozen while
other release lines keep their normal rules. Uses the GitHub CLI (`gh`),
pre-installed on hosted runners.

## Inputs

<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->

| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|----------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| branch | string | false | | Release branch to freeze or unfreeze, <br>e.g. v0.36 or release-4.11. Required for <br>freeze. |
| 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 |
| enforcement | string | false | `"active"` | active | evaluate | disabled. evaluate <br>is a dry run that logs <br>would-be blocks without blocking. Default active. |
| operation | string | true | | freeze (apply the code freeze) or unfreeze (lift it). |
| repository | string | true | | Target repository in owner/name form. |
| ruleset-name | string | false | | Override the ruleset name. Default release-branch-code-freeze. |

<!-- AUTO-DOC-INPUT:END -->

## Outputs

<!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section -->

| OUTPUT | TYPE | DESCRIPTION |
|------------|--------|-----------------------------------------------------------------------|
| ruleset-id | string | ID of the freeze ruleset that <br>was created, updated, or disabled. |

<!-- AUTO-DOC-OUTPUT:END -->

## Usage

### Freeze when a release branch is cut

```yaml
name: Code freeze on release branch
on:
create

permissions:
contents: read

jobs:
freeze:
# `create` fires for every ref; only act on release branches.
if: github.event.ref_type == 'branch' && startsWith(github.event.ref, 'v')
runs-on: ubuntu-latest
steps:
- uses: loft-sh/github-actions/.github/actions/release-branch-freeze@release-branch-freeze/v1
with:
operation: freeze
repository: ${{ github.repository }}
branch: ${{ github.event.ref }}
bypass-team-id: "16898535" # loft-sh/Eng-Tech-Leads
env:
GH_TOKEN: ${{ secrets.CODE_FREEZE_TOKEN }}
```

Run the first rollout with `enforcement: evaluate` to log who would be blocked
without blocking anyone, then switch to the default `active`.

### Unfreeze when the stable tag is cut

```yaml
name: Lift code freeze on stable tag
on:
push:
tags:
- 'v[0-9]+.[0-9]+.0' # first stable release of a line

permissions:
contents: read

jobs:
unfreeze:
runs-on: ubuntu-latest
steps:
- uses: loft-sh/github-actions/.github/actions/release-branch-freeze@release-branch-freeze/v1
with:
operation: unfreeze
repository: ${{ github.repository }}
env:
GH_TOKEN: ${{ secrets.CODE_FREEZE_TOKEN }}
```

`unfreeze` disables the named ruleset, so it needs neither `branch` nor
`bypass-team-id`.

## Auth

`GH_TOKEN` must be set as an environment variable (not an input). It must be a
Personal Access Token or GitHub App token with **Administration: read and
write** on the target repository, because rulesets are administered at that
level. `secrets.GITHUB_TOKEN` cannot manage rulesets. The token does not need
org-admin scope: the freeze ruleset is repo-level.

The bypass team is referenced by numeric id (find it with
`gh api orgs/<org>/teams/<slug> --jq .id`), so the token needs no org-read
permission at run time.

## Enforcement modes

| Mode | Effect |
|---|---|
| `active` | Freeze is enforced: only the bypass team can merge. Default. |
| `evaluate` | Dry run: would-be blocks are logged in the repo's ruleset insights, nobody is blocked. Use for a first rollout. |
| `disabled` | Ruleset enforces nothing. This is the state `unfreeze` leaves it in. |

## Testing

```bash
make test-release-branch-freeze
```

Runs the bats suite in `test/` against `src/freeze.sh` with a stubbed `gh` on
`PATH`. The end-to-end ruleset behavior (non-bypass merge blocked, bypass merge
allowed) was validated against a throwaway repo in the
`vClusterLabs-Experiments` org.
59 changes: 59 additions & 0 deletions .github/actions/release-branch-freeze/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Release Branch Code Freeze
description: |
Applies or lifts a temporary code freeze on a release branch by managing a
GitHub repository ruleset with the "Restrict updates" rule. During the freeze
only a bypass team can merge into the branch; lifting the freeze disables the
ruleset so the branch returns to its standing rules.

One reusable ruleset per repo is re-pointed at the branch being released, so
only that branch is affected while other release lines keep their normal
rules. Uses the GitHub CLI (gh), pre-installed on hosted runners.

The caller must expose a token with Administration:write on the target repo
as the GH_TOKEN environment variable (job or step env). secrets.GITHUB_TOKEN
cannot manage rulesets.
inputs:
operation:
description: 'freeze (apply the code freeze) or unfreeze (lift it).'
required: true
repository:
description: 'Target repository in owner/name form.'
required: true
branch:
description: 'Release branch to freeze or unfreeze, e.g. v0.36 or release-4.11. Required for freeze.'
required: false
default: ''
bypass-team-id:
description: |
Numeric GitHub team id allowed to merge during the freeze (required for
freeze). Find it with: gh api orgs/<org>/teams/<slug> --jq .id
required: false
enforcement:
description: 'active | evaluate | disabled. evaluate is a dry run that logs would-be blocks without blocking. Default active.'
required: false
default: 'active'
ruleset-name:
description: 'Override the ruleset name. Default release-branch-code-freeze.'
required: false
default: ''
outputs:
ruleset-id:
description: 'ID of the freeze ruleset that was created, updated, or disabled.'
value: ${{ steps.run.outputs.ruleset-id }}
runs:
using: composite
steps:
- name: Manage code-freeze ruleset
id: run
shell: bash
env:
INPUT_OPERATION: ${{ inputs.operation }}
INPUT_REPOSITORY: ${{ inputs.repository }}
INPUT_BRANCH: ${{ inputs.branch }}
INPUT_BYPASS_TEAM_ID: ${{ inputs.bypass-team-id }}
INPUT_ENFORCEMENT: ${{ inputs.enforcement }}
INPUT_RULESET_NAME: ${{ inputs.ruleset-name }}
run: ${{ github.action_path }}/src/freeze.sh
branding:
icon: 'lock'
color: 'orange'
119 changes: 119 additions & 0 deletions .github/actions/release-branch-freeze/src/freeze.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Manage a release-branch code freeze via a GitHub repository ruleset.
#
# The freeze is a single reusable ruleset per repo (default name
# "release-branch-code-freeze") carrying the "Restrict updates" rule. Only
# actors on its bypass list can update (merge into) the targeted branch, and a
# PR merge counts as an update, so non-bypass users cannot merge during a freeze.
#
# freeze upsert the ruleset so it targets refs/heads/<branch> with the
# chosen enforcement and a bypass team. That team is then the only
# one that can merge into the branch.
# unfreeze set the ruleset's enforcement to "disabled" so the branch falls
# back to the repo's standing rules. The object is kept, ready to be
# re-pointed at the next release branch.
#
# freeze re-points the same ruleset at the branch being released, so only that
# branch is affected; other release branches keep their normal rules.
#
# Required env:
# GH_TOKEN PAT or GitHub App token with Administration:write on
# INPUT_REPOSITORY. secrets.GITHUB_TOKEN cannot manage
# rulesets.
# INPUT_OPERATION "freeze" or "unfreeze".
# INPUT_REPOSITORY Target repo, owner/name.
# INPUT_BRANCH Release branch, e.g. "v0.36" or "release-4.11".
# Required for freeze:
# INPUT_BYPASS_TEAM_ID Numeric team id allowed to merge during the freeze
# (e.g. Eng-Tech-Leads). Find it with:
# gh api orgs/<org>/teams/<slug> --jq .id
# Optional:
# INPUT_ENFORCEMENT active | evaluate | disabled (default "active").
# evaluate = dry run: logs would-be blocks in the repo's
# ruleset insights but blocks nothing.
# INPUT_RULESET_NAME Override the ruleset name (default
# "release-branch-code-freeze").
set -euo pipefail

: "${GH_TOKEN:?GH_TOKEN required (Administration:write on the target repo)}"
: "${INPUT_OPERATION:?operation required (freeze|unfreeze)}"
: "${INPUT_REPOSITORY:?repository required (owner/name)}"

REPO="$INPUT_REPOSITORY"
BRANCH="${INPUT_BRANCH:-}"
RULESET_NAME="${INPUT_RULESET_NAME:-release-branch-code-freeze}"

# Echo the id of the freeze ruleset (matched by name), or nothing.
# --paginate so a repo with more than one page of rulesets (30 per page)
# can't hide the freeze ruleset on a later page and make us create a second.
find_ruleset_id() {
gh api --paginate "repos/${REPO}/rulesets" |
jq -r --arg n "$RULESET_NAME" 'map(select(.name == $n)) | (.[0].id // empty)'
}

write_output() {
echo "$1=$2" >> "${GITHUB_OUTPUT:-/dev/stdout}"
}

case "$INPUT_OPERATION" in
freeze)
: "${INPUT_BRANCH:?branch required for freeze}"
: "${INPUT_BYPASS_TEAM_ID:?bypass-team-id required for freeze}"
if ! [[ "$INPUT_BYPASS_TEAM_ID" =~ ^[0-9]+$ ]]; then
echo "::error::bypass-team-id must be numeric (got: ${INPUT_BYPASS_TEAM_ID})"
exit 1
fi
ENFORCEMENT="${INPUT_ENFORCEMENT:-active}"
case "$ENFORCEMENT" in
active | evaluate | disabled) ;;
*)
echo "::error::enforcement must be active, evaluate, or disabled (got: ${ENFORCEMENT})"
exit 1
;;
esac

# Build the payload with jq so branch names are JSON-escaped correctly.
BODY=$(jq -n \
--arg name "$RULESET_NAME" \
--arg ref "refs/heads/${BRANCH}" \
--arg enforcement "$ENFORCEMENT" \
--argjson team_id "$INPUT_BYPASS_TEAM_ID" \
'{
name: $name,
target: "branch",
enforcement: $enforcement,
conditions: { ref_name: { include: [ $ref ], exclude: [] } },
rules: [ { type: "update" } ],
bypass_actors: [ { actor_type: "Team", actor_id: $team_id, bypass_mode: "always" } ]
}')

RID="$(find_ruleset_id)"
if [ -n "$RID" ]; then
echo "::notice::updating ruleset ${RULESET_NAME} (id ${RID}) on ${REPO} -> ${BRANCH} (${ENFORCEMENT})"
gh api -X PUT "repos/${REPO}/rulesets/${RID}" --input - <<<"$BODY" >/dev/null
else
echo "::notice::creating ruleset ${RULESET_NAME} on ${REPO} -> ${BRANCH} (${ENFORCEMENT})"
RID="$(gh api -X POST "repos/${REPO}/rulesets" --input - <<<"$BODY" | jq -r '.id // empty')"
if [ -z "$RID" ]; then
echo "::error::ruleset POST succeeded but response contained no id; freeze may not have been applied"
exit 1
fi
fi
write_output "ruleset-id" "$RID"
echo "::notice::freeze ${ENFORCEMENT}: only team ${INPUT_BYPASS_TEAM_ID} may merge into ${BRANCH} on ${REPO}"
;;
unfreeze)
RID="$(find_ruleset_id)"
if [ -z "$RID" ]; then
echo "::notice::no ruleset named ${RULESET_NAME} on ${REPO}; nothing to unfreeze"
exit 0
fi
gh api -X PUT "repos/${REPO}/rulesets/${RID}" -f enforcement=disabled >/dev/null
write_output "ruleset-id" "$RID"
echo "::notice::unfreeze: ruleset ${RULESET_NAME} (id ${RID}) on ${REPO} set to disabled"
;;
*)
echo "::error::operation must be 'freeze' or 'unfreeze' (got: ${INPUT_OPERATION})"
exit 1
;;
esac
Loading
Loading