From c2005e13397db38be0ad0b33a06f331fe0bf0ce9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:51:42 +0100 Subject: [PATCH] feat(gates): create branch gates where none exist, and harden three policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch-gates applier could report that a repo was ungated but never cure it. Adds `--create-gates`, the `host` profile, `--no-integration-bypass`, and retires `codeql.yml` from the required set. 49 new assertions across 11 cases and 3 mutants. Three policy-shaped changes, each named so a reviewer can object to one without rejecting the rest: 1. `host` profile in config/rulesets/gates.json A new estate profile for repos shipping a desktop/GUI host workflow, keyed on `detect_workflows: ["host.yml"]` (the `proof` profile's convention, not the file-glob convention used by rust/zig/ada). Measured live: without it, paint-type's required list contains NO `build-and-test` — the product gate that builds the binary and proves it draws to a canvas is not required at all. 2. `--no-integration-bypass` on the applier Strips Integration actors from the gates-only bypass body, retaining `RepositoryRole:5`. Owner ruling: harden the body for paint-type first, canon later. This DIVERGES from the committed canon body (config/rulesets/gates-only.json); the divergence is deliberate, tracked in config/README.adoc, and reported in the applier's own detail line as `bypass=no_integrations` so it can never be silent. A flag, not a second body file: a rival `gates-only-strict.json` beside `gates-only.json` would recreate exactly the `Immutable-Tags.json` vs `immutable-tags.json` two-rival-files trap that caused the 2026-09-11 zero-bypass outage across 372 repositories. The applier REFUSES rather than warns if stripping would empty the bypass list — a zero-bypass ruleset is that outage's shape, not a strict gate. Mutant I proves the refusal is load-bearing. 3. `codeql.yml` added to `never_required_workflows` PR-REACHABILITY, which neither the context derivation nor `--require-green` checks. Measured on paint-type today: `codeql.yml`'s trigger is `workflow_dispatch:` only (PR 6a retired the push trigger because org config sets `allow_advanced: false`), yet its last five runs on main are all stale `event: push` failures. The applier selects the newest and derives the context `analyze (actions, none)` — a required check NO pull request can ever emit, which would permanently block every future merge. `derive_failed` cannot catch this: it fires on an unreadable run, not an unreachable workflow. `--require-green` cannot either — it DROPS non-green contexts rather than refusing, so it masks this one only by the coincidence that it is currently failing. Had the dispatch-only workflow been manually dispatched green, `--require-green` would have admitted a permanently-unsatisfiable check. `never_required_contexts` is the wrong instrument: it already carries "CodeQL (default setup)", the green default-setup check-run that separately satisfies EstateBranching's `code_scanning` rule. The workflow and the context are different objects. SCOPE, stated because it exceeds the measurement: `never_required_workflows` is consulted for EVERY repo the applier runs against, so this entry is estate-wide while the evidence above is paint-type's. That is deliberate and consistent rather than a widening. `never_required_contexts` ALREADY carries "CodeQL (default setup)", so the estate has already ruled that the default-setup CodeQL check-run is never a required status check; this entry completes that policy for the advanced workflow, leaving CodeQL enforced estate-wide by EstateBranching's `code_scanning` RULE rather than by any required status context. The honest cost: a repo whose `codeql.yml` IS pull_request-reachable loses that context from its required list until a reachability check lands in the applier. That check is filed as an issue with acceptance criteria rather than added as a fourth policy item here. Tests: scripts/tests/branch-gates-apply-test.sh 549 -> 924 lines, passed=46 -> passed=95, failed=0. The `gh` shim gains a POST branch that records to POSTS.log, writes the posted body back as the re-GET fixture, and honours GH_POST_DRIFT so both drift arms are exercised. Three new mutants, each killing its own case (A-F were taken): G shape discriminator removed -> falls to AMBIGUOUS; without it the estate is permanently ungatable H canon-shape guard neutered -> CREATEs from a hand-typed context body (contexts must be DERIVED) I empty-bypass refusal neutered -> POSTs a zero-bypass ruleset, reproducing the 2026-09-11 outage Each mutant is guarded by `cmp -s` (the sed must actually change the file) and `bash -n` (an invalid mutant cannot red every case for the wrong reason). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YSq3UodR3CjsuAK5yoTzHF --- config/README.adoc | 83 ++++- config/rulesets/gates.json | 12 +- scripts/apply-branch-gates.sh | 176 ++++++++++- scripts/tests/branch-gates-apply-test.sh | 383 ++++++++++++++++++++++- 4 files changed, 633 insertions(+), 21 deletions(-) diff --git a/config/README.adoc b/config/README.adoc index c654f9f6a..bf5db9579 100644 --- a/config/README.adoc +++ b/config/README.adoc @@ -25,9 +25,10 @@ Tier rules: `docs/CICD-SIGNAL-DISCIPLINE.adoc`, section "Estate canon". | Which workflow *files* are 🔴 GATE per profile, and how contexts are derived. | `rulesets/gates-only.json` -| Owner decision O6 only: a second ruleset carrying just the status-check rule - with a short bypass list, so AI-reviewer apps cannot merge around gates. - *Not applied unless O6 is ruled.* +| Owner decision O6: a second ruleset carrying just the status-check rule with + a short bypass list, so AI-reviewer apps cannot merge around gates. O6 IS + ruled (#787 row D17); `apply-branch-gates.sh --create-gates` creates this + body, and fills its deliberately-empty context list. | `rulesets/Optimus-Extras.json` | The opt-in additive OVERLAY (owner rulings R4/R6). Carries ONLY rules that @@ -61,6 +62,82 @@ Exactly one such ruleset must exist; zero or two is a verifier failure. The same rule for tags with `["~ALL"]`. The `name` field in these files is what a fresh POST uses; an existing ruleset is PUT by id and keeps whatever name it has. +=== That rule as written is not sufficient, and both appliers now say so + +Two measured gaps. Neither is theoretical: each was found by running the +applier against a live repository and reading what it selected. + +*1. `source_type` is the writability discriminator, and the rule omits it.* +`repos/{owner}/{repo}/rulesets` returns the ORGANISATION's rulesets alongside +the repository's own, and an inherited one reads back IN FULL at +`repos/{owner}/{repo}/rulesets/{id}` — so every read succeeds and nothing warns +you. The PUT to that same path 404s. Measured 67 times, once per +`metadatastician` repo reached by org-level `EstateBranching` (18225024). The +cure for an inherited ruleset lives at `/orgs/{org}/rulesets/{id}` and needs an +`admin:org` credential; a repo token reads it and cannot write it. It is +applied ONCE at the org, never per repo, so `apply-branch-gates.sh` reports +*ORG-INHERITED* and stops rather than issuing 67 doomed writes. + +The classification is done LOCALLY, not by a server-side `select`: a filter +whose empty result is also its success result cannot fail closed. The listing +is projected to `[(.source_type // "MISSING"), .id]`, then split with `awk` +— not `grep -P`, which is a GNU extension the runner may not ship. An entry +carrying no `.source_type` is reported *UNKNOWN* and never assumed repo-level: +writability is exactly what that field decides, and guessing wrong is a silent +404. When a repo-level ruleset IS found alongside an inherited one, the +inherited ids are recorded as `org_inherited=[…]` in the detail column — +rulesets are additive, so that one still enforces beside the one being filled. + +*2. Two repo-level branch rulesets is the EXPECTED steady state, not an error.* +Owner decision O6 (`hyperpolymath/standards#787` row D17) prescribes a baseline +ruleset carrying the review and signature rules plus a *second* checks-only +ruleset whose short bypass list is the entire point. Bypass binds a RULESET, +never a rule, so status checks must live in their own object to have any teeth. +A flat "zero or two is a verifier failure" would therefore make the applier +permanently unable to maintain the shape the ruling prescribes. + +*SHAPE* is the discriminator: the gates ruleset is the one whose ONLY rule is +`required_status_checks`. Name classifies nothing — the tag applier proved that +estate-wide, where 372 blocked repos and 26 healthy ones shared one name. If +shape does not single one out, the applier still reports *AMBIGUOUS* and +refuses. ⚠ The LIST endpoint omits `.rules`, so this needs a by-id GET per +candidate; that same omission is what turned every PUT into a POST in the +2026-09-11 outage. + +⚠ The canon text above is unchanged on purpose. Propagating these two +corrections into it — and into the tag side, whose identification rule has the +identical `source_type` omission — is filed as issue #1032; the applier does +not decide it. + +=== Creating the O6 checks-only ruleset + +`apply-branch-gates.sh` fills an existing ruleset. It creates one only under +`--create-gates`, and then only from the committed canon body +`rulesets/gates-only.json` (overridable with `--gates-only-file`), which is +guarded for shape before use: target `branch`, enforcement `active`, +`required_status_checks` as its SOLE rule, and an EMPTY context list — the +applier is what fills that list, and a committed non-empty one would be a +*typed* context, the one thing `gates.json` forbids. Creating branch protection +where none exists is a policy act; O6 IS that policy, so creating THAT ONE body +implements a ruling rather than making one. + +`--create-gates` fires when there is no repo-*level* branch ruleset, including +when an org-level one is inherited. It never writes the inherited ruleset: +adding `required_status_checks` to a ruleset with a long bypass list produces a +gate that is indistinguishable from a real one in every summary view and binds +nobody on that list. + +`--no-integration-bypass` strips every `Integration` actor from the CREATED +body, so the AI actors are genuinely held to the gates. It requires +`--create-gates` — the update path may not touch `bypass_actors` at all, since +the exactness guard and the post-write DRIFT check both forbid it — and it +refuses to leave the bypass list EMPTY. A branch ruleset with zero bypass +actors is the shape of the 2026-09-11 tag outage, and where an org ruleset +upstream sets `require_code_owner_review` against a CODEOWNERS the sole +contributor cannot self-approve, it deadlocks the repository outright. +⚠ Using it DIVERGES from the committed canon body; that divergence is tracked +on #1032 so a later canon-conformance sweep does not silently revert it. + == Two layers, no duplication Owner ruling R4 (2026-09-14): _"make the base as tight as possible, and layer the diff --git a/config/rulesets/gates.json b/config/rulesets/gates.json index f3a635acc..233f40a85 100644 --- a/config/rulesets/gates.json +++ b/config/rulesets/gates.json @@ -81,6 +81,15 @@ "abi-ffi-gate.yml", "spark-theatre-gate.yml" ] + }, + "host": { + "applies_to": "repos that ship a desktop/GUI host workflow", + "detect_workflows": [ + "host.yml" + ], + "gate_workflows": [ + "host.yml" + ] } }, "never_required_workflows": [ @@ -91,7 +100,8 @@ "stale.yml", "oikosbot.yml", "readme-derive.yml", - "sonarqube.yml" + "sonarqube.yml", + "codeql.yml" ], "never_required_contexts": [ "Allowlist Preflight", diff --git a/scripts/apply-branch-gates.sh b/scripts/apply-branch-gates.sh index 74bc2cbb7..d41f4c6a5 100755 --- a/scripts/apply-branch-gates.sh +++ b/scripts/apply-branch-gates.sh @@ -41,19 +41,28 @@ # Without this, one jq slip silently strips required_signatures estate-wide. # # WHAT IT DELIBERATELY DOES NOT DO -# * It never CREATES a ruleset. A repo with no active branch ruleset is -# reported NORULESET. Creating branch protection where none exists is a -# policy act, not a gate-fill. +# * It never CREATES a ruleset unless --create-gates is passed, and then only +# from the committed canon body (--gates-only-file, guarded for shape). +# Without the flag a repo with no repo-level branch ruleset is reported +# NORULESET or ORG-INHERITED. Creating branch protection where none exists +# is a policy act, not a gate-fill; owner decision O6 (standards#787 row +# D17) IS that policy, so creating THAT ONE body implements a ruling. # * It never DELETES or rewrites another rule. Repos carrying the retired # types (update, required_deployments, code_quality, code_coverage) are # REPORTED, not repaired: the estate census was ruled report-only. Pass # --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 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. +# * Two active REPO-LEVEL branch rulesets are resolved BY SHAPE, and only by +# shape: the gates ruleset is the one whose only rule is +# required_status_checks. That pair is the EXPECTED O6 steady state, so a +# flat refusal would make the applier unable to maintain the very shape the +# ruling prescribes. If shape does not single one out => 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 -- and NAME never discriminates, as the tag applier proved with +# 372 blocked repos and 26 healthy ones sharing one name. # * 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 @@ -66,6 +75,10 @@ # (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. +# ⚠ --create-gates does NOT change that: an org-inherited ruleset is still +# never written. It creates a SECOND, repo-level checks-only ruleset beside +# it, which is what O6 prescribes -- filling the inherited one would be a +# fake gate regardless of writability, because bypass binds a RULESET. # # Inputs (environment): # GH_TOKEN required for writes; needs administration:write on targets. @@ -88,6 +101,29 @@ # `skipped` and `neutral` COUNT AS GREEN: GitHub treats # both as satisfying a required status check. # --skip-user do not enumerate user/repos; use only ESTATE_ORGS. +# --create-gates when a repo has NO repo-level branch ruleset, CREATE the +# O6 checks-only one from --gates-only-file instead of +# reporting and stopping. Off by default: creating branch +# protection where none exists is a policy act. Owner +# decision O6 (standards#787 row D17) IS that policy and +# config/rulesets/gates-only.json is its committed body, +# so creating THAT ONE body implements a ruling rather +# than making one. The body is guarded for canon shape +# and this script fills its context list; it is never +# hand-written and never read from an arbitrary file. +# --gates-only-file F default config/rulesets/gates-only.json +# --no-integration-bypass +# strip every Integration actor from the CREATED ruleset's +# bypass list, so the AI actors (claude, dependabot, +# github-actions, oikosbot) are genuinely bound by the +# gates from day one. Requires --create-gates: the update +# path may not touch bypass_actors at all (the exactness +# guard and the post-write DRIFT check both forbid it). +# ⚠ This DIVERGES from the committed canon body. It refuses +# to leave the bypass list empty -- a branch ruleset with +# zero bypass actors is the shape of the 2026-09-11 tag +# outage, and with require_code_owner_review upstream it +# would deadlock the repository outright. # # Output: TSV on stdout repo state detail # per-class summary on stderr. @@ -99,6 +135,8 @@ RETIRED_TYPES='update required_deployments code_quality code_coverage' ACTIONS_INTEGRATION_ID=15368 APPLY=0 LIMIT=0 STRIP_RETIRED=0 SKIP_USER=0 REQUIRE_GREEN=0 +CREATE_GATES=0 NO_INTEGRATION_BYPASS=0 +GATES_ONLY_FILE='config/rulesets/gates-only.json' GATES_FILE='config/rulesets/gates.json' REPOS_EXPLICIT=() @@ -113,6 +151,9 @@ while [ $# -gt 0 ]; do --strip-retired) STRIP_RETIRED=1 ;; --require-green) shift; [ $# -gt 0 ] || die 'usage: --require-green N'; REQUIRE_GREEN="$1" ;; --skip-user) SKIP_USER=1 ;; + --create-gates) CREATE_GATES=1 ;; + --gates-only-file) shift; [ $# -gt 0 ] || die 'usage: --gates-only-file PATH'; GATES_ONLY_FILE="$1" ;; + --no-integration-bypass) NO_INTEGRATION_BYPASS=1 ;; -h|--help) sed -n '2,70p' "$0"; exit 0 ;; *) die "unknown flag: $1" ;; esac @@ -120,6 +161,8 @@ while [ $# -gt 0 ]; do done [ -r "$GATES_FILE" ] || die "gates file not readable: $GATES_FILE" +[ "$NO_INTEGRATION_BYPASS" = 1 ] && [ "$CREATE_GATES" = 0 ] \ + && die '--no-integration-bypass applies to the CREATED ruleset only; pass --create-gates (the update path may not alter bypass_actors)' command -v gh >/dev/null || die 'gh is required' command -v jq >/dev/null || die 'jq is required' @@ -339,6 +382,12 @@ while IFS= read -r R; do continue fi + # ---- build the checks payload ONCE, before the ruleset is located ------ + # Both downstream paths need it: the PUT body filled into an existing rule, + # and the POST body created from the canon checks-only file. + jq -R -s --argjson iid "$ACTIONS_INTEGRATION_ID" \ + 'split("\n")|map(select(length>0))|map({context:., integration_id:$iid})' "$WORK/ctx2" > "$WORK/checks.json" + # ---- 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 @@ -359,12 +408,115 @@ while IFS= read -r R; do # 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} with an admin:org credential (a repo token reads it and cannot write it), cured once at the org, never per repo" + if [ "$NIDS" -eq 0 ]; then + # ---- 3a. nothing repo-level to fill: REPORT, or CREATE under the flag -- + # Creating branch protection where none exists is a policy act, which is + # why this script refused to do it at all. Owner decision O6 (#787 D17) IS + # that policy and config/rulesets/gates-only.json is its committed body, so + # creating THAT ONE body implements a ruling rather than making one. It + # stays behind an explicit flag and is never the default. + # + # An org-inherited ruleset does NOT satisfy O6 and must not be filled in + # its place: bypass binds a RULESET, never a rule, so required_status_checks + # added to EstateBranching (whose bypass list is long and deliberate) would + # be a fake gate -- indistinguishable from a real one in every summary view. + # O6's whole content is that the checks live in their own object with their + # own short bypass list. So the cure here is a SECOND, repo-level ruleset + # alongside the inherited one, not a write to the inherited one. + if [ "$CREATE_GATES" = 0 ]; then + if [ "$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} with an admin:org credential (a repo token reads it and cannot write it), cured once at the org, never per repo — and filling one would be a fake gate anyway, since bypass binds a ruleset; pass --create-gates to add the O6 repo-level checks-only ruleset from $GATES_ONLY_FILE" + else + emit "$R" "NORULESET" "$DETAIL — no active branch ruleset; pass --create-gates to create the O6 checks-only ruleset from $GATES_ONLY_FILE" + fi + continue + fi + [ -r "$GATES_ONLY_FILE" ] \ + || { emit "$R" "REFUSED" "$DETAIL — --create-gates needs a readable $GATES_ONLY_FILE"; continue; } + # CANON-SHAPE GUARD. Never hand-write a ruleset body, and never create one + # from a file that is not the canon body: it must target branches, be + # active, carry required_status_checks as its ONLY rule, and arrive with an + # EMPTY context list -- this script is what fills it. A committed non-empty + # list would be a TYPED context, which is the one thing gates.json forbids. + if [ "$(jq -cS '[.rules[]?.type]|unique' "$GATES_ONLY_FILE")" != '["required_status_checks"]' ] \ + || [ "$(jq -r '.target // ""' "$GATES_ONLY_FILE")" != 'branch' ] \ + || [ "$(jq -r '.enforcement // ""' "$GATES_ONLY_FILE")" != 'active' ] \ + || [ "$(jq -r '[.rules[]?|select(.type=="required_status_checks")|.parameters.required_status_checks[]?]|length' "$GATES_ONLY_FILE")" != '0' ]; then + emit "$R" "REFUSED" "$DETAIL — $GATES_ONLY_FILE is not the canon checks-only body (target/enforcement/sole-rule/empty-contexts)" + continue + fi + jq --slurpfile ck "$WORK/checks.json" ' + .rules = [{ type:"required_status_checks", + parameters:{ strict_required_status_checks_policy:false, + do_not_enforce_on_create:false, + required_status_checks:$ck[0] } }] + ' "$GATES_ONLY_FILE" > "$WORK/create.json" + + if [ "$NO_INTEGRATION_BYPASS" = 1 ]; then + jq '.bypass_actors = [(.bypass_actors // [])[] | select(.actor_type != "Integration")]' \ + "$WORK/create.json" > "$WORK/c2" && mv "$WORK/c2" "$WORK/create.json" + # A branch ruleset with ZERO bypass actors is the shape of the 2026-09-11 + # tag outage, and where an org ruleset upstream sets + # require_code_owner_review with a CODEOWNERS the sole contributor cannot + # self-approve, it deadlocks the repository outright. Refuse, never warn. + [ "$(jq '[.bypass_actors[]?]|length' "$WORK/create.json")" -gt 0 ] \ + || { emit "$R" "REFUSED" "$DETAIL — --no-integration-bypass would leave $GATES_ONLY_FILE with an EMPTY bypass list; a zero-bypass ruleset is an outage, not a strict gate"; continue; } + DETAIL="$DETAIL bypass=no_integrations" + fi + + if [ "$APPLY" = 0 ]; then + emit "$R" "WOULD-CREATE" "$DETAIL ruleset= :: $(tr '\n' '|' < "$WORK/ctx2")" + continue + fi + if ! gh api -X POST "repos/$R/rulesets" --input "$WORK/create.json" > "$WORK/created.json" 2>"$WORK/e"; then + emit "$R" "FAILED" "$DETAIL — POST: $(head -c 160 "$WORK/e" | tr -d '\n')"; continue + fi + NEWID=$(jq -r '.id // empty' "$WORK/created.json") + [ -n "$NEWID" ] || { emit "$R" "WROTE-UNVERIFIED" "$DETAIL — POST returned no id"; continue; } + gh api "repos/$R/rulesets/$NEWID" > "$WORK/after.json" 2>/dev/null \ + || { emit "$R" "WROTE-UNVERIFIED" "$DETAIL — re-GET of new ruleset $NEWID failed"; continue; } + WANT=$(jq -cS '[.rules[]?|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]|sort' "$WORK/create.json") + GOT=$(jq -cS '[.rules[]?|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]|sort' "$WORK/after.json") + [ "$WANT" = "$GOT" ] \ + || { emit "$R" "DRIFT" "$DETAIL — contexts after create != planned (ruleset $NEWID)"; continue; } + # The bypass list is the whole point of a checks-only ruleset, so verify it + # landed as sent -- a server-side default here would silently restore the + # actors --no-integration-bypass exists to remove. + WANT_BY=$(jq -cS '[.bypass_actors[]?|{actor_id,actor_type,bypass_mode}]|sort' "$WORK/create.json") + GOT_BY=$(jq -cS '[.bypass_actors[]?|{actor_id,actor_type,bypass_mode}]|sort' "$WORK/after.json") + [ "$WANT_BY" = "$GOT_BY" ] \ + || { emit "$R" "DRIFT" "$DETAIL — bypass_actors after create != planned (ruleset $NEWID)"; continue; } + emit "$R" "CREATED" "$DETAIL ruleset=$NEWID :: $(tr '\n' '|' < "$WORK/ctx2")" 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 repo-level branch rulesets ($(paste -sd, "$WORK/ids")); rulesets are additive, refusing to guess"; continue; } + + # TWO repo-level branch rulesets is the EXPECTED steady state after owner + # decision O6 (#787 row D17): a baseline ruleset carrying the review and + # signature rules, plus a second checks-only ruleset whose short bypass list + # is the entire point -- bypass binds a RULESET, never a rule, so status + # checks must live in their own object to have any teeth. Returning + # AMBIGUOUS there makes the applier permanently unable to maintain the very + # shape O6 prescribes. + # SHAPE is the discriminator: the gates ruleset is the one whose ONLY rule is + # required_status_checks. Name classifies nothing -- the tag applier proved + # that estate-wide, where 372 blocked repos and 26 healthy ones shared a name. + # NOTE: the LIST endpoint omits .rules, so this needs a by-id GET. That same + # omission is what turned every PUT into a POST in the 2026-09-11 outage. + if [ "$NIDS" -gt 1 ]; then + : > "$WORK/shaped" + while IFS= read -r CAND; do + [ -n "$CAND" ] || continue + gh api "repos/$R/rulesets/$CAND" > "$WORK/cand.json" 2>/dev/null || continue + if [ "$(jq -cS '[.rules[]?.type]|unique' "$WORK/cand.json")" = '["required_status_checks"]' ]; then + printf '%s\n' "$CAND" >> "$WORK/shaped" + fi + done < "$WORK/ids" + if [ "$(wc -l < "$WORK/shaped")" -eq 1 ]; then + cp "$WORK/shaped" "$WORK/ids"; NIDS=1 + DETAIL="$DETAIL picked_by=shape" + fi + fi + [ "$NIDS" -gt 1 ] && { emit "$R" "AMBIGUOUS" "$DETAIL — $NIDS active repo-level branch rulesets ($(paste -sd, "$WORK/ids")) and none is uniquely checks-only; 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")]" @@ -377,8 +529,6 @@ while IFS= read -r R; do [ -n "$FOUND_RETIRED" ] && DETAIL="$DETAIL retired_present=[$FOUND_RETIRED]" # ---- 4. build the PUT body ------------------------------------------- - jq -R -s --argjson iid "$ACTIONS_INTEGRATION_ID" \ - 'split("\n")|map(select(length>0))|map({context:., integration_id:$iid})' "$WORK/ctx2" > "$WORK/checks.json" jq --slurpfile ck "$WORK/checks.json" ' {name,target,enforcement,conditions,bypass_actors,rules} diff --git a/scripts/tests/branch-gates-apply-test.sh b/scripts/tests/branch-gates-apply-test.sh index 0d086a47d..62a770d42 100755 --- a/scripts/tests/branch-gates-apply-test.sh +++ b/scripts/tests/branch-gates-apply-test.sh @@ -10,7 +10,7 @@ # nothing at all — strictly worse than having no rule, because it is # indistinguishable from a working one. # -# A green suite proves nothing about that. So three of the cases below are +# A green suite proves nothing about that. So nine of the cases below are # MUTANTS: the applier is copied, the guard under test is deliberately # removed, and the suite must go RED. A mutant that survives means the # corresponding control is decorative. @@ -37,20 +37,35 @@ cat > "$BIN/gh" <<'SHIM' set -uo pipefail [ "${1:-}" = api ] || exit 0 shift -METHOD=GET; JQF=''; APIPATH='' +METHOD=GET; JQF=''; APIPATH=''; INPUT='' while [ $# -gt 0 ]; do case "$1" in -X) shift; METHOD="$1" ;; --jq) shift; JQF="$1" ;; - --input) shift; cp "$1" "$GH_FIX/LAST_PUT.json" ;; + --input) shift; INPUT="$1" ;; --paginate|--silent) : ;; -*) : ;; *) [ -n "$APIPATH" ] || APIPATH="$1" ;; esac shift done +[ -n "$INPUT" ] && cp "$INPUT" "$GH_FIX/LAST_${METHOD}.json" KEY=$(printf '%s' "$APIPATH" | tr '/?&=' '____') if [ "$METHOD" = PUT ]; then printf '%s\n' "$APIPATH" >> "$GH_FIX/PUTS.log"; echo '{}'; exit 0; fi +if [ "$METHOD" = POST ]; then + printf '%s\n' "$APIPATH" >> "$GH_FIX/POSTS.log" + NEWID="${GH_POST_ID:-77}" + # Model the server, do not stub it. A created ruleset reads back at its OWN + # path, so the POSTED body is written there as the fixture -- that is what + # makes the applier's post-create re-GET a real round trip instead of a + # tautology, and it is the seam GH_POST_DRIFT bends to exercise DRIFT. + if [ -n "$INPUT" ]; then + jq --argjson id "$NEWID" '.id=$id' "$INPUT" | jq "${GH_POST_DRIFT:-.}" \ + > "$GH_FIX/$(printf '%s' "$APIPATH/$NEWID" | tr '/?&=' '____').json" + fi + printf '{"id":%s}\n' "$NEWID" + exit 0 +fi F="$GH_FIX/$KEY.json" [ -r "$F" ] || exit 1 if [ -n "$JQF" ]; then jq -r "$JQF" "$F"; else cat "$F"; fi @@ -71,7 +86,7 @@ G mkfix() { printf '%s' "$2" > "$FIX/$(printf '%s' "$1" | tr '/?&=' '____').json"; } -reset_fix() { rm -f "$FIX"/*.json; cp "$GATES" "$WORK/gates.json"; : > "$FIX/PUTS.log"; } +reset_fix() { rm -f "$FIX"/*.json; cp "$GATES" "$WORK/gates.json"; : > "$FIX/PUTS.log"; : > "$FIX/POSTS.log"; } run_applier() { # run_applier [extra flags...] local repo="$1"; shift @@ -544,6 +559,366 @@ else bad "mutant F was not applied — the sed pattern no longer matches the applier" fi +# ========================================================================= +# THE CREATE PATH (--create-gates / --no-integration-bypass) +# Everything above this line exercises the UPDATE path against a ruleset +# that already exists. None of it touches the code that BRINGS ONE INTO +# BEING, which is the riskier half: an update can only widen or narrow an +# object the owner already chose to have, while a create writes a NEW +# permanent gate onto a repository from a body this script supplies. +# ========================================================================= + +# The five fixture lines CASE 12/13/14 spell out in full, hoisted -- the create +# cases need them ten times over and the repetition would bury the assertions. +# Identical calls, identical helper (mkfix); nothing new is being modelled. +fix_repo() { # fix_repo [job-name] + local r="$1" j="${2:-governance / Governance}" + 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" "$(jq -cn --arg n "$j" '{jobs:[{name:$n}]}')" +} + +# The canon checks-only body, in the shape config/rulesets/gates-only.json +# commits: ONE rule, an EMPTY context list (this script is what fills it), and +# a short bypass list whose existence is the entire point of a separate object. +GO="$WORK/gates-only.json" +cat > "$GO" <<'GOC' +{ "name": "Gates", "target": "branch", "enforcement": "active", + "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" }, + { "actor_id": 1236702, "actor_type": "Integration", "bypass_mode": "pull_request" }, + { "actor_id": 29110, "actor_type": "Integration", "bypass_mode": "pull_request" } ], + "rules": [ { "type": "required_status_checks", + "parameters": { "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [] } } ] } +GOC + +# A body carrying a HAND-TYPED context. gates.json's whole doctrine is that a +# context is derived from an emitted check-run name and never typed, so this +# body must be refused even though it is otherwise well-formed. +GO_TYPED="$WORK/gates-only-typed.json" +jq '.rules[0].parameters.required_status_checks = [{"context":"CI / typed-by-hand","integration_id":15368}]' \ + "$GO" > "$GO_TYPED" + +# A body whose bypass list is Integrations ONLY. Stripping them empties it. +GO_INTONLY="$WORK/gates-only-intonly.json" +jq '.bypass_actors = [(.bypass_actors[] | select(.actor_type == "Integration"))]' "$GO" > "$GO_INTONLY" + +# =============================================================== CASE 15 +# ORG-INHERITED must NAME THE CURE. CASE 12 proves the state and the org +# endpoint; what is asserted here is that the report tells the operator the one +# flag that changes the outcome, and names the body it would create from. A +# refusal that does not say how to proceed is a dead end, not a guard. +reset_fix +R=acme/inherited-needs-cure +fix_repo "$R" +mkfix "repos/$R/rulesets" '[{"id":18225024,"name":"EstateBranching","target":"branch","enforcement":"active","source_type":"Organization"}]' + +OUT=$(run_applier "$R" --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "ORG-INHERITED" ] && ok "create/inherited: still ORG-INHERITED without the flag" || bad "create/inherited: state=$S (want ORG-INHERITED)" +case "$D" in *--create-gates*) ok "create/inherited: the detail names --create-gates as the cure" ;; *) bad "create/inherited: no cure pointer — $D" ;; esac +case "$D" in *"$GO"*) ok "create/inherited: the detail names the BODY it would create from" ;; *) bad "create/inherited: body path not named — $D" ;; esac +[ -s "$FIX/POSTS.log" ] && bad "create/inherited: POST issued without --create-gates" || ok "create/inherited: no POST without the flag" + +# =============================================================== CASE 16 +# NORULESET carries the same cure. This is the arm paint-type is NOT in and +# the one every unprotected repo in the estate is, so a missing pointer here +# costs the most. +reset_fix +R=acme/bare-repo +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "NORULESET" ] && ok "create/bare: state is NORULESET" || bad "create/bare: state=$S (want NORULESET)" +case "$D" in *--create-gates*) ok "create/bare: the detail names --create-gates as the cure" ;; *) bad "create/bare: no cure pointer — $D" ;; esac +[ -s "$FIX/POSTS.log" ] && bad "create/bare: POST issued without --create-gates" || ok "create/bare: no POST without the flag" + +# =============================================================== CASE 17 +# --create-gates WITHOUT --apply is a dry run, and a dry run that writes is the +# worst defect this suite can miss: it is invisible in the output and permanent +# on the server. Assert the POST log is empty, not merely that the state reads +# WOULD-CREATE -- a state string is not evidence about network calls. +reset_fix +R=acme/would-create +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --create-gates --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "WOULD-CREATE" ] && ok "would-create: state is WOULD-CREATE" || bad "would-create: state=$S (want WOULD-CREATE)" +case "$D" in *"ruleset="*) ok "would-create: the detail says it would create, and from which body" ;; *) bad "would-create: detail does not name the body — $D" ;; esac +case "$D" in *"governance / Governance"*) ok "would-create: the DERIVED context is shown before anything is written" ;; *) bad "would-create: context not reported — $D" ;; esac +[ -s "$FIX/POSTS.log" ] && bad "would-create: POST issued WITHOUT --apply — a dry run wrote to the server" || ok "would-create: no POST without --apply" + +# =============================================================== CASE 18 +# --apply --create-gates actually creates. The assertions are on the POSTED +# BODY, not on the state string: the state is what the script says it did, the +# body is what the server was actually told. +reset_fix +R=acme/creates +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --create-gates --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "CREATED" ] && ok "create: state is CREATED" || bad "create: state=$S (want CREATED) — $D" +case "$D" in *"ruleset=77"*) ok "create: the NEW id from the POST response is reported" ;; *) bad "create: new id not reported — $D" ;; esac +command grep -qxF "repos/$R/rulesets" "$FIX/POSTS.log" 2>/dev/null \ + && ok "create: POST went to the collection endpoint, not to an id" \ + || bad "create: expected a POST to repos/$R/rulesets, got $(cat "$FIX/POSTS.log" 2>/dev/null)" +[ -s "$FIX/PUTS.log" ] && bad "create: a PUT was issued on the create path" || ok "create: no PUT on the create path" +[ "$(jq -r '.name' "$FIX/LAST_POST.json")" = "Gates" ] \ + && ok "create: the posted body keeps the canon name" \ + || bad "create: posted name is $(jq -r '.name' "$FIX/LAST_POST.json") (want Gates)" +[ "$(jq -cS '[.rules[].type]' "$FIX/LAST_POST.json")" = '["required_status_checks"]' ] \ + && ok "create: required_status_checks is the SOLE rule — the bypass list stays short on purpose" \ + || bad "create: posted rules are $(jq -cS '[.rules[].type]' "$FIX/LAST_POST.json")" +[ "$(jq -r '[.rules[0].parameters.required_status_checks[].context]|join(",")' "$FIX/LAST_POST.json")" = "governance / Governance" ] \ + && ok "create: the posted contexts are the DERIVED ones" \ + || bad "create: posted contexts are $(jq -c '[.rules[0].parameters.required_status_checks[].context]' "$FIX/LAST_POST.json")" +[ "$(jq '[.bypass_actors[]]|length' "$FIX/LAST_POST.json")" = 3 ] \ + && ok "create: without --no-integration-bypass the canon bypass list is posted VERBATIM" \ + || bad "create: bypass list was altered without the flag — $(jq -c '.bypass_actors' "$FIX/LAST_POST.json")" + +# =============================================================== CASE 19 +# --no-integration-bypass is owner ruling Q2, and it is a DIVERGENCE FROM +# COMMITTED CANON, so it must be visible in the output as well as in the body. +# RepositoryRole:5 is retained deliberately: it is what keeps the repository +# recoverable, and dropping it is the 2026-09-11 outage shape. +reset_fix +R=acme/no-int-bypass +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --create-gates --no-integration-bypass --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "CREATED" ] && ok "no-int-bypass: state is CREATED" || bad "no-int-bypass: state=$S (want CREATED) — $D" +case "$D" in *"bypass=no_integrations"*) ok "no-int-bypass: the divergence from canon is DECLARED in the report" ;; *) bad "no-int-bypass: divergence is silent — $D" ;; esac +[ "$(jq '[.bypass_actors[]|select(.actor_type=="Integration")]|length' "$FIX/LAST_POST.json")" = 0 ] \ + && ok "no-int-bypass: zero Integration actors in the posted body" \ + || bad "no-int-bypass: Integrations survived — $(jq -c '.bypass_actors' "$FIX/LAST_POST.json")" +[ "$(jq '[.bypass_actors[]|select(.actor_type=="RepositoryRole" and .actor_id==5)]|length' "$FIX/LAST_POST.json")" = 1 ] \ + && ok "no-int-bypass: RepositoryRole:5 is RETAINED — the repo stays recoverable" \ + || bad "no-int-bypass: RepositoryRole:5 was stripped too — that is the outage shape, not a strict gate" + +# =============================================================== CASE 20 +# --no-integration-bypass WITHOUT --create-gates must die at argument parsing. +# The update path may not touch bypass_actors at all: silently accepting the +# flag there would read as "the Integrations were stripped" while leaving the +# live ruleset exactly as it was. +reset_fix +R=acme/flag-misuse +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --no-integration-bypass --gates-only-file "$GO"); RC=$? +[ "$RC" -ne 0 ] && ok "flag misuse: --no-integration-bypass alone exits non-zero ($RC)" || bad "flag misuse: rc=0 — the flag was silently accepted on the update path" +command grep -q 'create-gates' "$WORK/err" && ok "flag misuse: the error names the flag that would make it valid" || bad "flag misuse: unhelpful error — $(cat "$WORK/err")" +[ -s "$FIX/POSTS.log" ] && bad "flag misuse: a POST was issued" || ok "flag misuse: no POST" +[ -s "$FIX/PUTS.log" ] && bad "flag misuse: a PUT was issued" || ok "flag misuse: no PUT" + +# =============================================================== CASE 21 +# Stripping Integrations from a body whose bypass list is Integrations ONLY +# leaves ZERO bypass actors. A branch ruleset with no bypass, under an org +# ruleset requiring code-owner review that the sole contributor cannot +# self-approve, deadlocks the repository outright. REFUSE, never warn. +reset_fix +R=acme/would-empty-bypass +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --create-gates --no-integration-bypass --gates-only-file "$GO_INTONLY") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "REFUSED" ] && ok "empty bypass: state is REFUSED — a zero-bypass ruleset is an outage, not a strict gate" || bad "empty bypass: state=$S (want REFUSED) — $D" +case "$D" in *"EMPTY bypass"*) ok "empty bypass: the reason names the empty list" ;; *) bad "empty bypass: reason unclear — $D" ;; esac +[ -s "$FIX/POSTS.log" ] && bad "empty bypass: a zero-bypass ruleset was POSTED" || ok "empty bypass: nothing was written" + +# =============================================================== CASE 22 +# THE CANON-SHAPE GUARD. A committed body carrying a hand-typed context is the +# one thing gates.json forbids outright: contexts are derived from emitted +# check-run names, never typed, because a typed name that no job emits is a +# permanently unsatisfiable required check. +reset_fix +R=acme/typed-body +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --create-gates --gates-only-file "$GO_TYPED") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "REFUSED" ] && ok "typed body: state is REFUSED by the canon-shape guard" || bad "typed body: state=$S (want REFUSED) — a hand-typed context was accepted as a create body" +case "$D" in *"canon checks-only body"*) ok "typed body: the reason names the shape that was violated" ;; *) bad "typed body: reason unclear — $D" ;; esac +[ -s "$FIX/POSTS.log" ] && bad "typed body: a non-canon body was POSTED" || ok "typed body: nothing was written" + +# =============================================================== CASE 23 +# TWO repo-level branch rulesets is the EXPECTED steady state after O6, so +# AMBIGUOUS there would make the applier permanently unable to maintain the +# very shape O6 prescribes. SHAPE is the discriminator -- the gates ruleset is +# the one whose ONLY rule is required_status_checks. Name classifies nothing. +reset_fix +R=acme/two-own-one-shaped +fix_repo "$R" +mkfix "repos/$R/rulesets" '[{"id":8,"target":"branch","enforcement":"active","source_type":"Repository"},{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' +mkfix "repos/$R/rulesets/8" '{"id":8,"name":"Base","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"required_signatures"}]}' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Gates","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":"required_status_checks","parameters":{"strict_required_status_checks_policy":false,"do_not_enforce_on_create":false,"required_status_checks":[]}}]}' + +OUT=$(run_applier "$R") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "WOULD-GATE" ] && ok "two own: the checks-only ruleset was selected by SHAPE" || bad "two own: state=$S (want WOULD-GATE) — the O6 steady state was treated as ambiguous" +case "$D" in *"picked_by=shape"*) ok "two own: the report says HOW it disambiguated" ;; *) bad "two own: no picked_by in detail — $D" ;; esac +case "$D" in *"ruleset=9"*) ok "two own: the checks-only id was chosen, not the baseline one" ;; *) bad "two own: wrong ruleset — $D" ;; esac + +# ...and when NEITHER is uniquely checks-only, shape cannot decide and the only +# honest answer is to refuse. Rulesets are additive; guessing writes a real gate +# onto the wrong object. +reset_fix +R=acme/two-own-none-shaped +fix_repo "$R" +mkfix "repos/$R/rulesets" '[{"id":8,"target":"branch","enforcement":"active","source_type":"Repository"},{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' +mkfix "repos/$R/rulesets/8" '{"id":8,"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' +mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Other","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"non_fast_forward"}]}' + +OUT=$(run_applier "$R" --apply) +S=$(state_of "$OUT") +[ "$S" = "AMBIGUOUS" ] && ok "two own, none shaped: AMBIGUOUS — refuses to guess" || bad "two own, none shaped: state=$S (want AMBIGUOUS)" +[ -s "$FIX/PUTS.log" ] && bad "two own, none shaped: a PUT was issued on a guess" || ok "two own, none shaped: no PUT even with --apply" + +# =============================================================== CASE 24 +# THE VACUOUS-GATE REFUSAL, ON THE CREATE PATH. This is the defect the whole +# suite exists to prevent, and --create-gates is a NEW way to reach it: a +# required_status_checks rule carrying an empty context list, brought into +# being rather than written into an existing object. Zero contexts must not +# create anything at all. +reset_fix +R=acme/create-zero-ctx +fix_repo "$R" "Allowlist Preflight" # the only job is never-required +mkfix "repos/$R/rulesets" '[]' + +OUT=$(run_applier "$R" --apply --create-gates --gates-only-file "$GO") +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "UNGATED" ] && ok "create/zero contexts: state is UNGATED — refuses to create a vacuous gate" || bad "create/zero contexts: state=$S (want UNGATED) — an empty rule was about to be created" +[ -s "$FIX/POSTS.log" ] && bad "create/zero contexts: a VACUOUS ruleset was POSTED" || ok "create/zero contexts: nothing was written" + +# =============================================================== CASE 25 +# POST-CREATE VERIFICATION. rc=0 from a write is not evidence the write took +# effect -- measured estate-wide, a ruleset PUT returned 200 with an EMPTY BODY +# and had not applied. The applier re-GETs and compares; these two arms bend +# the server's reply so that the comparison is a real round trip and not a +# tautology. GH_POST_DRIFT is the seam. +reset_fix +R=acme/drift-contexts +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' +export GH_POST_DRIFT='.rules[0].parameters.required_status_checks = []' +OUT=$(run_applier "$R" --apply --create-gates --gates-only-file "$GO") +unset GH_POST_DRIFT +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "DRIFT" ] && ok "drift/contexts: a server that dropped the contexts is reported as DRIFT, not as CREATED" || bad "drift/contexts: state=$S (want DRIFT) — the post-create re-GET is a tautology" +case "$D" in *"contexts after create"*) ok "drift/contexts: the reason names WHICH field drifted" ;; *) bad "drift/contexts: reason unclear — $D" ;; esac + +# The bypass list is the whole point of a checks-only ruleset, so a server-side +# default restoring the actors --no-integration-bypass just removed must not be +# reported as success. +reset_fix +R=acme/drift-bypass +fix_repo "$R" +mkfix "repos/$R/rulesets" '[]' +export GH_POST_DRIFT='.bypass_actors += [{"actor_id":1236702,"actor_type":"Integration","bypass_mode":"pull_request"}]' +OUT=$(run_applier "$R" --apply --create-gates --no-integration-bypass --gates-only-file "$GO") +unset GH_POST_DRIFT +S=$(state_of "$OUT"); D=$(detail_of "$OUT") +[ "$S" = "DRIFT" ] && ok "drift/bypass: a server that restored an Integration is reported as DRIFT" || bad "drift/bypass: state=$S (want DRIFT) — the stripped actor came back unnoticed" +case "$D" in *"bypass_actors after create"*) ok "drift/bypass: the reason names WHICH field drifted" ;; *) bad "drift/bypass: reason unclear — $D" ;; esac + +# ---- MUTANT G: delete the SHAPE discriminator from the two-ruleset arm. ------ +# Without it the O6 steady state (baseline + checks-only) is unreachable: every +# such repo reports AMBIGUOUS and is never gated again. +MUTG="$WORK/mutant-g.sh" +cat > "$WORK/mut-g.sed" <<'SEDG' +s#^ if \[ "$(wc -l < "$WORK/shaped")" -eq 1 ]; then$# if false; then# +SEDG +sed -f "$WORK/mut-g.sed" "$APPLIER" > "$MUTG" +chmod +x "$MUTG" +if ! cmp -s "$MUTG" "$APPLIER" && bash -n "$MUTG" 2>/dev/null; then + reset_fix + R=acme/two-own-one-shaped + fix_repo "$R" + mkfix "repos/$R/rulesets" '[{"id":8,"target":"branch","enforcement":"active","source_type":"Repository"},{"id":9,"target":"branch","enforcement":"active","source_type":"Repository"}]' + mkfix "repos/$R/rulesets/8" '{"id":8,"name":"Base","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[],"rules":[{"type":"deletion"}]}' + mkfix "repos/$R/rulesets/9" '{"id":9,"name":"Gates","target":"branch","enforcement":"active","conditions":{},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"pull_request"}],"rules":[{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":false,"do_not_enforce_on_create":false,"required_status_checks":[]}}]}' + OUT=$(MUTANT="$MUTG" run_applier "$R") + if [ "$(state_of "$OUT")" = "AMBIGUOUS" ]; then + ok "mutant G killed: without the shape discriminator the O6 steady state becomes AMBIGUOUS" + else + bad "MUTANT G SURVIVED: shape discriminator removed yet still $(state_of "$OUT") — the control is decorative" + fi +else + bad "mutant G was not applied — the sed pattern no longer matches the applier" +fi + +# ---- MUTANT H: neuter the canon-shape guard's EMPTY-CONTEXTS clause. --------- +# Flipping != to = inverts exactly that one clause: a typed-context body now +# passes the guard, and the canon body would not. Only the typed case is run +# under the mutant, which is the point -- it must go from REFUSED to written. +MUTH="$WORK/mutant-h.sh" +cat > "$WORK/mut-h.sed" <<'SEDH' +s#|length' "$GATES_ONLY_FILE")" != '0'#|length' "$GATES_ONLY_FILE")" = '0'# +SEDH +sed -f "$WORK/mut-h.sed" "$APPLIER" > "$MUTH" +chmod +x "$MUTH" +if ! cmp -s "$MUTH" "$APPLIER" && bash -n "$MUTH" 2>/dev/null; then + reset_fix + R=acme/typed-body + fix_repo "$R" + mkfix "repos/$R/rulesets" '[]' + OUT=$(MUTANT="$MUTH" run_applier "$R" --apply --create-gates --gates-only-file "$GO_TYPED") + if [ "$(state_of "$OUT")" = "REFUSED" ]; then + bad "MUTANT H SURVIVED: canon-shape guard neutered yet a typed-context body was still REFUSED — the guard is not what refuses it" + else + ok "mutant H killed: without the empty-contexts clause a typed-context body reaches $(state_of "$OUT") and POSTs $(wc -l < "$FIX/POSTS.log") time(s)" + fi + [ -s "$FIX/POSTS.log" ] && ok "mutant H wrote to the server from a non-canon body — the guard is load-bearing" \ + || bad "mutant H: expected a POST from the neutered guard" +else + bad "mutant H was not applied — the sed pattern no longer matches the applier" +fi + +# ---- MUTANT I: neuter the empty-bypass refusal (-gt 0 becomes -ge 0). -------- +# A zero-bypass branch ruleset is the shape of the 2026-09-11 tag outage. If +# this control is decorative, --no-integration-bypass becomes a way to deadlock +# any repo whose canon bypass list happens to be Integrations only. +MUTI="$WORK/mutant-i.sh" +cat > "$WORK/mut-i.sed" <<'SEDI' +s#create.json")" -gt 0 ]#create.json")" -ge 0 ]# +SEDI +sed -f "$WORK/mut-i.sed" "$APPLIER" > "$MUTI" +chmod +x "$MUTI" +if ! cmp -s "$MUTI" "$APPLIER" && bash -n "$MUTI" 2>/dev/null; then + reset_fix + R=acme/would-empty-bypass + fix_repo "$R" + mkfix "repos/$R/rulesets" '[]' + OUT=$(MUTANT="$MUTI" run_applier "$R" --apply --create-gates --no-integration-bypass --gates-only-file "$GO_INTONLY") + if [ "$(state_of "$OUT")" = "REFUSED" ]; then + bad "MUTANT I SURVIVED: empty-bypass refusal neutered yet still REFUSED — the control is decorative" + else + ok "mutant I killed: without the refusal it reaches $(state_of "$OUT")" + fi + if [ -s "$FIX/POSTS.log" ] && [ "$(jq '[.bypass_actors[]?]|length' "$FIX/LAST_POST.json")" = 0 ]; then + ok "mutant I POSTED a ZERO-BYPASS branch ruleset — the 2026-09-11 outage shape, reproduced" + else + bad "mutant I: expected a POST carrying an empty bypass_actors list" + fi +else + bad "mutant I was not applied — the sed pattern no longer matches the applier" +fi + echo echo "passed=$pass failed=$fail" [ "$fail" -eq 0 ]