Multi-model review of #2827 caught: the script as-shipped would have silently weakened branch protection on EVERY non-checks dimension the moment anyone ran it. Live staging had enforce_admins=true, dismiss_stale_reviews=false, strict=true, allow_fork_syncing=false, bypass_pull_request_allowances={ HongmingWang-Rabbit + molecule-ai app } Script wrote the opposite for all five. Per memory feedback_dismiss_stale_reviews_blocks_promote.md, the dismiss_stale_reviews flip alone is the load-bearing one — would silently re-block every auto-promote PR (cost user 2.5h once). This PR: 1. apply.sh: per-branch payloads (build_staging_payload / build_main_payload) that codify the deliberate per-branch policy already on the repo, with the script's net contribution being ONLY the new check names (Canvas tabs E2E + E2E API Smoke on staging, Canvas tabs E2E on main). 2. apply.sh: R3 preflight that hits /commits/{sha}/check-runs and asserts every desired check name has at least one historical run on the branch tip. Catches typos like "Canvas Tabs E2E" vs "Canvas tabs E2E" — pre-fix a typo would silently block every PR forever waiting for a context that never emits. Skip via --skip-preflight for genuinely-new workflows whose first run hasn't fired. 3. drift_check.sh: compares the FULL normalised payload (admin, review, lock, conversation, fork-syncing, deletion, force-push) not just the checks list. Pre-fix the drift gate would have missed a UI click that flipped enforce_admins or dismiss_stale_reviews. Drops app_id from the comparison since GH auto-resolves -1 to a specific app id post-write. 4. branch-protection-drift.yml: per memory feedback_schedule_vs_dispatch_secrets_hardening.md — schedule + pull_request triggers HARD-FAIL when GH_TOKEN_FOR_ADMIN_API is missing (silent skip masks the gate disappearing). workflow_dispatch keeps soft-skip for one-off operator runs. Verified by running drift_check against live state: pre-fix would have shown 5 destructive drifts on staging + 5 on main. Post-fix shows ONLY the 2 intended additions on staging + 1 on main, which go away after `apply.sh` runs.
239 lines
8.1 KiB
Bash
Executable File
239 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# tools/branch-protection/apply.sh — idempotently apply branch
|
|
# protection to molecule-core's `staging` and `main` branches.
|
|
#
|
|
# Single source of truth for the protection settings. Diff this file
|
|
# against the live state (drift_check.sh handles that nightly + on
|
|
# every PR that touches this directory).
|
|
#
|
|
# Why each branch has its OWN payload section instead of a shared
|
|
# template: pre-2026-05-05 the script generated both branches from a
|
|
# shared template that hard-coded enforce_admins=false,
|
|
# dismiss_stale_reviews=true, strict=false, allow_fork_syncing=true,
|
|
# and dropped bypass_pull_request_allowances. Live staging had
|
|
# enforce_admins=true, dismiss_stale_reviews=false, strict=true,
|
|
# allow_fork_syncing=false, and a bypass list. Running the script
|
|
# would have silently weakened protection on every dimension at once.
|
|
# Per-branch payloads codify the deliberate per-branch policy that
|
|
# already lives on the repo, with the script's net contribution
|
|
# being ONLY the explicit additions to required_status_checks.
|
|
#
|
|
# Per memory feedback_dismiss_stale_reviews_blocks_promote.md,
|
|
# dismiss_stale_reviews=true silently re-blocks every auto-promote PR
|
|
# (cost the user 2.5h once already on staging — confirming we keep
|
|
# this OFF on staging is load-bearing for the auto-promote chain).
|
|
#
|
|
# Usage:
|
|
# tools/branch-protection/apply.sh # apply both branches
|
|
# tools/branch-protection/apply.sh --dry-run # show payload only
|
|
# tools/branch-protection/apply.sh --branch staging
|
|
# tools/branch-protection/apply.sh --skip-preflight # skip check-name validation
|
|
#
|
|
# Requires: gh CLI authenticated as a repo admin. The script uses gh's
|
|
# token (no separate PAT needed).
|
|
|
|
set -euo pipefail
|
|
|
|
REPO="Molecule-AI/molecule-core"
|
|
DRY_RUN=0
|
|
ONLY_BRANCH=""
|
|
SKIP_PREFLIGHT=0
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
--branch) ONLY_BRANCH="$2"; shift 2 ;;
|
|
--skip-preflight) SKIP_PREFLIGHT=1; shift ;;
|
|
-h|--help)
|
|
echo "Usage: $0 [--dry-run] [--branch <name>] [--skip-preflight]"
|
|
exit 0
|
|
;;
|
|
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
# ─── Required-check matrices ──────────────────────────────────────
|
|
# Each branch's set is the canonical list of check NAMES (from each
|
|
# workflow's job-name). Adding/removing a check here is the place to
|
|
# do it. Match docs/e2e-coverage.md.
|
|
|
|
read -r -d '' STAGING_CHECKS <<'EOF' || true
|
|
Analyze (go)
|
|
Analyze (javascript-typescript)
|
|
Analyze (python)
|
|
Canvas (Next.js)
|
|
Canvas tabs E2E
|
|
Detect changes
|
|
E2E API Smoke Test
|
|
Platform (Go)
|
|
Python Lint & Test
|
|
Scan diff for credential-shaped strings
|
|
Shellcheck (E2E scripts)
|
|
EOF
|
|
|
|
read -r -d '' MAIN_CHECKS <<'EOF' || true
|
|
Analyze (go)
|
|
Analyze (javascript-typescript)
|
|
Analyze (python)
|
|
Canvas (Next.js)
|
|
Canvas tabs E2E
|
|
Detect changes
|
|
E2E API Smoke Test
|
|
PR-built wheel + import smoke
|
|
Platform (Go)
|
|
Python Lint & Test
|
|
Scan diff for credential-shaped strings
|
|
Shellcheck (E2E scripts)
|
|
EOF
|
|
|
|
checks_to_json() {
|
|
printf '%s\n' "$1" | jq -Rs '
|
|
split("\n")
|
|
| map(select(length > 0))
|
|
| map({context: ., app_id: -1})
|
|
'
|
|
}
|
|
|
|
# ─── Per-branch payloads (each preserves live-state policy) ───────
|
|
# Staging payload — preserves the live values that pre-2026-05-05's
|
|
# apply.sh would have silently rewritten:
|
|
# enforce_admins=true, dismiss_stale_reviews=false, strict=true,
|
|
# allow_fork_syncing=false, bypass list = HongmingWang-Rabbit + molecule-ai app.
|
|
build_staging_payload() {
|
|
local checks_json
|
|
checks_json=$(checks_to_json "$STAGING_CHECKS")
|
|
jq -n \
|
|
--argjson checks "$checks_json" \
|
|
'{
|
|
required_status_checks: {
|
|
strict: true,
|
|
checks: $checks
|
|
},
|
|
enforce_admins: true,
|
|
required_pull_request_reviews: {
|
|
required_approving_review_count: 1,
|
|
dismiss_stale_reviews: false,
|
|
require_code_owner_reviews: false,
|
|
require_last_push_approval: false,
|
|
bypass_pull_request_allowances: {
|
|
users: ["HongmingWang-Rabbit"],
|
|
teams: [],
|
|
apps: ["molecule-ai"]
|
|
}
|
|
},
|
|
restrictions: null,
|
|
allow_deletions: false,
|
|
allow_force_pushes: false,
|
|
block_creations: false,
|
|
required_conversation_resolution: true,
|
|
required_linear_history: false,
|
|
lock_branch: false,
|
|
allow_fork_syncing: false
|
|
}'
|
|
}
|
|
|
|
# Main payload — preserves the live values:
|
|
# enforce_admins=false, dismiss_stale_reviews=true, strict=true,
|
|
# allow_fork_syncing=false, NO bypass list.
|
|
# main intentionally has different settings than staging because main
|
|
# is the deploy target — the auto-promote app pushes to main without
|
|
# the friction of an admin-bypass list, and stale-review dismissal
|
|
# is acceptable here because every change has already cleared
|
|
# staging review.
|
|
build_main_payload() {
|
|
local checks_json
|
|
checks_json=$(checks_to_json "$MAIN_CHECKS")
|
|
jq -n \
|
|
--argjson checks "$checks_json" \
|
|
'{
|
|
required_status_checks: {
|
|
strict: true,
|
|
checks: $checks
|
|
},
|
|
enforce_admins: false,
|
|
required_pull_request_reviews: {
|
|
required_approving_review_count: 1,
|
|
dismiss_stale_reviews: true,
|
|
require_code_owner_reviews: false,
|
|
require_last_push_approval: false
|
|
},
|
|
restrictions: null,
|
|
allow_deletions: false,
|
|
allow_force_pushes: false,
|
|
block_creations: false,
|
|
required_conversation_resolution: true,
|
|
required_linear_history: false,
|
|
lock_branch: false,
|
|
allow_fork_syncing: false
|
|
}'
|
|
}
|
|
|
|
# ─── R3 preflight: validate every desired check name has at least
|
|
# one historical run ──────────────────────────────────────────────
|
|
# Pre-fix the script accepted arbitrary strings into
|
|
# required_status_checks.checks. A typo like "Canvas Tabs E2E" vs
|
|
# "Canvas tabs E2E" → GH accepts → every PR is blocked forever
|
|
# waiting for a context that never emits. The preflight hits the
|
|
# /commits/{sha}/check-runs endpoint and asserts each desired name
|
|
# has at least one matching run. Skippable via --skip-preflight for
|
|
# the case where you're adding a brand-new workflow whose first run
|
|
# hasn't fired yet.
|
|
preflight_check_names() {
|
|
local branch="$1"
|
|
local checks="$2"
|
|
local sha
|
|
sha=$(gh api "repos/$REPO/commits/$branch" --jq '.sha' 2>/dev/null || echo "")
|
|
if [[ -z "$sha" ]]; then
|
|
echo "preflight: WARN cannot resolve $branch tip SHA, skipping check-name validation" >&2
|
|
return 0
|
|
fi
|
|
local known_names
|
|
known_names=$(gh api "repos/$REPO/commits/$sha/check-runs?per_page=100" \
|
|
--jq '.check_runs | map(.name)' 2>/dev/null || echo "[]")
|
|
local missing=()
|
|
while IFS= read -r name; do
|
|
[[ -z "$name" ]] && continue
|
|
if ! echo "$known_names" | jq -e --arg n "$name" 'index($n) != null' >/dev/null; then
|
|
missing+=("$name")
|
|
fi
|
|
done <<< "$checks"
|
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
echo "preflight: $branch — these check names are NOT in the historical check-runs for the tip SHA:" >&2
|
|
printf ' - %s\n' "${missing[@]}" >&2
|
|
echo "If they're truly new (workflow added but never run), re-run with --skip-preflight." >&2
|
|
echo "Otherwise typos here will permanently block every PR — fix the names." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
apply_branch() {
|
|
local branch="$1"
|
|
local checks="$2"
|
|
local payload_fn="$3"
|
|
local payload
|
|
payload=$($payload_fn)
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
echo "=== branch: $branch ==="
|
|
echo "$payload" | jq .
|
|
return
|
|
fi
|
|
if [[ "$SKIP_PREFLIGHT" -eq 0 ]]; then
|
|
if ! preflight_check_names "$branch" "$checks"; then
|
|
echo "FAIL: preflight on $branch caught typos or missing workflows. Aborting." >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
echo "Applying branch protection on $branch..."
|
|
printf '%s' "$payload" | gh api -X PUT \
|
|
"repos/$REPO/branches/$branch/protection" \
|
|
--input -
|
|
echo "Applied: $branch"
|
|
}
|
|
|
|
if [[ -z "$ONLY_BRANCH" || "$ONLY_BRANCH" == "staging" ]]; then
|
|
apply_branch staging "$STAGING_CHECKS" build_staging_payload
|
|
fi
|
|
if [[ -z "$ONLY_BRANCH" || "$ONLY_BRANCH" == "main" ]]; then
|
|
apply_branch main "$MAIN_CHECKS" build_main_payload
|
|
fi
|