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.
158 lines
5.8 KiB
Bash
Executable File
158 lines
5.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# tools/branch-protection/drift_check.sh — compare the live branch
|
|
# protection on staging + main against what apply.sh would set. Used
|
|
# by branch-protection-drift.yml (cron) to catch out-of-band UI edits.
|
|
#
|
|
# Pre-2026-05-05 version diffed only required_status_checks.checks —
|
|
# would have missed a UI click that flipped enforce_admins or
|
|
# dismiss_stale_reviews. Now compares the full normalized payload so
|
|
# any silent rewrite of admin/review/lock/deletion settings trips the
|
|
# drift gate.
|
|
#
|
|
# Exit codes:
|
|
# 0 — live state matches the script
|
|
# 1 — drift detected (output shows the diff)
|
|
# 2 — gh API call failed
|
|
|
|
set -euo pipefail
|
|
|
|
REPO="Molecule-AI/molecule-core"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
EXIT_CODE=0
|
|
|
|
# Normalise the GET /branches/:b/protection response so we can compare
|
|
# against apply.sh's payload. The GET response inflates booleans into
|
|
# {url, enabled} sub-objects and bypass list users/apps into full
|
|
# user/app objects with avatar_url etc — strip those down to match
|
|
# the input shape.
|
|
NORMALISE_LIVE='{
|
|
required_status_checks: (
|
|
.required_status_checks
|
|
| { strict: .strict,
|
|
checks: (.checks | map({context}) | sort_by(.context)) }
|
|
),
|
|
enforce_admins: (
|
|
if (.enforce_admins | type) == "object"
|
|
then .enforce_admins.enabled
|
|
else .enforce_admins end
|
|
),
|
|
required_pull_request_reviews: (
|
|
.required_pull_request_reviews
|
|
| if . == null then null else
|
|
{ required_approving_review_count,
|
|
dismiss_stale_reviews,
|
|
require_code_owner_reviews,
|
|
require_last_push_approval,
|
|
bypass_pull_request_allowances: (
|
|
if .bypass_pull_request_allowances == null then null
|
|
else {
|
|
users: (.bypass_pull_request_allowances.users // [] | map(.login) | sort),
|
|
teams: (.bypass_pull_request_allowances.teams // [] | map(.slug) | sort),
|
|
apps: (.bypass_pull_request_allowances.apps // [] | map(.slug) | sort)
|
|
} end
|
|
)
|
|
}
|
|
end
|
|
),
|
|
restrictions: (
|
|
if .restrictions == null then null
|
|
else { users: (.restrictions.users | map(.login) | sort),
|
|
teams: (.restrictions.teams | map(.slug) | sort),
|
|
apps: (.restrictions.apps | map(.slug) | sort) }
|
|
end
|
|
),
|
|
allow_deletions: (
|
|
if (.allow_deletions | type) == "object" then .allow_deletions.enabled
|
|
else (.allow_deletions // false) end
|
|
),
|
|
allow_force_pushes: (
|
|
if (.allow_force_pushes | type) == "object" then .allow_force_pushes.enabled
|
|
else (.allow_force_pushes // false) end
|
|
),
|
|
block_creations: (
|
|
if (.block_creations | type) == "object" then .block_creations.enabled
|
|
else (.block_creations // false) end
|
|
),
|
|
required_conversation_resolution: (
|
|
if (.required_conversation_resolution | type) == "object"
|
|
then .required_conversation_resolution.enabled
|
|
else (.required_conversation_resolution // false) end
|
|
),
|
|
required_linear_history: (
|
|
if (.required_linear_history | type) == "object" then .required_linear_history.enabled
|
|
else (.required_linear_history // false) end
|
|
),
|
|
lock_branch: (
|
|
if (.lock_branch | type) == "object" then .lock_branch.enabled
|
|
else (.lock_branch // false) end
|
|
),
|
|
allow_fork_syncing: (
|
|
if (.allow_fork_syncing | type) == "object" then .allow_fork_syncing.enabled
|
|
else (.allow_fork_syncing // false) end
|
|
)
|
|
}'
|
|
|
|
# Apply.sh's payload is already in the input shape; we just need to
|
|
# canonicalise the checks order and fill in optional fields with their
|
|
# defaults so the comparison aligns.
|
|
NORMALISE_SCRIPT='{
|
|
required_status_checks: {
|
|
strict: .required_status_checks.strict,
|
|
checks: (.required_status_checks.checks | map({context}) | sort_by(.context))
|
|
},
|
|
enforce_admins: .enforce_admins,
|
|
required_pull_request_reviews: (
|
|
if .required_pull_request_reviews == null then null else
|
|
{ required_approving_review_count: .required_pull_request_reviews.required_approving_review_count,
|
|
dismiss_stale_reviews: .required_pull_request_reviews.dismiss_stale_reviews,
|
|
require_code_owner_reviews: (.required_pull_request_reviews.require_code_owner_reviews // false),
|
|
require_last_push_approval: (.required_pull_request_reviews.require_last_push_approval // false),
|
|
bypass_pull_request_allowances: (
|
|
if .required_pull_request_reviews.bypass_pull_request_allowances == null then null
|
|
else {
|
|
users: (.required_pull_request_reviews.bypass_pull_request_allowances.users // [] | sort),
|
|
teams: (.required_pull_request_reviews.bypass_pull_request_allowances.teams // [] | sort),
|
|
apps: (.required_pull_request_reviews.bypass_pull_request_allowances.apps // [] | sort)
|
|
} end
|
|
)
|
|
}
|
|
end
|
|
),
|
|
restrictions: .restrictions,
|
|
allow_deletions: (.allow_deletions // false),
|
|
allow_force_pushes: (.allow_force_pushes // false),
|
|
block_creations: (.block_creations // false),
|
|
required_conversation_resolution: (.required_conversation_resolution // false),
|
|
required_linear_history: (.required_linear_history // false),
|
|
lock_branch: (.lock_branch // false),
|
|
allow_fork_syncing: (.allow_fork_syncing // false)
|
|
}'
|
|
|
|
check_branch() {
|
|
local branch="$1"
|
|
local want
|
|
want=$(bash "$SCRIPT_DIR/apply.sh" --dry-run --branch "$branch" 2>&1 |
|
|
sed -n '/^{$/,/^}$/p' |
|
|
jq -S "$NORMALISE_SCRIPT")
|
|
local have_raw
|
|
if ! have_raw=$(gh api "repos/$REPO/branches/$branch/protection" 2>/dev/null); then
|
|
echo "drift_check: FAIL to fetch $branch protection (gh API error)"
|
|
return 2
|
|
fi
|
|
local have
|
|
have=$(echo "$have_raw" | jq -S "$NORMALISE_LIVE")
|
|
if [[ "$want" != "$have" ]]; then
|
|
echo "=== DRIFT on $branch ==="
|
|
diff <(echo "$want") <(echo "$have") || true
|
|
return 1
|
|
fi
|
|
echo "OK: $branch matches desired state"
|
|
}
|
|
|
|
for b in staging main; do
|
|
if ! check_branch "$b"; then
|
|
EXIT_CODE=1
|
|
fi
|
|
done
|
|
exit "$EXIT_CODE"
|