From 9d15a77b1461ece90a005a7f3ff2ae45db097093 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:45:18 +0100 Subject: [PATCH 1/2] fix(applier): never PUT an org-inherited ruleset at the repo endpoint repos/{r}/rulesets RETURNS the organisation's rulesets alongside the repository's own. An inherited one reads back IN FULL at repos/{r}/rulesets/{id}, so every GET succeeds and nothing warns you -- and the PUT to that same path 404s. apply-branch-gates.sh selected `.target=="branch" and .enforcement=="active"` with no ownership filter, so on every metadatastician repo it picked up the org-level EstateBranching (18225024) and then PUT to the repo path. That is the failure already measured 67 times on this estate, once per reached repo. The discriminator is .source_type, which the LIST endpoint does return -- verified against the live API, every entry carries it. The population is now fetched and classified LOCALLY; a server-side select whose empty result is also its success result cannot fail closed. Three outcomes, none of them a guess: * only inherited rulesets active -> ORG-INHERITED, naming the ids and the /orgs/{org}/rulesets/{id} endpoint. The cure is at the org, applied ONCE; 67 doomed per-repo writes are not a smaller version of it. * inherited BESIDE a repo-level one -> the repo-level one is selected and the inherited one is REPORTED, because rulesets are additive and it goes on enforcing. Counting both as candidates would have turned an ordinary repo into AMBIGUOUS and left it permanently ungated. * .source_type absent -> UNKNOWN. Writability is exactly what that field decides; defaulting a missing one to the writable arm is a silent 404. Verified report-only against the live estate: metadatastician/paint-type, whose three active rulesets are ALL source_type=Organization, now reports ORG-INHERITED instead of attempting the write. Tests: CASE 12/13/14 plus MUTANT F, which restores the original selection and dies by PUTting to repos/{r}/rulesets/18225024 -- the 404 reproduced. Cases 12 and 13 deliberately ship NO fixture for the inherited ruleset's own path, so the shim's exit-1-on-missing-fixture asserts the fixed applier never reads it. The 14 existing rulesets-list fixtures omitted source_type, which the live API always sends; they are now faithful to it. 46/46 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-branch-gates.sh | 43 ++++++-- scripts/tests/branch-gates-apply-test.sh | 128 ++++++++++++++++++++--- 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/scripts/apply-branch-gates.sh b/scripts/apply-branch-gates.sh index 815eb954..a8470148 100755 --- a/scripts/apply-branch-gates.sh +++ b/scripts/apply-branch-gates.sh @@ -50,10 +50,22 @@ # --strip-retired to opt in, one repo at a time. # * It never emits a retired rule type itself. The exactness guard makes that # structurally impossible, not merely intended. -# * Two active branch rulesets => AMBIGUOUS, fail closed. Rulesets are -# ADDITIVE (see apply-tag-ruleset-canon.sh): writing one of a pair leaves -# the other enforcing, and the repo stays blocked by a rule nothing +# * Two active REPO-LEVEL branch rulesets => AMBIGUOUS, fail closed. Rulesets +# are ADDITIVE (see apply-tag-ruleset-canon.sh): writing one of a pair +# leaves the other enforcing, and the repo stays blocked by a rule nothing # announced. Guessing which to fill is how that happens silently. +# * It never tries to write an ORG-INHERITED ruleset. repos/{r}/rulesets +# RETURNS the org's rulesets alongside the repo's own, and one of them is +# readable IN FULL at repos/{r}/rulesets/{id} -- so every read succeeds and +# nothing warns you. The PUT to that same path 404s. That was measured 67 +# times, once for every metadatastician repo reached by the org-level +# EstateBranching (18225024). The cure for an inherited ruleset is at +# /orgs/{org}/rulesets/{id}, applied ONCE, not per repo -- so this script +# reports ORG-INHERITED and stops rather than issuing 67 doomed writes. +# The discriminator is .source_type, which the LIST endpoint does return +# (verified against the live API: every entry carries it). An entry WITHOUT +# it is reported UNKNOWN, never assumed repo-level: writability is exactly +# what that field decides, and guessing it wrong is a silent 404. # # Inputs (environment): # GH_TOKEN required for writes; needs administration:write on targets. @@ -327,14 +339,33 @@ while IFS= read -r R; do continue fi - # ---- 3. locate the one active branch ruleset -------------------------- + # ---- 3. locate the one active REPO-LEVEL branch ruleset ---------------- + # This listing includes the ORG's rulesets as well as the repo's own, and an + # inherited one reads back in full at repos/{r}/rulesets/{id} while the PUT + # to that same path 404s. Fetch the population and classify LOCALLY -- a + # server-side select whose empty result is also its success result cannot + # fail closed. gh api "repos/$R/rulesets" > "$WORK/rs.json" 2>"$WORK/e" \ || { emit "$R" "UNKNOWN" "rulesets GET failed"; continue; } - jq -r '.[]|select(.target=="branch" and .enforcement=="active")|.id' "$WORK/rs.json" > "$WORK/ids" + jq -r '.[]|select(.target=="branch" and .enforcement=="active") + |[(.source_type // "MISSING"), (.id|tostring)]|@tsv' "$WORK/rs.json" > "$WORK/active" + command grep -P '^Repository\t' "$WORK/active" | cut -f2 > "$WORK/ids" + command grep -vP '^(Repository|MISSING)\t' "$WORK/active" | cut -f2 > "$WORK/inherited" + NMISS=$(command grep -cP '^MISSING\t' "$WORK/active" || true) NIDS=$(wc -l < "$WORK/ids") + NINH=$(wc -l < "$WORK/inherited") + + # An absent discriminator REFUSES; it never defaults to the writable arm. + [ "${NMISS:-0}" -gt 0 ] && { emit "$R" "UNKNOWN" "$DETAIL — $NMISS active branch ruleset(s) carry no .source_type; cannot tell repo-level from org-inherited, refusing to guess"; continue; } + if [ "$NIDS" -eq 0 ] && [ "$NINH" -gt 0 ]; then + emit "$R" "ORG-INHERITED" "$DETAIL — the only active branch ruleset(s) here are org-level ($(paste -sd, "$WORK/inherited")); writable ONLY at /orgs/{org}/rulesets/{id}, cured once at the org, never per repo" + continue + fi [ "$NIDS" -eq 0 ] && { emit "$R" "NORULESET" "$DETAIL — no active branch ruleset; this script never creates one"; continue; } - [ "$NIDS" -gt 1 ] && { emit "$R" "AMBIGUOUS" "$DETAIL — $NIDS active branch rulesets ($(tr '\n' ',' < "$WORK/ids")); rulesets are additive, refusing to guess"; continue; } + [ "$NIDS" -gt 1 ] && { emit "$R" "AMBIGUOUS" "$DETAIL — $NIDS active repo-level branch rulesets ($(paste -sd, "$WORK/ids")); rulesets are additive, refusing to guess"; continue; } ID=$(cat "$WORK/ids") + # Additive: an inherited ruleset still enforces alongside the one being filled. + [ "$NINH" -gt 0 ] && DETAIL="$DETAIL org_inherited=[$(paste -sd, "$WORK/inherited")]" gh api "repos/$R/rulesets/$ID" > "$WORK/live.json" 2>/dev/null \ || { emit "$R" "UNKNOWN" "ruleset $ID GET failed"; continue; } diff --git a/scripts/tests/branch-gates-apply-test.sh b/scripts/tests/branch-gates-apply-test.sh index 9be5c81b..685bc72f 100755 --- a/scripts/tests/branch-gates-apply-test.sh +++ b/scripts/tests/branch-gates-apply-test.sh @@ -94,7 +94,7 @@ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{ mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":22}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"},{"name":"governance / Code quality + docs"},{"name":"Allowlist Preflight"}]}' mkfix "repos/$R/actions/runs/22/jobs?per_page=100" '{"jobs":[{"name":"CodeQL Security Analysis"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"},{"id":8,"target":"tag","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"},{"id":8,"target":"tag","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Base","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"pull_request"}],"rules":[{"type":"deletion"},{"type":"required_signatures"}]}' OUT=$(run_applier "$R") @@ -120,7 +120,7 @@ mkfix "repos/$R" '{"default_branch":"main"}' mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R" --apply) @@ -137,7 +137,7 @@ mkfix "repos/$R" '{"default_branch":"main"}' mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(MUTANT="$MUT" run_applier "$R" --apply) if [ "$(state_of "$OUT")" = "UNGATED" ]; then @@ -159,7 +159,7 @@ mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"pull_request"}],"rules":[{"type":"deletion"},{"type":"required_signatures"}]}' # MUTANT B: the body-builder also drops required_signatures. @@ -192,7 +192,7 @@ mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"},{"id":10,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"},{"id":10,"target":"branch","enforcement":"active","source_type":"Repository"}]' OUT=$(run_applier "$R" --apply) [ "$(state_of "$OUT")" = "AMBIGUOUS" ] && ok "two active branch rulesets: AMBIGUOUS, fail closed" || bad "two rulesets: state=$(state_of "$OUT") (want AMBIGUOUS)" [ -s "$FIX/PUTS.log" ] && bad "two rulesets: wrote anyway" || ok "two rulesets: no PUT" @@ -206,7 +206,7 @@ mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' -mkfix "repos/$R/rulesets" '[{"id":8,"target":"tag","enforcement":"active"},{"id":7,"target":"branch","enforcement":"disabled"}]' +mkfix "repos/$R/rulesets" '[{"id":8,"target":"tag","enforcement":"active","source_type":"Repository"},{"id":7,"target":"branch","enforcement":"disabled","source_type":"Repository"}]' OUT=$(run_applier "$R" --apply) [ "$(state_of "$OUT")" = "NORULESET" ] && ok "no active branch ruleset: NORULESET, nothing created" || bad "no ruleset: state=$(state_of "$OUT")" @@ -221,7 +221,7 @@ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{ mkfix "repos/$R/actions/workflows/ada-ci.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":33}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' mkfix "repos/$R/actions/runs/33/jobs?per_page=100" '{"jobs":[{"name":"Ada Build"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R") case "$(detail_of "$OUT")" in *"Ada Build"*) ok "profile detect: *.gpr glob activated the ada profile" ;; *) bad "profile detect: ada gate missing — $(detail_of "$OUT")" ;; esac @@ -254,7 +254,7 @@ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{ mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":22}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' # NOTE: no fixture for run 22's jobs -- the shim will exit 1. -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R" --apply) @@ -275,7 +275,7 @@ mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":22}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(MUTANT="$MUTC" run_applier "$R" --apply) if [ "$(state_of "$OUT")" = "REFUSED" ]; then @@ -311,7 +311,7 @@ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=3" '{"workflow_runs":[{"id":11},{"id":12}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' mkfix "repos/$R/actions/runs/12/jobs?per_page=100" '{"jobs":[]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R" --apply --require-green 3) @@ -333,7 +333,7 @@ if ! cmp -s "$MUTD" "$APPLIER"; then mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=3" '{"workflow_runs":[{"id":11},{"id":12}]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' mkfix "repos/$R/actions/runs/12/jobs?per_page=100" '{"jobs":[]}' - mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' + mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(MUTANT="$MUTD" run_applier "$R" --apply --require-green 3) if [ "$(state_of "$OUT")" = "REFUSED" ]; then @@ -368,7 +368,7 @@ mkfix "repos/$R/contents" '[{"name":"README.md"}]' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=3" '{"workflow_runs":[]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R" --apply --require-green 3) @@ -400,7 +400,7 @@ mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=3" '{ mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' mkfix "repos/$R/actions/runs/12/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' mkfix "repos/$R/actions/workflows/codeql.yml/runs?branch=main&per_page=1" '{"workflow_runs":[]}' -mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(run_applier "$R" --require-green 3) @@ -426,7 +426,7 @@ if ! cmp -s "$MUTE" "$APPLIER"; then mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=3" '{"workflow_runs":[]}' mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance","conclusion":"success"}]}' - mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' + mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' mkfix "repos/$R/rulesets/9" '{"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' OUT=$(MUTANT="$MUTE" run_applier "$R" --apply --require-green 3) if [ "$(state_of "$OUT")" = "REFUSED" ]; then @@ -444,6 +444,106 @@ else bad "mutant E was not applied — the sed pattern no longer matches the applier" fi +# =============================================================== CASE 12 +# THE ORG-INHERITED TRAP. repos/{r}/rulesets RETURNS the org's rulesets +# alongside the repo's own. An inherited one reads back IN FULL at +# repos/{r}/rulesets/{id} -- so every GET succeeds and nothing warns you -- +# while the PUT to that same path 404s. Measured 67 times on this estate, once +# per metadatastician repo reached by the org-level EstateBranching (18225024). +# The cure is at /orgs/{org}/rulesets/{id}, applied ONCE; issuing 67 doomed +# per-repo writes is not a smaller version of it. +# NOTE there is deliberately NO fixture for repos/$R/rulesets/18225024: the shim +# exits 1 on a missing fixture, so if the applier ever READS the inherited +# ruleset this case fails. That absence is a free assertion. +reset_fix +R=acme/org-inherited +mkfix "repos/$R" '{"default_branch":"main"}' +mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' +mkfix "repos/$R/contents" '[{"name":"README.md"}]' +mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' +mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' +mkfix "repos/$R/rulesets" '[{"id":18225024,"name":"EstateBranching","target":"branch","enforcement":"active","source_type":"Organization","source":"metadatastician"}]' + +OUT=$(run_applier "$R" --apply) +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "ORG-INHERITED" ] && ok "org-inherited: state is ORG-INHERITED" || bad "org-inherited: state=$S (want ORG-INHERITED) — an inherited ruleset was treated as writable" +case "$D" in *18225024*) ok "org-inherited: the inherited ruleset id is NAMED so the org-level cure is actionable" ;; *) bad "org-inherited: id not reported — $D" ;; esac +case "$D" in *"/orgs/"*) ok "org-inherited: the detail says WHERE the cure lives" ;; *) bad "org-inherited: detail does not point at the org endpoint — $D" ;; esac +[ -s "$FIX/PUTS.log" ] && bad "org-inherited: PUT issued despite --apply — this is the 404 being guarded" || ok "org-inherited: no PUT even with --apply" + +# =============================================================== CASE 13 +# THE REGRESSION THE NAIVE FIX WOULD CAUSE. Rulesets are ADDITIVE, so a repo +# legitimately carries an inherited ruleset BESIDE its own. Counting both as +# candidates turns that ordinary repo into AMBIGUOUS and it never gets gated. +# The repo-level one must be selected, and the inherited one REPORTED (it still +# enforces) rather than silently ignored. +# Again: no fixture for rulesets/18225024 -- reading it fails the case. +reset_fix +R=acme/org-plus-own +mkfix "repos/$R" '{"default_branch":"main"}' +mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' +mkfix "repos/$R/contents" '[{"name":"README.md"}]' +mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' +mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' +mkfix "repos/$R/rulesets" '[{"id":18225024,"name":"EstateBranching","target":"branch","enforcement":"active","source_type":"Organization"},{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Base","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"}]}' + +OUT=$(run_applier "$R") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "WOULD-GATE" ] && ok "org beside own: state is WOULD-GATE — the repo-level ruleset was selected" || bad "org beside own: state=$S (want WOULD-GATE) — an additive inherited ruleset made an ordinary repo unreachable" +case "$D" in *"ruleset=9"*) ok "org beside own: the REPO-LEVEL id was chosen, not the org id" ;; *) bad "org beside own: wrong ruleset selected — $D" ;; esac +case "$D" in *"org_inherited=[18225024]"*) ok "org beside own: the inherited ruleset is still REPORTED — it enforces regardless" ;; *) bad "org beside own: inherited ruleset went unreported — $D" ;; esac + +# =============================================================== CASE 14 +# AN ABSENT DISCRIMINATOR REFUSES. Writability is exactly what .source_type +# decides; defaulting a missing field to the writable arm is a silent 404. +reset_fix +R=acme/no-source-type +mkfix "repos/$R" '{"default_branch":"main"}' +mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' +mkfix "repos/$R/contents" '[{"name":"README.md"}]' +mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' +mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' +mkfix "repos/$R/rulesets" '[{"id":9,"target":"branch","enforcement":"active"}]' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' + +OUT=$(run_applier "$R" --apply) +S=$(state_of "$OUT") +[ "$S" = "UNKNOWN" ] && ok "absent source_type: state is UNKNOWN — refuses rather than assuming repo-level" || bad "absent source_type: state=$S (want UNKNOWN)" +[ -s "$FIX/PUTS.log" ] && bad "absent source_type: PUT issued on an unclassifiable ruleset" || ok "absent source_type: no PUT even with --apply" + +# ---- MUTANT F: delete the source_type filter, restoring the original defect. -- +# The pre-fix line selected every active branch ruleset regardless of ownership. +MUTF="$WORK/mutant-f.sh" +sed 's|^ command grep -P .\^Repository.*> "\$WORK/ids"$| cut -f2 "$WORK/active" > "$WORK/ids"|' "$APPLIER" > "$MUTF" +chmod +x "$MUTF" +if ! cmp -s "$MUTF" "$APPLIER" && bash -n "$MUTF" 2>/dev/null; then + reset_fix + R=acme/org-inherited + mkfix "repos/$R" '{"default_branch":"main"}' + mkfix "repos/$R/contents/.github/workflows" '[{"name":"governance.yml"}]' + mkfix "repos/$R/contents" '[{"name":"README.md"}]' + mkfix "repos/$R/actions/workflows/governance.yml/runs?branch=main&per_page=1" '{"workflow_runs":[{"id":11}]}' + mkfix "repos/$R/actions/runs/11/jobs?per_page=100" '{"jobs":[{"name":"governance / Governance"}]}' + mkfix "repos/$R/rulesets" '[{"id":18225024,"name":"EstateBranching","target":"branch","enforcement":"active","source_type":"Organization"}]' + # the mutant WILL read the inherited ruleset, so it needs a fixture the fixed + # applier must never ask for. + mkfix "repos/$R/rulesets/18225024" '{"id":18225024,"name":"EstateBranching","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' + OUT=$(MUTANT="$MUTF" run_applier "$R" --apply) + if [ "$(state_of "$OUT")" = "ORG-INHERITED" ]; then + bad "MUTANT F SURVIVED: source_type filter removed yet still ORG-INHERITED — the control is decorative" + else + ok "mutant F killed: without the filter it becomes $(state_of "$OUT") and PUTs $(wc -l < "$FIX/PUTS.log") time(s)" + fi + if command grep -qxF "repos/$R/rulesets/18225024" "$FIX/PUTS.log" 2>/dev/null; then + ok "mutant F PUT to the INHERITED ruleset's repo path — the 404 measured 67 times, reproduced" + else + bad "mutant F: expected a PUT to repos/$R/rulesets/18225024" + fi +else + bad "mutant F was not applied — the sed pattern no longer matches the applier" +fi + echo echo "passed=$pass failed=$fail" [ "$fail" -eq 0 ] From 8eb94f30d7055208e8ef502342d98f46e2780a57 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:52:58 +0100 Subject: [PATCH 2/2] Drop the GNU-only grep -P, and name the credential the cure needs Two hardening follow-ups on the org-inherited fix: 1. `grep -P` is a GNU extension. The classification of active branch rulesets into repo-level vs org-inherited is the load-bearing step of this script; it must not depend on which grep the runner ships. awk with an explicit `-F'\t'` does the same field test and is portable. Mutant F is retargeted at the awk line so the control still dies. 2. The ORG-INHERITED detail named the endpoint but not the credential. An org-level ruleset write needs `admin:org`; a repo-scoped token reads the ruleset in full and cannot write it, which is precisely the asymmetry that produced the original 404. Saying so in the report line saves the operator a second 403 chase. Suite: 46/46, mutant F still killed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-branch-gates.sh | 10 ++++++---- scripts/tests/branch-gates-apply-test.sh | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/apply-branch-gates.sh b/scripts/apply-branch-gates.sh index a8470148..74bc2cbb 100755 --- a/scripts/apply-branch-gates.sh +++ b/scripts/apply-branch-gates.sh @@ -349,16 +349,18 @@ while IFS= read -r R; do || { emit "$R" "UNKNOWN" "rulesets GET failed"; continue; } jq -r '.[]|select(.target=="branch" and .enforcement=="active") |[(.source_type // "MISSING"), (.id|tostring)]|@tsv' "$WORK/rs.json" > "$WORK/active" - command grep -P '^Repository\t' "$WORK/active" | cut -f2 > "$WORK/ids" - command grep -vP '^(Repository|MISSING)\t' "$WORK/active" | cut -f2 > "$WORK/inherited" - NMISS=$(command grep -cP '^MISSING\t' "$WORK/active" || true) + # awk, not grep -P: -P is a GNU extension and this script must not depend on + # which grep the runner ships. + awk -F'\t' '$1=="Repository"{print $2}' "$WORK/active" > "$WORK/ids" + awk -F'\t' '$1!="Repository" && $1!="MISSING"{print $2}' "$WORK/active" > "$WORK/inherited" + NMISS=$(awk -F'\t' '$1=="MISSING"{c++} END{print c+0}' "$WORK/active") NIDS=$(wc -l < "$WORK/ids") NINH=$(wc -l < "$WORK/inherited") # An absent discriminator REFUSES; it never defaults to the writable arm. [ "${NMISS:-0}" -gt 0 ] && { emit "$R" "UNKNOWN" "$DETAIL — $NMISS active branch ruleset(s) carry no .source_type; cannot tell repo-level from org-inherited, refusing to guess"; continue; } if [ "$NIDS" -eq 0 ] && [ "$NINH" -gt 0 ]; then - emit "$R" "ORG-INHERITED" "$DETAIL — the only active branch ruleset(s) here are org-level ($(paste -sd, "$WORK/inherited")); writable ONLY at /orgs/{org}/rulesets/{id}, cured once at the org, never per repo" + emit "$R" "ORG-INHERITED" "$DETAIL — the only active branch ruleset(s) here are org-level ($(paste -sd, "$WORK/inherited")); writable ONLY at /orgs/{org}/rulesets/{id} with an admin:org credential (a repo token reads it and cannot write it), cured once at the org, never per repo" continue fi [ "$NIDS" -eq 0 ] && { emit "$R" "NORULESET" "$DETAIL — no active branch ruleset; this script never creates one"; continue; } diff --git a/scripts/tests/branch-gates-apply-test.sh b/scripts/tests/branch-gates-apply-test.sh index 685bc72f..0d086a47 100755 --- a/scripts/tests/branch-gates-apply-test.sh +++ b/scripts/tests/branch-gates-apply-test.sh @@ -515,7 +515,7 @@ S=$(state_of "$OUT") # ---- MUTANT F: delete the source_type filter, restoring the original defect. -- # The pre-fix line selected every active branch ruleset regardless of ownership. MUTF="$WORK/mutant-f.sh" -sed 's|^ command grep -P .\^Repository.*> "\$WORK/ids"$| cut -f2 "$WORK/active" > "$WORK/ids"|' "$APPLIER" > "$MUTF" +sed 's|^ awk -F.\\t. ..1=="Repository".*> "\$WORK/ids"$| cut -f2 "$WORK/active" > "$WORK/ids"|' "$APPLIER" > "$MUTF" chmod +x "$MUTF" if ! cmp -s "$MUTF" "$APPLIER" && bash -n "$MUTF" 2>/dev/null; then reset_fix