From d01e0c91dbac31ccaf9aa8f0eecd10274f847b84 Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Wed, 1 Jul 2026 18:57:04 +0200 Subject: [PATCH 1/2] 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. --- .../actions/release-branch-freeze/README.md | 126 ++++++++++++ .../actions/release-branch-freeze/action.yml | 59 ++++++ .../release-branch-freeze/src/freeze.sh | 113 +++++++++++ .../release-branch-freeze/test/freeze.bats | 182 ++++++++++++++++++ .../release-branch-freeze/test/gh_mock.bash | 65 +++++++ .../workflows/test-release-branch-freeze.yaml | 22 +++ Makefile | 7 +- README.md | 36 ++++ 8 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 .github/actions/release-branch-freeze/README.md create mode 100644 .github/actions/release-branch-freeze/action.yml create mode 100755 .github/actions/release-branch-freeze/src/freeze.sh create mode 100644 .github/actions/release-branch-freeze/test/freeze.bats create mode 100644 .github/actions/release-branch-freeze/test/gh_mock.bash create mode 100644 .github/workflows/test-release-branch-freeze.yaml diff --git a/.github/actions/release-branch-freeze/README.md b/.github/actions/release-branch-freeze/README.md new file mode 100644 index 0000000..41ecb88 --- /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..3c5f12d --- /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..816bfcd --- /dev/null +++ b/.github/actions/release-branch-freeze/src/freeze.sh @@ -0,0 +1,113 @@ +#!/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. +find_ruleset_id() { + gh api "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')" + 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..045d75c --- /dev/null +++ b/.github/actions/release-branch-freeze/test/freeze.bats @@ -0,0 +1,182 @@ +#!/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" + [[ "$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 failure surfaces a non-zero exit" { + export GH_MOCK_FAIL=1 + run "$SCRIPT" + [ "$status" -ne 0 ] +} 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..eaeacac --- /dev/null +++ b/.github/actions/release-branch-freeze/test/gh_mock.bash @@ -0,0 +1,65 @@ +#!/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, and returns fixtures: +# GET .../rulesets -> $GH_MOCK_RULESETS (default "[]") +# POST .../rulesets -> {"id": $GH_MOCK_NEW_ID} (default 12345) +# PUT .../rulesets/ -> {} +# Set GH_MOCK_FAIL=1 to force a non-zero exit. + +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" + : > "$GH_MOCK_CALLS" + : > "$GH_MOCK_BODY_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|-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 + +case "$method $path" in + "GET "*/rulesets) printf '%s' "$GH_MOCK_RULESETS" ;; + "POST "*/rulesets) printf '{"id": %s}' "$GH_MOCK_NEW_ID" ;; + *) 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 From b5ab1336387ea595b724a9a8f108cd8d68281583 Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Thu, 2 Jul 2026 10:48:59 +0200 Subject: [PATCH 2/2] 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. --- .../actions/release-branch-freeze/README.md | 2 +- .../actions/release-branch-freeze/action.yml | 2 +- .../release-branch-freeze/src/freeze.sh | 10 ++++-- .../release-branch-freeze/test/freeze.bats | 28 ++++++++++++++++- .../release-branch-freeze/test/gh_mock.bash | 31 +++++++++++++------ 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/.github/actions/release-branch-freeze/README.md b/.github/actions/release-branch-freeze/README.md index 41ecb88..739a3fa 100644 --- a/.github/actions/release-branch-freeze/README.md +++ b/.github/actions/release-branch-freeze/README.md @@ -32,7 +32,7 @@ pre-installed on hosted runners. | OUTPUT | TYPE | DESCRIPTION | |------------|--------|-----------------------------------------------------------------------| -| ruleset-id | string | Id of the freeze ruleset that
was created, updated, or disabled. | +| ruleset-id | string | ID of the freeze ruleset that
was created, updated, or disabled. | diff --git a/.github/actions/release-branch-freeze/action.yml b/.github/actions/release-branch-freeze/action.yml index 3c5f12d..5318f26 100644 --- a/.github/actions/release-branch-freeze/action.yml +++ b/.github/actions/release-branch-freeze/action.yml @@ -38,7 +38,7 @@ inputs: default: '' outputs: ruleset-id: - description: 'Id of the freeze ruleset that was created, updated, or disabled.' + description: 'ID of the freeze ruleset that was created, updated, or disabled.' value: ${{ steps.run.outputs.ruleset-id }} runs: using: composite diff --git a/.github/actions/release-branch-freeze/src/freeze.sh b/.github/actions/release-branch-freeze/src/freeze.sh index 816bfcd..4e2aba7 100755 --- a/.github/actions/release-branch-freeze/src/freeze.sh +++ b/.github/actions/release-branch-freeze/src/freeze.sh @@ -44,8 +44,10 @@ 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 "repos/${REPO}/rulesets" | + gh api --paginate "repos/${REPO}/rulesets" | jq -r --arg n "$RULESET_NAME" 'map(select(.name == $n)) | (.[0].id // empty)' } @@ -91,7 +93,11 @@ case "$INPUT_OPERATION" in 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')" + 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}" diff --git a/.github/actions/release-branch-freeze/test/freeze.bats b/.github/actions/release-branch-freeze/test/freeze.bats index 045d75c..f1e2bd0 100644 --- a/.github/actions/release-branch-freeze/test/freeze.bats +++ b/.github/actions/release-branch-freeze/test/freeze.bats @@ -152,6 +152,10 @@ last_body() { 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"* ]] } @@ -175,8 +179,30 @@ last_body() { # --- error propagation ------------------------------------------------------ -@test "gh failure surfaces a non-zero exit" { +@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 index eaeacac..0591963 100644 --- a/.github/actions/release-branch-freeze/test/gh_mock.bash +++ b/.github/actions/release-branch-freeze/test/gh_mock.bash @@ -1,11 +1,16 @@ #!/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, and returns fixtures: +# --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/ -> {} -# Set GH_MOCK_FAIL=1 to force a non-zero exit. +# 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)" @@ -15,8 +20,10 @@ setup_gh_mock() { 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' @@ -31,12 +38,13 @@ 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|-H|--header) shift 2 ;; - --jq|-q) shift 2 ;; - -*) shift ;; - *) [ -z "$path" ] && path="$1"; shift ;; + -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 @@ -49,10 +57,15 @@ 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) printf '{"id": %s}' "$GH_MOCK_NEW_ID" ;; + "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