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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,12 @@ Shared pragmatic markdownlint profile (defaults on; noisy prose/structural
rules off). Copy into a repo as `.trunk/configs/.markdownlint.yaml` and remove
`markdownlint` from `lint.disabled` in `.trunk/trunk.yaml`. Strict adoption is
tracked in #7.

## Org scripts

### `scripts/protect-branches.sh`

Applies the tiered default-branch protection across the `cshuttle` org (tier
membership and required checks live at the top of the script). Idempotent;
`DRY_RUN=1` prints the bodies, `ONLY=<repo>` limits it to one repo. Needs `gh`
with org admin and `jq`. Moved here from the retired `cshuttle/k8s` repo.
162 changes: 162 additions & 0 deletions scripts/protect-branches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
#
# Apply consistent main-branch protection across the cshuttle/* GitHub org.
#
# Tiers (strictness differs; mechanism is uniform):
# Tier 1 β€” cluster-impacting GitOps (cshuttle/main). PR required + required
# status checks (strict) + linear history + block force-push/deletion
# + conversation resolution. Self-merge (0 reviews) β€” solo owner.
# Tier 2 β€” service stacks, code, shared config. PR required + block
# force-push/deletion + conversation resolution; required checks where
# a context is listed in CHECKS and has actually reported.
# Tier 3 β€” static / personal / secret stores. Block force-push + deletion
# only; no PR ceremony.
#
# Design notes:
# * Reads each repo's DEFAULT branch (some repos default to `master`, not
# `main`) β€” never hardcodes the branch name.
# * Two-pass safe: a required check is only applied once that context has
# reported on the default branch. A not-yet-seen context is warned and
# skipped, so re-running after CI lands finishes the job. Never blocks a
# branch on a check that can never go green.
# * `enforce_admins=false` everywhere: the owner keeps a break-glass path.
# * Idempotent β€” safe to re-run. `DRY_RUN=1` prints the intended body instead
# of applying. `ONLY=<repo>` limits to one repo.
#
# Requires: gh (authenticated, admin on the org repos), jq.
set -euo pipefail

ORG=cshuttle
DRY_RUN="${DRY_RUN:-0}"
ONLY="${ONLY:-}"

# --- tier membership -------------------------------------------------------
TIER1=(main)
TIER2=(Komodo Omni Garage Garage-Admin Caddy Monitoring MCP-Gateway Semaphore
blinkstick-mqtt Terraform renovate-config workflows)
TIER3=(Cars WWW MagicMirror .password-store)

# --- desired required-check contexts (';'-separated; repos absent = none) ---
# Context names themselves contain spaces and slashes (e.g. "kustomize /
# validate"), so contexts are separated by ';', never whitespace. Only contexts
# that have actually reported on the default branch are applied.
declare -A CHECKS=(
[main]="kustomize / validate"
[workflows]="lefthook-config;actionlint" # public repo β€” required checks are free
)

# Split a repo's CHECKS entry into the global `WANT` array (empty if none).
# Must return 0 even when the repo has no checks, else `set -e` aborts the run.
want_for() {
WANT=()
if [ -n "${CHECKS[$1]:-}" ]; then IFS=';' read -ra WANT <<<"${CHECKS[$1]}"; fi
}

# --- helpers ---------------------------------------------------------------

# Echo the default branch for a repo.
default_branch() { gh api "repos/$ORG/$1" --jq '.default_branch'; }

# Print, one per line, the subset of requested contexts ($3..) that have
# reported on $1's branch $2. Warn (stderr) about the rest.
present_checks() {
local repo="$1" br="$2"; shift 2
[ "$#" -eq 0 ] && return 0
local have ctx
have="$(gh api "repos/$ORG/$repo/commits/$br/check-runs" \
--jq '[.check_runs[].name] | unique' 2>/dev/null || echo '[]')"
for ctx in "$@"; do
if jq -e --arg c "$ctx" 'index($c)' >/dev/null <<<"$have"; then
printf '%s\n' "$ctx"
else
echo " warn: $repo: check '$ctx' not yet reported on $br β€” skipping (re-run later)" >&2
fi
done
}

# Build a JSON array of {context} objects from contexts on stdin (one per line).
checks_json() { jq -R . | jq -s 'map({context: .})'; }

# PUT a protection body ($2) onto $1's default branch (or print it in dry-run).
apply() {
local repo="$1" body="$2" br
br="$(default_branch "$repo")"
if [ "$DRY_RUN" = 1 ]; then
echo "── $repo ($br) ──"; jq . <<<"$body"; return 0
fi
if jq . <<<"$body" | gh api -X PUT \
"repos/$ORG/$repo/branches/$br/protection" --input - >/dev/null; then
echo " βœ“ $repo ($br)"
else
echo " βœ— $repo ($br) β€” FAILED" >&2
fi
}

skip() { [ -n "$ONLY" ] && [ "$ONLY" != "$1" ]; }

# --- per-tier bodies -------------------------------------------------------

protect_tier1() {
local repo="$1" br checks
br="$(default_branch "$repo")"
want_for "$repo"
checks="$(present_checks "$repo" "$br" "${WANT[@]}" | checks_json)"
apply "$repo" "$(jq -n --argjson checks "$checks" '{
required_status_checks: { strict: true, checks: $checks },
enforce_admins: false,
required_pull_request_reviews: {
dismiss_stale_reviews: false,
require_code_owner_reviews: false,
required_approving_review_count: 0
},
restrictions: null,
required_linear_history: true,
allow_force_pushes: false,
allow_deletions: false,
required_conversation_resolution: true
}')"
}

protect_tier2() {
local repo="$1" br checks rsc
br="$(default_branch "$repo")"
want_for "$repo"
checks="$(present_checks "$repo" "$br" "${WANT[@]}" | checks_json)"
# null required_status_checks when there are no present contexts
if [ "$(jq 'length' <<<"$checks")" -gt 0 ]; then
rsc="$(jq -n --argjson c "$checks" '{strict:true, checks:$c}')"
else rsc=null; fi
apply "$repo" "$(jq -n --argjson rsc "$rsc" '{
required_status_checks: $rsc,
enforce_admins: false,
required_pull_request_reviews: {
dismiss_stale_reviews: false,
require_code_owner_reviews: false,
required_approving_review_count: 0
},
restrictions: null,
allow_force_pushes: false,
allow_deletions: false,
required_conversation_resolution: true
}')"
}

protect_tier3() {
apply "$1" "$(jq -n '{
required_status_checks: null,
enforce_admins: false,
required_pull_request_reviews: null,
restrictions: null,
allow_force_pushes: false,
allow_deletions: false
}')"
}

# --- run -------------------------------------------------------------------
echo "Tier 1 (strict β€” PR + required checks):"
for r in "${TIER1[@]}"; do skip "$r" && continue; protect_tier1 "$r"; done
echo "Tier 2 (PR + force/deletion block):"
for r in "${TIER2[@]}"; do skip "$r" && continue; protect_tier2 "$r"; done
echo "Tier 3 (force/deletion block only):"
for r in "${TIER3[@]}"; do skip "$r" && continue; protect_tier3 "$r"; done
echo "done."
Loading