teqo-backup/scripts/lib/github-branch-protection.mjs

71 lines
2.6 KiB
JavaScript

/**
* Pure decision for the GitHub `main` branch protection rule (OPS71).
*
* The desired rule is the repo contract: main requires the `CI (PR) / checks`
* check-run (the ci-pr single-job cascade — OPS62) as the only status check
* (strict=false, 0 reviews) and `enforce_admins: true` so even repo admins
* cannot merge with a red required check (the rule-based protection of the
* Forgejo era applied to everyone — preserved).
*
* The auto-merge safety net never merges on red CI by construction (GitHub's
* server-side guarantee), but the branch protection rule is the final defense
* against every other merge path (manual API merges included).
*/
export const REQUIRED_CHECK_CONTEXT = 'CI (PR) / checks'
/**
* GitHub PUT /branches/{branch}/protection replace-semantics payload. Only
* `checks` (modern) is sent — `contexts` is legacy-derived and GitHub's API
* rejects the two together (422 anyOf). The GET normalization reads the
* check-run contexts from `checks`, so drift comparison is unaffected.
*/
export const DESIRED_RULE = Object.freeze({
required_status_checks: {
strict: false,
checks: [{ context: REQUIRED_CHECK_CONTEXT }],
},
enforce_admins: true,
required_pull_request_reviews: null,
restrictions: null,
required_linear_history: false,
allow_force_pushes: false,
allow_deletions: false,
block_creations: false,
})
const sameStrings = (have, want) =>
have.length === want.length &&
want.every((value) => have.includes(value)) &&
have.every((value) => want.includes(value))
/**
* True when the existing rule (from `getBranchProtection`, already
* normalized) matches the desired rule on every compared field — array
* fields as sets.
*/
export const ruleMatches = (existing) => {
if (!existing) return false
const wantStatus = DESIRED_RULE.required_status_checks
if (existing.enforce_admins !== DESIRED_RULE.enforce_admins) return false
if (existing.required_pull_request_reviews !== null) return false
const haveStatus = existing.required_status_checks
if (!haveStatus) return false
if (haveStatus.strict !== wantStatus.strict) return false
return sameStrings(
haveStatus.contexts,
wantStatus.checks.map((entry) => entry.context),
)
}
/**
* What to do with the rule: `create` (no rule), `update` (rule exists with
* drift), `noop` (already compliant). Idempotent re-runs land on `noop`.
*
* @param {object|null} existing - normalized rule from the API, or null
* @returns {{ action: 'create' | 'update' | 'noop' }}
*/
export const planBranchProtectionRule = (existing) => {
if (ruleMatches(existing)) return { action: 'noop' }
return { action: existing ? 'update' : 'create' }
}