Skip to content
Open
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
23 changes: 23 additions & 0 deletions .github/branch-freeze-allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# branch-freeze allowlist
#
# GitHub logins permitted to run the `/freeze` and `/unfreeze` slash commands
# (see .github/workflows/branch-freeze-command.yml). One login per line.
#
# Rules:
# * One GitHub login per line (the value after the `@`, e.g. `octocat`).
# * Blank lines and `# comments` are ignored.
# * Matching is case-insensitive; surrounding whitespace is trimmed.
#
# Changes to this file go through normal PR review, which is the audit trail for
# who may operate the branch freeze. Keep it in sync with the @dotnet/kitten
# build-duty rotation.

AlesProkop
AR-May
baronfel
jankratochvilcz
JanProvaznik
OvesN
rainersigwald
ViktorHofer
VolPlita
151 changes: 151 additions & 0 deletions .github/branch-freeze/handle-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Handles a /freeze or /unfreeze command. Authorization is performed by the
# calling workflow BEFORE this script runs.
#
# Command is read from the FIRST line of the triggering comment ($BODY):
# /freeze [--branch <name>] <reason...> branch defaults to `main`; reason required
# /unfreeze [--branch <name>] branch defaults to `main`
#
# Freeze state is stored as an open issue labeled `branch-freeze` whose body is
# <reason>
#
# <!-- branch-freeze:<branch> -->
# This script only toggles that state and replies; it emits `branch` and
# `changed` step outputs so the calling workflow can refresh the affected PR
# statuses via branch-freeze-refresh.yml.
#
# Env (required): GH_TOKEN, REPO, ACTOR, ISSUE_NUMBER, COMMENT_ID, BODY
set -euo pipefail

# Step 1: Read the values supplied by the command workflow.
: "${GH_TOKEN:?}"; : "${REPO:?}"; : "${ACTOR:?}"; : "${ISSUE_NUMBER:?}"; : "${COMMENT_ID:?}"
BODY="${BODY:-}"
label="branch-freeze"

# Step 2: Define helpers for reacting, replying, and returning workflow outputs.
react() { gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content="$1" >/dev/null 2>&1 || true; }
reply() {
if ! gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$1" >/dev/null; then
echo "::warning::Failed to post confirmation comment on issue #${ISSUE_NUMBER}."
fi
return 0
}
set_output() { [ -n "${GITHUB_OUTPUT:-}" ] && printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"; return 0; }

# Backticks below are literal Markdown in the reply text, not command substitution.
# shellcheck disable=SC2016
usage='**Usage**
- `/freeze [--branch <name>] <reason>` — freeze a branch (default `main`); a reason is required.
- `/unfreeze [--branch <name>]` — unfreeze a branch (default `main`).'

fail_usage() { react "confused"; reply "$1"$'\n\n'"$usage"; exit 0; }

# Step 3: Read the command and arguments from the comment's first line.
line="$(printf '%s' "$BODY" | head -n1 | tr -d '\r')"
cmd="${line%%[[:space:]]*}"
rest="${line#"$cmd"}"
rest="${rest#"${rest%%[![:space:]]*}"}" # left-trim

# Step 4: Decide whether this is a freeze or unfreeze request.
case "$cmd" in
/freeze) action="freeze" ;;
/unfreeze) action="unfreeze" ;;
*) echo "Ignoring non-command first token: '$cmd'"; exit 0 ;;
esac

# Step 5: Read the optional branch and the freeze reason.
branch="main"
case "$rest" in
--branch|-b)
fail_usage "Missing a branch name after \`${rest}\`." ;;
--branch[[:space:]]*|-b[[:space:]]*)
branch="$(printf '%s' "$rest" | awk '{print $2}')"
rest="$(printf '%s' "$rest" | sed -E 's/^(--branch|-b)[[:space:]]+[^[:space:]]+[[:space:]]*//')"
;;
esac
reason="$(printf '%s' "$rest" | sed -E 's/[[:space:]]+$//')"
[ -n "$branch" ] || fail_usage "No branch name was given after the \`--branch\` flag."

# Step 6: Validate the branch name and confirm that the branch exists.
case "$branch" in
*[!A-Za-z0-9._/-]*) fail_usage "Branch \`$branch\` contains unexpected characters." ;;
esac

if ! gh api "repos/$REPO/branches/$branch" >/dev/null 2>&1; then
fail_usage "Branch \`$branch\` was not found in \`$REPO\`."
fi

marker="<!-- branch-freeze:$branch -->"

# Step 7: Ensure the tracking label exists.
gh label create "$label" --repo "$REPO" --color B60205 \
--description "Tracks a frozen branch" >/dev/null 2>&1 || true

# Step 8: Find any open tracking issues for this branch.
existing_nums="$(gh issue list --repo "$REPO" --label "$label" --state open --limit 200 --json number,body \
| jq -r --arg m "$marker" \
'map(select((.body // "") | split("\n") | any(.[]; (gsub("^\\s+|\\s+$";"")) == $m))) | .[].number')"

if [ "$action" = "freeze" ]; then
# Step 9a: Freeze the branch by creating or updating its tracking issue.
[ -n "$reason" ] || fail_usage "A reason is required to freeze \`$branch\`."

# The branch marker makes the issue machine-detectable; the actor marker records
# who froze the branch so the blocking status can name them. Both marker lines
# are stripped from the human-readable reason by set-pr-status.sh.
issue_body="${reason}"$'\n\n'"${marker}"$'\n'"<!-- branch-freeze-by:${ACTOR} -->"

# Keep one tracking issue and close any duplicates.
primary=""
while IFS= read -r n; do
[ -n "$n" ] || continue
if [ -z "$primary" ]; then
primary="$n"
else
gh issue close "$n" --repo "$REPO" \
--comment "Superseded by #${primary} (duplicate \`$branch\` freeze tracking issue)." >/dev/null || true
fi
done <<< "$existing_nums"

if [ -n "$primary" ]; then
gh issue edit "$primary" --repo "$REPO" --body "$issue_body" >/dev/null
issue_num="$primary"; verb="updated"
else
url="$(gh issue create --repo "$REPO" --label "$label" \
--title "Branch frozen: $branch" --body "$issue_body")"
issue_num="${url##*/}"; verb="opened"
fi

# Tell the workflow to refresh PR statuses, then acknowledge the command.
set_output branch "$branch"
set_output changed "true"
react "+1"
reply "❄️ **\`$branch\` is now frozen** by @${ACTOR} — tracking issue #${issue_num} (${verb}).

> ${reason}

Pull requests targeting \`${branch}\` will be blocked by the \`branch-freeze\` check until someone runs \`/unfreeze --branch ${branch}\` (or \`/unfreeze\` for \`main\`)."
else
# Step 9b: Unfreeze the branch by closing its tracking issues.
closed=""
while IFS= read -r n; do
[ -n "$n" ] || continue
gh issue close "$n" --repo "$REPO" \
--comment "Unfrozen by @${ACTOR} via \`/unfreeze\`." >/dev/null
closed="${closed:+$closed, }#${n}"
done <<< "$existing_nums"

if [ -z "$closed" ]; then
# The branch was already open, so no PR status refresh is needed.
set_output changed "false"
react "+1"
reply "\`${branch}\` is not currently frozen — nothing to do."
exit 0
fi

# Tell the workflow to refresh PR statuses, then acknowledge the command.
set_output branch "$branch"
set_output changed "true"
react "+1"
reply "✅ **\`${branch}\` is now unfrozen** by @${ACTOR} — closed tracking issue(s) ${closed}. The \`branch-freeze\` check now passes on open PRs targeting \`${branch}\`."
fi
30 changes: 30 additions & 0 deletions .github/branch-freeze/is-allowed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Exits 0 if <actor-login> appears in the branch-freeze allowlist, non-zero otherwise.
#
# Usage: is-allowed.sh <actor-login> [allowlist-path]
# allowlist-path defaults to .github/branch-freeze-allowlist.txt
#
# The allowlist has one GitHub login per line; blank lines and `#` comments are
# ignored, matching is case-insensitive, and surrounding whitespace is trimmed.
# Exit codes: 0 = allowed, 1 = not allowed, 2 = allowlist file missing.
set -uo pipefail

actor="${1:?actor login required}"
allowlist="${2:-.github/branch-freeze-allowlist.txt}"

if [ ! -f "$allowlist" ]; then
echo "Allowlist file '$allowlist' not found." >&2
exit 2
fi

actor_lc="$(printf '%s' "$actor" | tr '[:upper:]' '[:lower:]')"
while IFS= read -r line || [ -n "$line" ]; do
login="${line%%#*}" # strip trailing comment
login="$(printf '%s' "$login" | tr -d '[:space:]')" # trim all whitespace
[ -n "$login" ] || continue
if [ "$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')" = "$actor_lc" ]; then
exit 0
fi
done < "$allowlist"

exit 1
47 changes: 47 additions & 0 deletions .github/branch-freeze/refresh-pr-statuses.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Refreshes the `branch-freeze` status on every open pull request, optionally
# limited to a single base branch. Calls set-pr-status.sh once per PR and
# aggregates failures into a non-zero exit. Used by branch-freeze-refresh.yml
# for rollout seeding, manual re-sync, and /freeze or /unfreeze fan-out.
#
# Usage: refresh-pr-statuses.sh [base-ref] # blank base-ref = all open PRs
# Env: GH_TOKEN (required), REPO (default: $GITHUB_REPOSITORY)
set -euo pipefail

# Step 1: Read the optional branch filter and repository.
base_ref="${1:-}"
export REPO="${REPO:-${GITHUB_REPOSITORY:?REPO or GITHUB_REPOSITORY must be set}}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Step 2: Build a query for open PRs, optionally limited to one base branch.
args=(--repo "$REPO" --state open --limit 1000 --json "number,headRefOid,baseRefName")
if [ -n "$base_ref" ]; then
args+=(--base "$base_ref")
fi

# Step 3: Load the matching PRs and report how many will be refreshed.
prs="$(gh pr list "${args[@]}")"
count="$(printf '%s' "$prs" | jq 'length')"
echo "Stamping ${count} open PR(s)${base_ref:+ targeting ${base_ref}}"
if [ "$count" -ge 1000 ]; then
echo "::warning::Open PR count hit the query limit (${count}); some PRs may not have been stamped."
fi

# Step 4: Refresh the required status on each PR's current head commit.
fail=0
while IFS= read -r pr; do
num="$(printf '%s' "$pr" | jq -r '.number')"
sha="$(printf '%s' "$pr" | jq -r '.headRefOid')"
base="$(printf '%s' "$pr" | jq -r '.baseRefName')"
echo "::group::PR #${num} (${base} @ ${sha})"

# Remember any failure, but continue refreshing the remaining PRs.
if ! bash "$here/set-pr-status.sh" "$sha" "$base"; then
echo "::warning::Failed to stamp PR #${num}"
fail=1
fi
echo "::endgroup::"
done < <(printf '%s' "$prs" | jq -c '.[]')

# Step 5: Fail the workflow if any PR status could not be refreshed.
exit "$fail"
82 changes: 82 additions & 0 deletions .github/branch-freeze/set-pr-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Posts the `branch-freeze` commit status for a single pull request head commit.
#
# Usage: set-pr-status.sh <head-sha> <base-ref>
# Env: GH_TOKEN (required) token with `statuses: write` + `issues: read`
# REPO (optional) owner/repo; defaults to $GITHUB_REPOSITORY
#
# A branch is considered FROZEN while an open issue labeled `branch-freeze`
# contains the marker `<!-- branch-freeze:<branch> -->` on a line by itself
# (surrounding whitespace / CR tolerated). The remaining issue body is used as
# the human-readable reason, and an optional `<!-- branch-freeze-by:<login> -->`
# marker names who froze it (surfaced in the status description). Matching the
# marker as a WHOLE LINE - not a substring - prevents an issue that merely
# mentions the marker from freezing a branch.
set -euo pipefail

# Step 1: Read the PR commit, target branch, and repository.
sha="${1:?head sha required}"
base_ref="${2:?base ref required}"
repo="${REPO:-${GITHUB_REPOSITORY:?REPO or GITHUB_REPOSITORY must be set}}"

# Step 2: Define the required check name and tracking issue marker.
context="branch-freeze"
label="branch-freeze"
marker="<!-- branch-freeze:${base_ref} -->"

# Step 3: Load all open branch-freeze tracking issues.
issues="$(gh issue list \
--repo "$repo" \
--label "$label" \
--state open \
--limit 200 \
--json number,body,url)"

# Step 4: Find the tracking issue for this PR's target branch.
match="$(printf '%s' "$issues" \
| jq -c --arg m "$marker" \
'map(select((.body // "") | split("\n") | any(.[]; (gsub("^\\s+|\\s+$";"")) == $m)))
| first // empty')"

if [ -n "$match" ]; then
# Step 5a: The branch is frozen. Read the issue details for the check message.
issue_url="$(printf '%s' "$match" | jq -r '.url')"
body="$(printf '%s' "$match" | jq -r '.body // ""')"

# Who froze it, from the machine-readable actor marker (if present).
actor="$(printf '%s' "$body" \
| sed -nE 's@^[[:space:]]*<!--[[:space:]]*branch-freeze-by:[[:space:]]*([^[:space:]>]+)[[:space:]]*-->[[:space:]]*$@\1@p' \
| head -n1)"

# Human-readable reason = body minus any branch-freeze marker lines.
reason="$(printf '%s' "$body" \
| grep -vE '^[[:space:]]*<!--[[:space:]]*branch-freeze.*-->[[:space:]]*$' \
| tr '\n' ' ' \
| sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
[ -n "$reason" ] || reason="(no reason provided)"

if [ -n "$actor" ]; then
description="Frozen by @${actor}: ${reason}"
else
description="Frozen: ${reason}"
fi
# GitHub truncates status descriptions at 140 chars; trim with an ellipsis.
if [ "${#description}" -gt 140 ]; then
description="${description:0:137}..."
fi

# Step 6a: Mark the required check as failed and link the tracking issue.
echo "Branch '$base_ref' is FROZEN -> reporting failure on $sha"
gh api -X POST "repos/$repo/statuses/$sha" \
-f state=failure \
-f context="$context" \
-f description="$description" \
-f target_url="$issue_url" >/dev/null
else
# Step 5b: The branch is open, so mark the required check as successful.
echo "Branch '$base_ref' is open -> reporting success on $sha"
gh api -X POST "repos/$repo/statuses/$sha" \
-f state=success \
-f context="$context" \
-f description="Branch open" >/dev/null
fi
50 changes: 50 additions & 0 deletions .github/branch-freeze/tests/mock-gh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Minimal mock of the GitHub CLI (`gh`) for the branch-freeze tests.
# Implements ONLY the gh surface used by the scripts under test:
# * `gh issue list ... --json ...` -> prints $MOCK_ISSUES (default [])
# * `gh issue comment ...` -> fails when
# $MOCK_ISSUE_COMMENT_FAILURE=1
# * `gh api -X POST .../statuses/<sha> -f k=v ...` -> appends each k=v line to
# $GH_STATUS_FILE (when set) so tests can assert on the posted commit status.
set -uo pipefail

cmd="${1:-}"; shift || true

case "$cmd" in
issue)
sub="${1:-}"; shift || true
case "$sub" in
list) printf '%s' "${MOCK_ISSUES:-[]}" ;;
comment) [ "${MOCK_ISSUE_COMMENT_FAILURE:-0}" != "1" ] ;;
*) : ;;
esac
;;
api)
endpoint=""
keys=()
vals=()
while [ "$#" -gt 0 ]; do
case "$1" in
-f) keys+=("${2%%=*}"); vals+=("${2#*=}"); shift 2 ;;
-X|--jq) shift 2 ;;
-*) shift ;;
*) endpoint="$1"; shift ;;
esac
done
case "$endpoint" in
*/statuses/*)
if [ -n "${GH_STATUS_FILE:-}" ]; then
i=0
while [ "$i" -lt "${#keys[@]}" ]; do
printf '%s=%s\n' "${keys[$i]}" "${vals[$i]}" >> "$GH_STATUS_FILE"
i=$((i + 1))
done
fi
;;
*) echo "{}" ;;
esac
;;
*) : ;;
esac

exit 0
Loading
Loading