Repository navigation
Repoint the dead required context: the repair as a script, not a command to type #1337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| #!/usr/bin/env bash | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # fix-required-context-name.sh — repoint ONE required status check at the call | ||
| # site that actually publishes it on a pull request. | ||
| # | ||
| # WHY THIS EXISTS | ||
| # --------------- | ||
| # A required status check is matched by NAME. For a job that calls a reusable | ||
| # workflow, that name is `<caller job name> / <callee job name>`. Two workflows | ||
| # that run the same reusable therefore publish two different contexts, and a | ||
| # ruleset can require the one that only runs after merge. | ||
| # | ||
| # That is this repository's position today: | ||
| # | ||
| # scan / Hypatia Neurosymbolic Analysis hypatia-scan.yml | ||
| # ↑ named by ruleset 23787415 push/schedule only | ||
| # Hypatia verdict grid / Hypatia Neurosymbolic Analysis fork-governance.yml | ||
| # ↑ published on every pull_request same scanner | ||
| # | ||
| # The result is not a red pull request, it is an UNDECIDABLE one: #1336 reached | ||
| # 65 passing checks, a cleared reviewDecision and mergeable=MERGEABLE while | ||
| # mergeStateStatus stayed BLOCKED, because the required check can never appear. | ||
| # No rebase, re-run, or code change can satisfy it — the only route to `main` | ||
| # is the ruleset's `always` bypass, which discards the other nineteen required | ||
| # checks in the same action. | ||
| # | ||
| # `scripts/apply-branch-gates.sh` cannot fix this: it derives contexts from | ||
| # checks that actually run and then has to choose a ruleset, and it refuses | ||
| # because six active branch rulesets are additive and none is uniquely | ||
| # checks-only (correct — guessing which one to rewrite is how protection gets | ||
| # silently dropped). This script instead touches exactly one named context in | ||
| # exactly one named ruleset, and refuses if the before-state is not what it | ||
| # expects. | ||
| # | ||
| # NOT a rename to make a gate pass. The two names are the same scanner; the | ||
| # required one simply names a call site that cannot run while the pull request | ||
| # is open. The deep scan keeps its weekly `main` run. | ||
| # | ||
| # Requires `administration: write`. Installation tokens report repo admin=true | ||
| # and still get 403 here; run it with an admin-scoped user token. | ||
| # | ||
| # Usage: | ||
| # scripts/fix-required-context-name.sh # report what would change | ||
| # scripts/fix-required-context-name.sh --apply # write it | ||
| # REPO=owner/name RULESET=123 scripts/fix-required-context-name.sh | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| REPO="${REPO:-hyperpolymath/standards}" | ||
| RULESET="${RULESET:-23787415}" | ||
| OLD_CONTEXT="${OLD_CONTEXT:-scan / Hypatia Neurosymbolic Analysis}" | ||
| NEW_CONTEXT="${NEW_CONTEXT:-Hypatia verdict grid / Hypatia Neurosymbolic Analysis}" | ||
|
|
||
| # The binary is injectable so scripts/tests/fix-required-context-name-test.sh | ||
| # can drive every refusal branch offline. Nothing else about the call changes. | ||
| GH="${GH:-gh}" | ||
|
|
||
| APPLY=0 | ||
| case "${1:-}" in | ||
| --apply) APPLY=1 ;; | ||
| -h|--help) sed -n '2,45p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; | ||
| "") ;; | ||
| *) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;; | ||
| esac | ||
|
|
||
| command -v "$GH" >/dev/null || { echo "'$GH' is required" >&2; exit 2; } | ||
| command -v jq >/dev/null || { echo "jq is required" >&2; exit 2; } | ||
|
|
||
| tmp="$(mktemp -d)" | ||
| trap 'rm -rf "$tmp"' EXIT | ||
|
|
||
| echo "== $REPO ruleset $RULESET" | ||
|
|
||
| if ! "$GH" api "repos/$REPO/rulesets/$RULESET" >"$tmp/before.json" 2>"$tmp/err"; then | ||
| echo "FATAL: cannot read the ruleset: $(head -1 "$tmp/err")" >&2 | ||
| echo " (needs read access to the repository rules; a 403 here means the" >&2 | ||
| echo " token cannot see protection at all, not that nothing is required)" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| enforcement="$(jq -r '.enforcement' "$tmp/before.json")" | ||
| n_rules="$(jq -r '.rules | length' "$tmp/before.json")" | ||
| n_ctx="$(jq -r '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]?] | length' "$tmp/before.json")" | ||
| has_old="$(jq -r --arg c "$OLD_CONTEXT" '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]? | select(.context==$c)] | length' "$tmp/before.json")" | ||
| has_new="$(jq -r --arg c "$NEW_CONTEXT" '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]? | select(.context==$c)] | length' "$tmp/before.json")" | ||
|
|
||
| printf ' name %s\n' "$(jq -r '.name' "$tmp/before.json")" | ||
| printf ' enforcement %s\n' "$enforcement" | ||
| printf ' rules %s (%s required context(s))\n' "$n_rules" "$n_ctx" | ||
|
|
||
| if [ "$enforcement" != "active" ]; then | ||
|
Check failure on line 93 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: ruleset is '$enforcement', not 'active' — editing it would change" >&2 | ||
| echo " nothing while looking like it did." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Refuse rather than guess. Each of these means the world moved and a scripted | ||
| # single-field edit is no longer the right instrument. | ||
| if [ "$has_new" -gt 0 ] && [ "$has_old" -eq 0 ]; then | ||
|
Check failure on line 101 in scripts/fix-required-context-name.sh
|
||
| echo "OK: already repaired — '$NEW_CONTEXT' is required, '$OLD_CONTEXT' is not." | ||
| exit 0 | ||
| fi | ||
| if [ "$has_old" -eq 0 ]; then | ||
|
Check failure on line 105 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: '$OLD_CONTEXT' is not a required context in this ruleset." >&2 | ||
| echo " Nothing to repoint; refusing to invent a change." >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$has_new" -gt 0 ]; then | ||
|
Check failure on line 110 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: both names are required. Remove '$OLD_CONTEXT' by hand:" >&2 | ||
| echo " one of them can never be produced on a pull request." >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$has_old" -gt 1 ]; then | ||
|
Check failure on line 115 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: '$OLD_CONTEXT' appears $has_old times; this script replaces one." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| jq --arg old "$OLD_CONTEXT" --arg new "$NEW_CONTEXT" '{ | ||
| name, target, enforcement, bypass_actors, conditions, | ||
| rules: [.rules[] | if .type=="required_status_checks" then | ||
| {type, parameters: (.parameters | .required_status_checks |= | ||
| map(if .context==$old then .context=$new else . end))} | ||
| else {type, parameters} end] | ||
| }' "$tmp/before.json" >"$tmp/after.json" | ||
|
|
||
| # The whole safety property: exactly one string changed, the rule count and the | ||
| # context count are identical, and nothing else in the ruleset moved. | ||
| n_after="$(jq -r '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]?] | length' "$tmp/after.json")" | ||
| n_rules_after="$(jq -r '.rules | length' "$tmp/after.json")" | ||
| if [ "$n_after" != "$n_ctx" ] || [ "$n_rules_after" != "$n_rules" ]; then | ||
|
Check failure on line 132 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: payload changed the shape of the ruleset ($n_rules→$n_rules_after rules," >&2 | ||
| echo " $n_ctx→$n_after contexts). Refusing to send." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo | ||
| echo "== context diff (whole ruleset; anything else changing is a bug)" | ||
| diff <(jq -r '.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context' "$tmp/before.json") \ | ||
| <(jq -r '.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context' "$tmp/after.json") \ | ||
| || true | ||
|
|
||
| if [ "$APPLY" -eq 0 ]; then | ||
|
Check failure on line 144 in scripts/fix-required-context-name.sh
|
||
| echo | ||
| echo "REPORT-ONLY. Re-run with --apply to write this to $REPO." | ||
| exit 0 | ||
| fi | ||
|
|
||
| echo | ||
| echo "== applying" | ||
| if ! "$GH" api -X PUT "repos/$REPO/rulesets/$RULESET" --input "$tmp/after.json" >"$tmp/resp.json" 2>"$tmp/err"; then | ||
| echo "FATAL: the API refused the write:" >&2 | ||
| sed 's/^/ /' "$tmp/err" >&2 | ||
| echo " 'Resource not accessible by integration' means the token is an" >&2 | ||
| echo " installation token: it may report admin=true and still lack" >&2 | ||
| echo " administration:write. Use an admin user token." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Verify against the server, not against the payload we sent. | ||
| "$GH" api "repos/$REPO/rulesets/$RULESET" >"$tmp/verify.json" | ||
| v_new="$(jq -r --arg c "$NEW_CONTEXT" '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]? | select(.context==$c)] | length' "$tmp/verify.json")" | ||
| v_old="$(jq -r --arg c "$OLD_CONTEXT" '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]? | select(.context==$c)] | length' "$tmp/verify.json")" | ||
|
Check warning on line 164 in scripts/fix-required-context-name.sh
|
||
| v_cnt="$(jq -r '[.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[]?] | length' "$tmp/verify.json")" | ||
|
|
||
| if [ "$v_new" -ne 1 ] || [ "$v_old" -ne 0 ] || [ "$v_cnt" -ne "$n_ctx" ]; then | ||
|
Check failure on line 167 in scripts/fix-required-context-name.sh
|
||
| echo "FATAL: read-back does not match what was intended." >&2 | ||
| echo " new=$v_new old=$v_old contexts=$v_cnt (expected 1/0/$n_ctx)" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "OK: $NEW_CONTEXT is now required ($v_cnt contexts, unchanged);" | ||
| echo " $OLD_CONTEXT is no longer required." | ||
| echo | ||
| echo "Next: the blocked pull request should become decidable on the next rule" | ||
| echo "evaluation. Re-check with: gh pr view <n> --json mergeStateStatus" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| #!/usr/bin/env bash | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # Offline tests for scripts/fix-required-context-name.sh. | ||
| # | ||
| # The script edits branch protection, so the branches that matter are the | ||
| # refusals: every one of them exists to stop a well-meaning run from dropping | ||
| # nineteen required checks while appearing to fix one. A fake `gh` on PATH | ||
| # (via $GH) serves fixtures and records whether a write was attempted, so no | ||
| # test touches a real ruleset. | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| script="$here/../fix-required-context-name.sh" | ||
| pass=0; fail=0 | ||
|
|
||
| work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT | ||
|
|
||
| ctx() { jq -c '.' ; } | ||
|
Check warning on line 21 in scripts/tests/fix-required-context-name-test.sh
|
||
|
|
||
| # A faithful slice of ruleset 23787415: one checks rule, 20 contexts, five rules. | ||
| base() { | ||
|
Check warning on line 24 in scripts/tests/fix-required-context-name-test.sh
|
||
| jq -n --arg old "$1" --arg new "$2" --argjson both "$3" '$ctxs | | ||
|
Check warning on line 25 in scripts/tests/fix-required-context-name-test.sh
|
||
| [$ctxs[] | {context: ., integration_id: 15368}] as $list' 2>/dev/null | ||
| } | ||
|
|
||
| mk_ruleset() { # $1=old context present (1/0) $2=new context present (1/0) | ||
|
Check warning on line 29 in scripts/tests/fix-required-context-name-test.sh
|
||
| # Pure jq: inline Python is banned in this repository | ||
| # (scripts/tests/check-inline-python-test.sh), and a fixture generator is not | ||
| # a good enough reason to open the door. | ||
| jq -n --argjson o "$1" --argjson n "$2" ' | ||
|
Check warning on line 33 in scripts/tests/fix-required-context-name-test.sh
|
||
| (["CodeQL", "SPARK Theatre Gate"] | ||
| + [range(0;17) | "governance / filler \(.)"] | ||
| + (if $o == 1 then ["scan / Hypatia Neurosymbolic Analysis"] else [] end) | ||
| + (if $n == 1 then ["Hypatia verdict grid / Hypatia Neurosymbolic Analysis"] else [] end)) as $ctx | ||
| | {name: "main gate: append-only + required checks + signatures + scanning", | ||
| target: "branch", enforcement: "active", | ||
| bypass_actors: [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always"}], | ||
| conditions: {ref_name: {exclude: [], include: ["~DEFAULT_BRANCH"]}}, | ||
| rules: [ | ||
| {type: "deletion"}, | ||
| {type: "non_fast_forward"}, | ||
| {type: "required_status_checks", parameters: { | ||
| strict_required_status_checks_policy: false, | ||
| do_not_enforce_on_create: false, | ||
| required_status_checks: [$ctx[] | {context: ., integration_id: 15368}]}}, | ||
| {type: "pull_request", parameters: {required_approving_review_count: 0}}, | ||
| {type: "required_signatures"} | ||
| ]}' | ||
| } | ||
|
|
||
| # Fake `gh`: `api repos/R/rulesets/N` serves $FIXTURE, unless a write has | ||
| # happened, in which case it serves $WRITTEN. `api -X PUT ... --input FILE` | ||
| # records the payload and flips the switch. $REFUSE=1 makes the write 403. | ||
| install_gh() { | ||
|
Check warning on line 57 in scripts/tests/fix-required-context-name-test.sh
|
||
| cat >"$work/gh" <<'SH' | ||
| #!/usr/bin/env bash | ||
| set -uo pipefail | ||
| method=GET | ||
| args=() | ||
| while [ $# -gt 0 ]; do | ||
| case "$1" in | ||
| -X) method="$2"; shift 2 ;; | ||
| --input) payload="$2"; shift 2 ;; | ||
| *) args+=("$1"); shift ;; | ||
| esac | ||
| done | ||
| if [ "$method" = PUT ]; then | ||
| cp "${payload:?}" "$PUT_CAPTURE" | ||
| if [ "${REFUSE:-0}" = 1 ]; then | ||
| echo '{"message":"Resource not accessible by integration","status":"403"}' >&2 | ||
| exit 1 | ||
| fi | ||
| cp "$PUT_CAPTURE" "$WRITTEN" | ||
| fi | ||
| if [ -s "${WRITTEN:-/dev/null}" ]; then cat "$WRITTEN"; else cat "$FIXTURE"; fi | ||
| SH | ||
| chmod +x "$work/gh" | ||
| } | ||
|
|
||
| # Sets $out and $rc in the CALLER. Deliberately not `out="$(run ...)"`: a | ||
| # command substitution runs in a subshell, so the exit status captured inside | ||
| # would never reach the assertion. | ||
| out=""; rc=0 | ||
| run() { # fixture-file [arg] | ||
|
Check warning on line 87 in scripts/tests/fix-required-context-name-test.sh
|
||
| : >"$work/written" | ||
| FIXTURE="$1" WRITTEN="$work/written" PUT_CAPTURE="$work/payload.json" \ | ||
| REFUSE="${REFUSE:-0}" GH="$work/gh" \ | ||
| bash "$script" "${2:-}" >"$work/out" 2>&1 | ||
| rc=$? | ||
| out="$(cat "$work/out")" | ||
| } | ||
|
|
||
| check() { local label="$1" want="$2" got="$3" | ||
|
Check warning on line 96 in scripts/tests/fix-required-context-name-test.sh
|
||
| if [ "$got" = "$want" ]; then pass=$((pass+1)); echo "PASS $label" | ||
|
Check failure on line 97 in scripts/tests/fix-required-context-name-test.sh
|
||
| else fail=$((fail+1)); echo "FAIL $label — want exit $want, got $got"; fi; } | ||
|
|
||
| check_says() { local label="$1" want="$2" out="$3" | ||
|
Check warning on line 100 in scripts/tests/fix-required-context-name-test.sh
|
||
| if printf '%s' "$out" | grep -qF "$want"; then pass=$((pass+1)); echo "PASS $label" | ||
| else fail=$((fail+1)); echo "FAIL $label — output lacks: $want"; printf '%s\n' "$out" | tail -3; fi; } | ||
|
|
||
| install_gh | ||
|
|
||
| # 1. report-only shows the single-line diff and writes nothing | ||
| mk_ruleset 1 0 >"$work/old-only.json" | ||
| run "$work/old-only.json"; check "report-only exits 0" 0 "$rc" | ||
| check_says "report-only names the target context" "Hypatia verdict grid / Hypatia Neurosymbolic Analysis" "$out" | ||
| check_says "report-only says it did not write" "REPORT-ONLY" "$out" | ||
| if [ -s "$work/written" ]; then fail=$((fail+1)); echo "FAIL report-only wrote to the ruleset" | ||
|
Check failure on line 111 in scripts/tests/fix-required-context-name-test.sh
|
||
| else pass=$((pass+1)); echo "PASS report-only wrote nothing"; fi | ||
|
|
||
| # 2. --apply writes, and the read-back is what is verified | ||
| run "$work/old-only.json" --apply; check "apply exits 0" 0 "$rc" | ||
| check_says "apply confirms the new context" "is now required" "$out" | ||
| n_written=$(jq -r '[.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[]?]|length' "$work/written" 2>/dev/null) | ||
| check "apply preserves the context count (20)" 20 "$n_written" | ||
| n_old=$(jq -r '[.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[]?|select(.context=="scan / Hypatia Neurosymbolic Analysis")]|length' "$work/written" 2>/dev/null) | ||
| check "apply removed the dead context" 0 "$n_old" | ||
| n_rules=$(jq -r '.rules|length' "$work/written" 2>/dev/null) | ||
| check "apply preserves the rule count (5)" 5 "$n_rules" | ||
| n_bypass=$(jq -r '.bypass_actors|length' "$work/written" 2>/dev/null) | ||
| check "apply preserves the bypass actors" 1 "$n_bypass" | ||
|
|
||
| # 3. already repaired: no-op success, and no write | ||
| mk_ruleset 0 1 >"$work/new-only.json" | ||
| run "$work/new-only.json" --apply; check "already-repaired exits 0" 0 "$rc" | ||
| check_says "already-repaired says so" "already repaired" "$out" | ||
| if [ -s "$work/written" ]; then fail=$((fail+1)); echo "FAIL already-repaired still wrote" | ||
|
Check failure on line 130 in scripts/tests/fix-required-context-name-test.sh
|
||
| else pass=$((pass+1)); echo "PASS already-repaired wrote nothing"; fi | ||
|
|
||
| # 4. neither name present: refuse rather than invent a change | ||
| mk_ruleset 0 0 >"$work/neither.json" | ||
| run "$work/neither.json" --apply; check "neither-present exits 1" 1 "$rc" | ||
| check_says "neither-present refuses to invent" "refusing to invent" "$out" | ||
|
|
||
| # 5. both present: refuse, this needs a human | ||
| mk_ruleset 1 1 >"$work/both.json" | ||
| run "$work/both.json" --apply; check "both-present exits 1" 1 "$rc" | ||
| check_says "both-present asks for a manual decision" "Remove" "$out" | ||
|
|
||
| # 6. inactive ruleset: editing it would change nothing while looking like it did | ||
| mk_ruleset 1 0 | jq '.enforcement="evaluate"' >"$work/inactive.json" | ||
| run "$work/inactive.json" --apply; check "inactive exits 1" 1 "$rc" | ||
| check_says "inactive is refused" "not 'active'" "$out" | ||
|
|
||
| # 7. the API refuses the write (installation token): say what it means | ||
| REFUSE=1; run "$work/old-only.json" --apply; check "403 exits 1" 1 "$rc" | ||
| REFUSE=0 | ||
| check_says "403 explains the installation-token trap" "installation token" "$out" | ||
|
|
||
| # 8. unknown argument is not silently ignored | ||
| run "$work/old-only.json" --force; check "unknown arg exits 2" 2 "$rc" | ||
|
|
||
| echo | ||
| echo "self-test: $pass passed, $fail failed" | ||
| [ "$fail" -eq 0 ] | ||
|
Check failure on line 158 in scripts/tests/fix-required-context-name-test.sh
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the read-back request fails after a successful PUT, such as during a network interruption, the script still reports success and exits zero without verifying the protection settings. With
set -uo pipefailbut no explicit status check here, an empty response leaves all three verification variables empty; the subsequent numeric comparisons produceinteger expression expectederrors and fall through to theOKmessage. This is reproducible with the injectable$GHreturning exit 1 and no stdout on the final GET. Explicitly reject a failed request and an invalid or empty verification response before comparing the counts.Useful? React with 👍 / 👎.