diff --git a/README.md b/README.md index 5a4474e..7c68fb2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ docs/ Per-script and per-library documentation | `bwcodex` | OpenAI Codex CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwcopilot` | GitHub Copilot CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwopencode` | OpenCode in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | -| `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) | +| `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection; optionally restricts branch creation to named patterns | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) | | `pemdecompose` | List and verify certificates in a PEM file | [docs/pemdecompose.md](docs/pemdecompose.md) | All `bw*` wrappers share `lib/bwrap_sandbox_lib.sh` for sandbox construction. @@ -99,6 +99,42 @@ Common wrapper options: `bwclaude` also supports `--debug`. See each script's `--help` for the authoritative option list. +### `gh-protect-branch` + +Apply NSLS-II standard branch protection to a GitHub repository. Enforces +PR reviews, signed commits, deletion and force-push blocking, and secret +scanning via a repository ruleset and a named-branch classic protection +rule. Optionally restricts who can create new branches. + +```text +gh-protect-branch [--approvers ] [--no-sign-commits] + [--restrict-branch-creation [--exclude-branches ]] + [branch] +gh-protect-branch --only-restrict-creation [--exclude-branches ] + +``` + +| Option | Effect | +| --- | --- | +| `--approvers ` | Restrict stale-review dismissal to this team (team must exist in the org) | +| `--no-sign-commits` | Do not require signed commits | +| `--restrict-branch-creation` | Also apply a creation-restriction ruleset (additive) | +| `--only-restrict-creation` | Apply only the creation-restriction ruleset; skip full protection | +| `--exclude-branches ` | Comma-separated fnmatch patterns allowed to be created (default: `main,preview`); requires `--restrict-branch-creation` or `--only-restrict-creation` | +| `` | Repository in `owner/repo` format (required) | +| `[branch]` | Named branch for classic protection (default: `main`) | +| `-h`, `--help` | Show help and exit | + +```bash +gh-protect-branch NSLS2/n2sndocs +gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main +gh-protect-branch --only-restrict-creation \ + --exclude-branches main,preview NSLS2/n2sndocs +``` + +Exit codes: `0` success, `1` usage error or missing dependency, +`2` `gh` not authenticated, non-zero `gh` exit code on API failure. + ### `gpg-passwd.sh` Source the library and call `decrypt_env_file`: diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index 579019c..a3b4488 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -23,25 +23,58 @@ # Repository settings: # - Secret scanning and push protection enabled # +# Repository ruleset "nsls2-restrict-branch-creation" (optional): +# - Blocks creation of branches whose names do not match the excluded patterns +# - Excluded (allowed) patterns are supplied via --exclude-branches (CSV) +# - Applied additively alongside full protection (--restrict-branch-creation) +# or as the only change to the repo (--only-restrict-creation) +# # Requires: # - gh(1) authenticated with admin:repo scope on the target repository +# - read:org scope when using --approvers (GitHub team API requirement) # # Usage: -# gh-protect-branch [--approvers ] [--no-sign-commits] [branch] +# gh-protect-branch [--approvers ] [--no-sign-commits] +# [--restrict-branch-creation [--exclude-branches ]] +# [branch] +# gh-protect-branch --only-restrict-creation [--exclude-branches ] +# # gh-protect-branch --help # +# Options: +# --approvers Restrict stale-review dismissal to this team. +# The team must exist in the repo's org; the script +# validates this before applying any protection. +# --no-sign-commits Do not require signed commits. +# --restrict-branch-creation Apply the creation-restriction ruleset in +# addition to full branch protection. +# --only-restrict-creation Apply only the creation-restriction ruleset; +# skip full branch protection, classic rules, +# and secret-scanning changes. +# Incompatible with --approvers and --no-sign-commits. +# --exclude-branches Comma-separated list of fnmatch branch-name +# patterns to allow creation of. Each entry is +# matched against refs/heads/. +# Default: main,preview +# Requires --restrict-branch-creation or +# --only-restrict-creation. +# # Examples: # gh-protect-branch NSLS2/n2snscripts # gh-protect-branch NSLS2/n2sndocs main # gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts -# gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main # gh-protect-branch --no-sign-commits NSLS2/n2snscripts +# gh-protect-branch --restrict-branch-creation NSLS2/n2sndocs +# gh-protect-branch --restrict-branch-creation \ +# --exclude-branches main,preview,release/** NSLS2/n2sndocs +# gh-protect-branch --only-restrict-creation \ +# --exclude-branches main,preview NSLS2/n2sndocs # # Exit codes: # 0 success -# 1 usage error or missing dependency +# 1 usage error or missing dependency (including team not found / read:org) # 2 gh not authenticated -# 3 API call failed +# non-zero gh API call failed (exit code from gh; typically 1) set -euo pipefail @@ -50,7 +83,8 @@ if ((BASH_VERSINFO[0] < 4)); then exit 1 fi -RULESET_NAME="nsls2-branch-protection" +FULL_RULESET_NAME="nsls2-branch-protection" +CREATION_RULESET_NAME="nsls2-restrict-branch-creation" show_help() { sed -n '2,/^$/{ s/^# \{0,1\}//; p; }' "$0" @@ -58,6 +92,10 @@ show_help() { APPROVERS_TEAM="" SIGN_COMMITS="true" +RESTRICT_CREATION="false" +ONLY_RESTRICT_CREATION="false" +EXCLUDE_CSV="main,preview" +EXCLUDE_CSV_SET="false" while [[ "${1:-}" == -* ]]; do case "$1" in @@ -77,6 +115,23 @@ while [[ "${1:-}" == -* ]]; do SIGN_COMMITS="false" shift ;; + --restrict-branch-creation) + RESTRICT_CREATION="true" + shift + ;; + --only-restrict-creation) + ONLY_RESTRICT_CREATION="true" + shift + ;; + --exclude-branches) + if [[ -z "${2:-}" ]]; then + echo "Error: --exclude-branches requires a comma-separated list of patterns." >&2 + exit 1 + fi + EXCLUDE_CSV="$2" + EXCLUDE_CSV_SET="true" + shift 2 + ;; *) echo "Error: unknown option: $1" >&2 show_help @@ -85,6 +140,32 @@ while [[ "${1:-}" == -* ]]; do esac done +# ── Validate flag combinations ───────────────────────────────────────────────── + +if [[ "${RESTRICT_CREATION}" == "true" && "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo "Error: --restrict-branch-creation and --only-restrict-creation are mutually exclusive." >&2 + exit 1 +fi + +if [[ "${ONLY_RESTRICT_CREATION}" == "true" && -n "${APPROVERS_TEAM}" ]]; then + echo "Error: --only-restrict-creation is incompatible with --approvers." >&2 + exit 1 +fi + +if [[ "${ONLY_RESTRICT_CREATION}" == "true" && "${SIGN_COMMITS}" == "false" ]]; then + echo "Error: --only-restrict-creation is incompatible with --no-sign-commits." >&2 + exit 1 +fi + +if [[ "${EXCLUDE_CSV_SET}" == "true" && + "${RESTRICT_CREATION}" == "false" && + "${ONLY_RESTRICT_CREATION}" == "false" ]]; then + echo "Error: --exclude-branches requires --restrict-branch-creation or --only-restrict-creation." >&2 + exit 1 +fi + +# ── Positional arguments ─────────────────────────────────────────────────────── + if [[ -z "${1:-}" ]]; then show_help exit 1 @@ -99,6 +180,8 @@ if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then exit 1 fi +# ── Dependency + auth checks ─────────────────────────────────────────────────── + command -v gh > /dev/null || { echo "Error: gh CLI not found in PATH." >&2 exit 1 @@ -108,18 +191,60 @@ gh auth status > /dev/null 2>&1 || { exit 2 } -echo "Applying branch protection to ${REPO}@${BRANCH}..." +# ── Validate --approvers team exists in the org ──────────────────────────────── +# Note: the GitHub team API (orgs/{org}/teams/{slug}) requires read:org scope +# in addition to admin:repo. A 404 may mean the team does not exist OR that +# the token lacks read:org; the error message covers both cases. -# Build the rules array for the ruleset. Signed commits are conditional. -if [[ "${SIGN_COMMITS}" == "true" ]]; then - SIGN_RULE=', - { "type": "required_signatures" }' -else - SIGN_RULE='' +if [[ -n "${APPROVERS_TEAM}" ]]; then + ORG="${REPO%%/*}" + if ! gh api "orgs/${ORG}/teams/${APPROVERS_TEAM}" > /dev/null 2>&1; then + echo "Error: team '${APPROVERS_TEAM}' not found in org '${ORG}'," >&2 + echo " or gh token lacks read:org scope required by the team API." >&2 + exit 1 + fi fi -# Compose the ruleset payload (used for both create and update). -RULESET_PAYLOAD=$(printf '{ +# ── Build the creation-restriction ruleset exclude JSON ─────────────────────── +# Each CSV entry is trimmed and prefixed with refs/heads/ so bare names like +# "main" match correctly. fnmatch patterns (e.g. release/**) are preserved. + +_build_exclude_json() { + local csv="$1" + local result="" + local p + IFS=',' read -ra _pats <<< "${csv}" + for p in "${_pats[@]}"; do + # trim leading/trailing whitespace + p="${p#"${p%%[![:space:]]*}"}" + p="${p%"${p##*[![:space:]]}"}" + [[ -z "$p" ]] && continue + result+="${result:+, }\"refs/heads/${p}\"" + done + if [[ -z "${result}" ]]; then + echo "Error: --exclude-branches produced no usable patterns after trimming." >&2 + echo " At least one non-empty branch name or pattern is required." >&2 + exit 1 + fi + printf '%s' "${result}" +} + +# ── Full branch protection (steps 1–4) ──────────────────────────────────────── +# Skipped when --only-restrict-creation is set. + +if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then + echo "Applying branch protection to ${REPO}@${BRANCH}..." + + # Build the rules array. Signed commits are conditional. + if [[ "${SIGN_COMMITS}" == "true" ]]; then + SIGN_RULE=', + { "type": "required_signatures" }' + else + SIGN_RULE='' + fi + + # Compose the ruleset payload (used for both create and update). + RULESET_PAYLOAD=$(printf '{ "name": "%s", "target": "branch", "enforcement": "active", @@ -143,38 +268,38 @@ RULESET_PAYLOAD=$(printf '{ } }%s ] -}' "${RULESET_NAME}" "${SIGN_RULE}") +}' "${FULL_RULESET_NAME}" "${SIGN_RULE}") -# 1) Repository ruleset (all branches via ~ALL) — idempotent. -# Check for an existing ruleset with our name; PUT if found, POST if not. -RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${RULESET_NAME}\") | .id" 2> /dev/null || true) + # 1) Repository ruleset (all branches via ~ALL) — idempotent. + # Check for an existing ruleset with our name; PUT if found, POST if not. + RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ + --jq ".[] | select(.name==\"${FULL_RULESET_NAME}\") | .id" 2> /dev/null || true) -if [[ -n "${RULESET_ID}" ]]; then - printf '%s' "${RULESET_PAYLOAD}" | - gh api -X PUT "repos/${REPO}/rulesets/${RULESET_ID}" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - RULESET_ACTION="updated" -else - printf '%s' "${RULESET_PAYLOAD}" | - gh api -X POST "repos/${REPO}/rulesets" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - RULESET_ACTION="created" -fi + if [[ -n "${RULESET_ID}" ]]; then + printf '%s' "${RULESET_PAYLOAD}" | + gh api -X PUT "repos/${REPO}/rulesets/${RULESET_ID}" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + RULESET_ACTION="updated" + else + printf '%s' "${RULESET_PAYLOAD}" | + gh api -X POST "repos/${REPO}/rulesets" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + RULESET_ACTION="created" + fi -# 2) Classic named-branch protection — enforce_admins + optional dismissal restrictions. -# The Ruleset covers the substantive rules for all branches; the classic rule -# adds admin enforcement on the named branch and (when --approvers is set) -# restricts who can dismiss stale reviews to the named team. -if [[ -n "${APPROVERS_TEAM}" ]]; then - DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' -else - DISMISSAL_JSON='' -fi + # 2) Classic named-branch protection — enforce_admins + optional dismissal + # restrictions. The Ruleset covers the substantive rules for all + # branches; the classic rule adds admin enforcement on the named branch + # and (when --approvers is set) restricts who can dismiss stale reviews. + if [[ -n "${APPROVERS_TEAM}" ]]; then + DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' + else + DISMISSAL_JSON='' + fi -printf '{ + printf '{ "required_status_checks": null, "enforce_admins": true, "required_pull_request_reviews": { @@ -187,41 +312,89 @@ printf '{ "restrictions": null, "required_conversation_resolution": true }' "${DISMISSAL_JSON}" | - gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - -# 3) Belt-and-suspenders signed commits on the named branch via classic API. -# The Ruleset already requires signed commits on ~ALL branches, but the classic -# required_signatures endpoint provides an additional enforcement layer on the -# named branch. -if [[ "${SIGN_COMMITS}" == "true" ]]; then - gh api -X POST "repos/${REPO}/branches/${BRANCH}/protection/required_signatures" \ - -H "Accept: application/vnd.github+json" > /dev/null -fi + gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null -# 4) Secret scanning + push protection (repository-level) -printf '{ + # 3) Belt-and-suspenders signed commits on the named branch via classic API. + # The Ruleset already requires signed commits on ~ALL branches, but the + # classic required_signatures endpoint adds an enforcement layer on the + # named branch. + if [[ "${SIGN_COMMITS}" == "true" ]]; then + gh api -X POST \ + "repos/${REPO}/branches/${BRANCH}/protection/required_signatures" \ + -H "Accept: application/vnd.github+json" > /dev/null + fi + + # 4) Secret scanning + push protection (repository-level). + printf '{ "security_and_analysis": { "secret_scanning": { "status": "enabled" }, "secret_scanning_push_protection": { "status": "enabled" } } }' | gh api -X PATCH "repos/${REPO}" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - -echo "Done. ${REPO}@${BRANCH} protected:" -echo " - Ruleset '${RULESET_NAME}' ${RULESET_ACTION} (all branches via ~ALL)" -echo " PR + 1 approval + last-push + CODEOWNERS + conversation resolution" -echo " + deletion blocked + force-push blocked" -if [[ "${SIGN_COMMITS}" == "true" ]]; then - echo " + signed commits required" -fi -echo " - Classic protection on ${BRANCH}: admins enforced" -if [[ -n "${APPROVERS_TEAM}" ]]; then - echo " + stale-review dismissal restricted to team '${APPROVERS_TEAM}'" + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + + echo "Done. ${REPO}@${BRANCH} protected:" + echo " - Ruleset '${FULL_RULESET_NAME}' ${RULESET_ACTION} (all branches via ~ALL)" + echo " PR + 1 approval + last-push + CODEOWNERS + conversation resolution" + echo " + deletion blocked + force-push blocked" + if [[ "${SIGN_COMMITS}" == "true" ]]; then + echo " + signed commits required" + fi + echo " - Classic protection on ${BRANCH}: admins enforced" + if [[ -n "${APPROVERS_TEAM}" ]]; then + echo " + stale-review dismissal restricted to team '${APPROVERS_TEAM}'" + fi + if [[ "${SIGN_COMMITS}" == "true" ]]; then + echo " + signed commits required on ${BRANCH}" + fi + echo " - Secret scanning and push protection enabled" fi -if [[ "${SIGN_COMMITS}" == "true" ]]; then - echo " + signed commits required on ${BRANCH}" + +# ── Creation-restriction ruleset (step 5) ───────────────────────────────────── +# Applied when --restrict-branch-creation or --only-restrict-creation is set. + +if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + EXCLUDE_JSON=$(_build_exclude_json "${EXCLUDE_CSV}") + + CREATION_PAYLOAD=$(printf '{ + "name": "%s", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~ALL"], + "exclude": [%s] + } + }, + "rules": [ + { "type": "creation" } + ] +}' "${CREATION_RULESET_NAME}" "${EXCLUDE_JSON}") + + CREATION_RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ + --jq ".[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id" \ + 2> /dev/null || true) + + if [[ -n "${CREATION_RULESET_ID}" ]]; then + printf '%s' "${CREATION_PAYLOAD}" | + gh api -X PUT "repos/${REPO}/rulesets/${CREATION_RULESET_ID}" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + CREATION_ACTION="updated" + else + printf '%s' "${CREATION_PAYLOAD}" | + gh api -X POST "repos/${REPO}/rulesets" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + CREATION_ACTION="created" + fi + + if [[ "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo "Done. ${REPO} creation restricted:" + fi + echo " - Ruleset '${CREATION_RULESET_NAME}' ${CREATION_ACTION}" + echo " Branch creation blocked except: ${EXCLUDE_CSV}" fi -echo " - Secret scanning and push protection enabled" diff --git a/docs/gh-protect-branch.md b/docs/gh-protect-branch.md index 6c8366a..9b52041 100644 --- a/docs/gh-protect-branch.md +++ b/docs/gh-protect-branch.md @@ -6,8 +6,7 @@ through `gh(1)`. Enforces the full NSLS-II branch protection policy across two layers. Because the script is named `gh-*`, it is also auto-discovered by `gh` -as an extension, so -`gh protect-branch [--approvers ] [--no-sign-commits] [branch]` +as an extension, so `gh protect-branch [branch]` works equivalently. ## Protections applied @@ -35,8 +34,8 @@ rather than creating a duplicate. - Optionally restricts stale-review dismissal to a named GitHub team (`--approvers `) — only members of that team can dismiss a stale review, so a team member must re-approve after new commits are - pushed; the team slug must belong to the same GitHub organization as - the repo + pushed; the team slug is validated against the GitHub API before + protection is applied (a nonexistent team is a hard error) - Belt-and-suspenders signed commits on the named branch (in addition to the ruleset enforcement above) @@ -44,41 +43,105 @@ rather than creating a duplicate. - Secret scanning and push protection enabled +### Branch creation restriction (optional) + +A second ruleset named `nsls2-restrict-branch-creation` targeting +`~ALL` branches can be applied in addition to, or instead of, full +branch protection. It blocks creation of any branch whose name does not +match one of the excluded (allowed) patterns. + +Allowed patterns are supplied via `--exclude-branches` as a +comma-separated list of fnmatch branch-name strings. Each entry is +automatically prefixed with `refs/heads/` so a bare name such as `main` +matches correctly. fnmatch wildcards (`*`, `**`, `?`) are supported, so +`release/**` allows nested names like `release/2025/q1`. + +The default excluded patterns (when `--exclude-branches` is omitted) +are `main,preview`. + ## Usage ```text -gh-protect-branch [--approvers ] [--no-sign-commits] [branch] +gh-protect-branch [--approvers ] [--no-sign-commits] + [--restrict-branch-creation [--exclude-branches ]] + [branch] +gh-protect-branch --only-restrict-creation [--exclude-branches ] + +gh-protect-branch --help ``` | Option / Argument | Description | | --- | --- | -| `--approvers ` | GitHub team slug whose members are the only ones allowed to dismiss stale reviews (optional; org-owned repos only) | -| `--no-sign-commits` | Skip enforcing signed commits on the named branch (signing is required by default) | -| `` | Repository in `owner/repo` format (required) | -| `[branch]` | Primary branch to protect (default: `main`); ruleset protection is always applied to all branches regardless | +| `--approvers ` | GitHub team slug whose members are the only ones allowed to dismiss stale reviews. The team is validated via the GitHub API before protection is applied (requires `read:org` scope in addition to `admin:repo`). | +| `--no-sign-commits` | Skip enforcing signed commits. Incompatible with `--only-restrict-creation`. | +| `--restrict-branch-creation` | Apply the creation-restriction ruleset in addition to full branch protection. Mutually exclusive with `--only-restrict-creation`. | +| `--only-restrict-creation` | Apply only the creation-restriction ruleset; skip full branch protection, classic rules, and secret-scanning changes. Mutually exclusive with `--restrict-branch-creation`, `--approvers`, and `--no-sign-commits`. | +| `--exclude-branches ` | Comma-separated fnmatch patterns for branches whose creation is allowed. Default: `main,preview`. At least one non-empty pattern is required. Requires `--restrict-branch-creation` or `--only-restrict-creation`. | +| `` | Repository in `owner/repo` format (required). | +| `[branch]` | Primary branch to protect via the classic API (default: `main`). The ruleset always targets all branches regardless of this argument. Not used with `--only-restrict-creation`. | ## Examples ```bash -# Standard protection (no team restriction) +# Standard full protection gh-protect-branch NSLS2/n2snscripts -# With approver team restriction +# Standard protection on a non-default branch +gh-protect-branch NSLS2/n2sndocs main + +# With approver team restriction (team validated before applying) gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main -# Skip the signed-commit requirement (e.g. for repos where signing is not yet rolled out) +# Skip the signed-commit requirement gh-protect-branch --no-sign-commits NSLS2/n2snscripts + +# Full protection plus branch creation restriction (default: allow main,preview) +gh-protect-branch --restrict-branch-creation NSLS2/n2sndocs + +# Full protection plus creation restriction with custom allowed patterns +gh-protect-branch --restrict-branch-creation \ + --exclude-branches main,preview,release/** NSLS2/n2sndocs + +# Creation restriction only (no PR/signing changes) +gh-protect-branch --only-restrict-creation NSLS2/n2sndocs + +# Creation restriction only with custom allowed patterns +gh-protect-branch --only-restrict-creation \ + --exclude-branches main,preview NSLS2/n2sndocs ``` +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Usage error or missing dependency (includes team-not-found / `read:org` missing) | +| `2` | `gh` not authenticated | +| non-zero | `gh` API call failed; exit code is from `gh` (typically `1`) | + ## Caveats -Requires `gh(1)` authenticated with `admin:repo` scope on the target -repository. Secret scanning requires the repository to belong to an -organization on GitHub Team or Enterprise Cloud (or to be a public -repository). The `require_code_owner_review` rule is a no-op if no -`CODEOWNERS` file exists or no entry matches the changed files. Re-runs -are idempotent: the `nsls2-branch-protection` ruleset is updated in place -rather than duplicated. Signed-commit enforcement is applied to all -branches via the ruleset and additionally as a belt-and-suspenders rule -on the named branch via the classic `required_signatures` endpoint. +- Requires `gh(1)` authenticated with `admin:repo` scope on the target + repository. The `--approvers` preflight check also requires `read:org` + scope (GitHub team API requirement); a 404 may mean the team does not + exist **or** that the token lacks `read:org`. +- Secret scanning requires the repository to belong to an organization on + GitHub Team or Enterprise Cloud, or to be a public repository. +- Repository rulesets (both `nsls2-branch-protection` and + `nsls2-restrict-branch-creation`) require GitHub Team or Enterprise on + private repositories. +- The `--approvers` team slug is validated via the GitHub API before any + protection is applied. GitHub's branch-protection API silently ignores + unknown teams in `dismissal_restrictions`; the pre-flight check catches + this and exits with an error rather than applying a misconfigured rule. +- The `require_code_owner_review` rule is a no-op if no `CODEOWNERS` file + exists or no entry matches the changed files. +- Re-runs are idempotent: both rulesets are updated in place rather than + duplicated. +- Signed-commit enforcement is applied to all branches via the ruleset + and additionally as a belt-and-suspenders rule on the named branch via + the classic `required_signatures` endpoint. +- fnmatch patterns in `--exclude-branches` are automatically prefixed + with `refs/heads/`. Do not include `refs/heads/` yourself; it would + produce a doubled prefix.