diff --git a/.github/actions/release-branch-freeze/README.md b/.github/actions/release-branch-freeze/README.md
new file mode 100644
index 0000000..739a3fa
--- /dev/null
+++ b/.github/actions/release-branch-freeze/README.md
@@ -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
+
+
+
+| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
+|----------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
+| branch | string | false | | Release branch to freeze or unfreeze,
e.g. v0.36 or release-4.11. Required for
freeze. |
+| bypass-team-id | string | false | | Numeric GitHub team id allowed to
merge during the freeze (required for
freeze). Find it with: gh api
orgs//teams/ --jq .id |
+| enforcement | string | false | `"active"` | active | evaluate | disabled. evaluate
is a dry run that logs
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. |
+
+
+
+## Outputs
+
+
+
+| OUTPUT | TYPE | DESCRIPTION |
+|------------|--------|-----------------------------------------------------------------------|
+| ruleset-id | string | ID of the freeze ruleset that
was created, updated, or disabled. |
+
+
+
+## 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//teams/ --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.
diff --git a/.github/actions/release-branch-freeze/action.yml b/.github/actions/release-branch-freeze/action.yml
new file mode 100644
index 0000000..5318f26
--- /dev/null
+++ b/.github/actions/release-branch-freeze/action.yml
@@ -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//teams/ --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'
diff --git a/.github/actions/release-branch-freeze/src/freeze.sh b/.github/actions/release-branch-freeze/src/freeze.sh
new file mode 100755
index 0000000..4e2aba7
--- /dev/null
+++ b/.github/actions/release-branch-freeze/src/freeze.sh
@@ -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/ 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//teams/ --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
diff --git a/.github/actions/release-branch-freeze/test/freeze.bats b/.github/actions/release-branch-freeze/test/freeze.bats
new file mode 100644
index 0000000..f1e2bd0
--- /dev/null
+++ b/.github/actions/release-branch-freeze/test/freeze.bats
@@ -0,0 +1,208 @@
+#!/usr/bin/env bats
+# Tests for freeze.sh. Stubs `gh` (see gh_mock.bash); uses the real jq so the
+# payload built by the script is validated end-to-end.
+
+SCRIPT="$BATS_TEST_DIRNAME/../src/freeze.sh"
+
+load gh_mock
+
+setup() {
+ setup_gh_mock
+ export GH_TOKEN="fake-token"
+ export INPUT_OPERATION="freeze"
+ export INPUT_REPOSITORY="loft-sh/vcluster-pro"
+ export INPUT_BRANCH="v0.36"
+ export INPUT_BYPASS_TEAM_ID="16898535"
+ export INPUT_ENFORCEMENT=""
+ export INPUT_RULESET_NAME=""
+ export GITHUB_OUTPUT="$MOCK_DIR/output"
+ : > "$GITHUB_OUTPUT"
+}
+
+teardown() {
+ teardown_gh_mock
+}
+
+# Last non-empty request body recorded by the gh mock.
+last_body() {
+ awk 'BEGIN{RS="---END---\n"} NF{b=$0} END{printf "%s", b}' "$GH_MOCK_BODY_LOG"
+}
+
+# --- required input validation ----------------------------------------------
+
+@test "missing GH_TOKEN fails fast, no api call" {
+ unset GH_TOKEN
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [ ! -s "$GH_MOCK_CALLS" ]
+}
+
+@test "missing operation fails fast" {
+ unset INPUT_OPERATION
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+}
+
+@test "missing repository fails fast" {
+ unset INPUT_REPOSITORY
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+}
+
+@test "freeze without branch fails fast" {
+ unset INPUT_BRANCH
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+}
+
+@test "unknown operation fails fast" {
+ export INPUT_OPERATION="frobnicate"
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"operation must be"* ]]
+}
+
+# --- freeze input validation ------------------------------------------------
+
+@test "freeze without bypass-team-id fails, no write call" {
+ unset INPUT_BYPASS_TEAM_ID
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ ! grep -qE '^(POST|PUT) ' "$GH_MOCK_CALLS"
+}
+
+@test "freeze with non-numeric bypass-team-id fails" {
+ export INPUT_BYPASS_TEAM_ID="eng-tech-leads"
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"numeric"* ]]
+}
+
+@test "freeze with invalid enforcement fails" {
+ export INPUT_ENFORCEMENT="on"
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"enforcement must be"* ]]
+}
+
+# --- freeze: create path ----------------------------------------------------
+
+@test "freeze creates ruleset when none exists, with the right shape" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -q '^POST repos/loft-sh/vcluster-pro/rulesets$' "$GH_MOCK_CALLS"
+ body="$(last_body)"
+ [ "$(jq -r '.name' <<<"$body")" = "release-branch-code-freeze" ]
+ [ "$(jq -r '.target' <<<"$body")" = "branch" ]
+ [ "$(jq -r '.enforcement' <<<"$body")" = "active" ]
+ [ "$(jq -r '.rules[0].type' <<<"$body")" = "update" ]
+ [ "$(jq -r '.conditions.ref_name.include[0]' <<<"$body")" = "refs/heads/v0.36" ]
+ [ "$(jq -r '.bypass_actors[0].actor_type' <<<"$body")" = "Team" ]
+ [ "$(jq -r '.bypass_actors[0].actor_id' <<<"$body")" = "16898535" ]
+ [ "$(jq -r '.bypass_actors[0].bypass_mode' <<<"$body")" = "always" ]
+ grep -q '^ruleset-id=12345$' "$GITHUB_OUTPUT"
+}
+
+@test "freeze defaults enforcement to active" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq -r '.enforcement' <<<"$(last_body)")" = "active" ]
+}
+
+@test "freeze honors enforcement=evaluate for a dry run" {
+ export INPUT_ENFORCEMENT="evaluate"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq -r '.enforcement' <<<"$(last_body)")" = "evaluate" ]
+}
+
+# --- freeze: update (upsert) path -------------------------------------------
+
+@test "freeze updates the existing ruleset instead of creating a second" {
+ export GH_MOCK_RULESETS='[{"id":777,"name":"release-branch-code-freeze"}]'
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -q '^PUT repos/loft-sh/vcluster-pro/rulesets/777$' "$GH_MOCK_CALLS"
+ ! grep -q '^POST ' "$GH_MOCK_CALLS"
+ grep -q '^ruleset-id=777$' "$GITHUB_OUTPUT"
+}
+
+@test "freeze re-points the ref to the given branch on update" {
+ export GH_MOCK_RULESETS='[{"id":777,"name":"release-branch-code-freeze"}]'
+ export INPUT_BRANCH="release-4.11"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq -r '.conditions.ref_name.include[0]' <<<"$(last_body)")" = "refs/heads/release-4.11" ]
+}
+
+@test "custom ruleset-name is matched and written" {
+ export INPUT_RULESET_NAME="freeze-v0.36"
+ export GH_MOCK_RULESETS='[{"id":9,"name":"freeze-v0.36"}]'
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -q '^PUT repos/loft-sh/vcluster-pro/rulesets/9$' "$GH_MOCK_CALLS"
+ [ "$(jq -r '.name' <<<"$(last_body)")" = "freeze-v0.36" ]
+}
+
+# --- unfreeze ---------------------------------------------------------------
+
+@test "unfreeze disables the existing ruleset" {
+ export INPUT_OPERATION="unfreeze"
+ export GH_MOCK_RULESETS='[{"id":777,"name":"release-branch-code-freeze"}]'
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -q '^PUT repos/loft-sh/vcluster-pro/rulesets/777$' "$GH_MOCK_CALLS"
+ # assert the PUT actually carried enforcement=disabled, not just the log line
+ grep -q '^enforcement=disabled$' "$GH_MOCK_FIELD_LOG"
+ # and that the ruleset id was written to the step output, as the freeze paths do
+ grep -q '^ruleset-id=777$' "$GITHUB_OUTPUT"
+ [[ "$output" == *"disabled"* ]]
+}
+
+@test "unfreeze is a no-op when no ruleset exists" {
+ export INPUT_OPERATION="unfreeze"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ ! grep -qE '^(POST|PUT) ' "$GH_MOCK_CALLS"
+ [[ "$output" == *"nothing to unfreeze"* ]]
+}
+
+@test "unfreeze requires neither branch nor bypass-team-id" {
+ export INPUT_OPERATION="unfreeze"
+ unset INPUT_BYPASS_TEAM_ID
+ unset INPUT_BRANCH
+ export GH_MOCK_RULESETS='[{"id":5,"name":"release-branch-code-freeze"}]'
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -q '^PUT repos/loft-sh/vcluster-pro/rulesets/5$' "$GH_MOCK_CALLS"
+}
+
+# --- error propagation ------------------------------------------------------
+
+@test "gh read failure surfaces a non-zero exit" {
+ # default GH_MOCK_RULESETS='[]' means the first call is the GET in
+ # find_ruleset_id, so this exercises the read side.
+ export GH_MOCK_FAIL=1
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+}
+
+@test "gh write failure surfaces a non-zero exit" {
+ # GET succeeds (ruleset found) so we reach the PUT, which then fails: proves
+ # the write path propagates errors instead of swallowing them.
+ export GH_MOCK_RULESETS='[{"id":777,"name":"release-branch-code-freeze"}]'
+ export GH_MOCK_FAIL_WRITE=1
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ grep -q '^PUT repos/loft-sh/vcluster-pro/rulesets/777$' "$GH_MOCK_CALLS"
+}
+
+@test "freeze fails loudly when the create response has no id" {
+ # POST returns a 2xx body without an id; the script must not write
+ # ruleset-id=null and exit 0.
+ export GH_MOCK_POST_NO_ID=1
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"no id"* ]]
+ ! grep -q '^ruleset-id=' "$GITHUB_OUTPUT"
+}
diff --git a/.github/actions/release-branch-freeze/test/gh_mock.bash b/.github/actions/release-branch-freeze/test/gh_mock.bash
new file mode 100644
index 0000000..0591963
--- /dev/null
+++ b/.github/actions/release-branch-freeze/test/gh_mock.bash
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# Stub `gh` on PATH for freeze.sh tests. Records every invocation as
+# "METHOD path" into $GH_MOCK_CALLS, captures request bodies (read from
+# --input -) into $GH_MOCK_BODY_LOG, records -f/--field key=value pairs into
+# $GH_MOCK_FIELD_LOG, and returns fixtures:
+# GET .../rulesets -> $GH_MOCK_RULESETS (default "[]")
+# POST .../rulesets -> {"id": $GH_MOCK_NEW_ID} (default 12345)
+# PUT .../rulesets/ -> {}
+# Knobs:
+# GH_MOCK_FAIL=1 force a non-zero exit on every call.
+# GH_MOCK_FAIL_WRITE=1 force a non-zero exit on write calls (POST/PUT/PATCH)
+# only, so a GET (find_ruleset_id) still succeeds.
+# GH_MOCK_POST_NO_ID=1 POST returns {} (a 2xx body without an id field).
+
+setup_gh_mock() {
+ MOCK_DIR="$(mktemp -d)"
+ export MOCK_DIR
+ PATH="$MOCK_DIR:$PATH"
+ export PATH
+
+ export GH_MOCK_CALLS="$MOCK_DIR/calls.log"
+ export GH_MOCK_BODY_LOG="$MOCK_DIR/bodies.log"
+ export GH_MOCK_FIELD_LOG="$MOCK_DIR/fields.log"
+ : > "$GH_MOCK_CALLS"
+ : > "$GH_MOCK_BODY_LOG"
+ : > "$GH_MOCK_FIELD_LOG"
+ export GH_MOCK_RULESETS='[]'
+ export GH_MOCK_NEW_ID='12345'
+
+ cat > "$MOCK_DIR/gh" <<'EOF'
+#!/usr/bin/env bash
+# Mock gh. Covers `gh api [-X METHOD] [--input -] [-f k=v]`.
+[ "${1:-}" = "api" ] || { echo "unsupported gh invocation: $*" >&2; exit 99; }
+shift
+
+method="GET"
+path=""
+read_stdin=0
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -X|--method) method="$2"; shift 2 ;;
+ --input) [ "$2" = "-" ] && read_stdin=1; shift 2 ;;
+ -f|--raw-field|--field) printf '%s\n' "$2" >> "$GH_MOCK_FIELD_LOG"; shift 2 ;;
+ -H|--header) shift 2 ;;
+ --jq|-q) shift 2 ;;
+ -*) shift ;;
+ *) [ -z "$path" ] && path="$1"; shift ;;
+ esac
+done
+
+printf '%s %s\n' "$method" "$path" >> "$GH_MOCK_CALLS"
+if [ "$read_stdin" = "1" ]; then
+ printf '%s\n---END---\n' "$(cat)" >> "$GH_MOCK_BODY_LOG"
+fi
+
+if [ "${GH_MOCK_FAIL:-0}" = "1" ]; then
+ echo "mock gh: forced failure" >&2
+ exit 1
+fi
+if [ "${GH_MOCK_FAIL_WRITE:-0}" = "1" ] && [ "$method" != "GET" ]; then
+ echo "mock gh: forced write failure ($method)" >&2
+ exit 1
+fi
+
+case "$method $path" in
+ "GET "*/rulesets) printf '%s' "$GH_MOCK_RULESETS" ;;
+ "POST "*/rulesets)
+ if [ "${GH_MOCK_POST_NO_ID:-0}" = "1" ]; then printf '{}'; else printf '{"id": %s}' "$GH_MOCK_NEW_ID"; fi ;;
+ *) printf '{}' ;;
+esac
+exit 0
+EOF
+ chmod +x "$MOCK_DIR/gh"
+}
+
+teardown_gh_mock() {
+ rm -rf "$MOCK_DIR"
+}
diff --git a/.github/workflows/test-release-branch-freeze.yaml b/.github/workflows/test-release-branch-freeze.yaml
new file mode 100644
index 0000000..81c3774
--- /dev/null
+++ b/.github/workflows/test-release-branch-freeze.yaml
@@ -0,0 +1,22 @@
+name: Test release-branch-freeze
+
+on:
+ pull_request:
+ paths:
+ - '.github/workflows/test-release-branch-freeze.yaml'
+ - '.github/actions/release-branch-freeze/**'
+
+permissions:
+ contents: read
+
+jobs:
+ bats:
+ name: Run bats tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
+ with:
+ tests: .github/actions/release-branch-freeze/test
diff --git a/Makefile b/Makefile
index 6fa043f..0b71684 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: test test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter build-linear-release-sync lint install-auto-doc generate-docs check-docs help
+.PHONY: test test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze build-linear-release-sync lint install-auto-doc generate-docs check-docs help
ACTIONS_DIR := .github/actions
WORKFLOWS_DIR := .github/workflows
@@ -93,7 +93,7 @@ check-docs: generate-docs ## verify docs are up to date (fails if drift detected
help: ## show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}'
-test: test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter ## run all action tests
+test: test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze ## run all action tests
test-semver-validation: ## run semver-validation unit tests
cd $(ACTIONS_DIR)/semver-validation && npm ci --silent && NODE_OPTIONS=--experimental-vm-modules npx jest --ci --coverage --watchAll=false
@@ -149,6 +149,9 @@ test-repository-dispatch: ## run repository-dispatch bats tests
test-parse-label-filter: ## run parse-label-filter bats tests
bats $(ACTIONS_DIR)/parse-label-filter/test
+test-release-branch-freeze: ## run release-branch-freeze bats tests
+ bats $(ACTIONS_DIR)/release-branch-freeze/test
+
build-linear-release-sync: ## build linear-release-sync binary (linux/amd64)
cd $(ACTIONS_DIR)/linear-release-sync/src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o ../linear-release-sync-linux-amd64 .
diff --git a/README.md b/README.md
index d2a800b..ece4556 100644
--- a/README.md
+++ b/README.md
@@ -278,6 +278,42 @@ runs).
a PAT or GitHub App token with `repo` scope on the target. See the action
README for full details.
+### Release Branch Code Freeze
+
+Applies or lifts a temporary code freeze on a release branch by managing a
+repository ruleset with the "Restrict updates" rule. During the freeze only a
+bypass team can merge into the branch; unfreeze 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 frozen.
+
+**Location:** `.github/actions/release-branch-freeze`
+
+**Usage:**
+
+```yaml
+- name: Freeze the release branch
+ 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 }}
+```
+
+**Inputs:**
+
+- `operation` (required): `freeze` or `unfreeze`
+- `repository` (required): `/` to manage
+- `branch` (freeze only): release branch to freeze, e.g. `v0.36`
+- `bypass-team-id` (freeze only): numeric team id allowed to merge during the freeze
+- `enforcement` (optional, default `active`): `active`, `evaluate` (dry run), or `disabled`
+
+`GH_TOKEN` is read from the step's environment, not from inputs. It must be a
+PAT or GitHub App token with Administration read and write on the target repo.
+See the action README for full details.
+
## Available Reusable Workflows
### Validate Renovate Config