From 67cddd229c76b81b9af6ee31f0817d5e35350068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Guilhermo=20de=20Barros=20Val=C3=A9rio?= Date: Mon, 12 Oct 2026 21:34:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(ruleset-sync):=20an=20@consumers=20ext?= =?UTF-8?q?ra=20for=20the=20merge=20bot=20(MIP-0086=20=C2=A75.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "@consumers" key in bypass-extras.json merges into the expected ruleset of every repo in .github/consumers.txt (--consumers FILE) before that repo's own extras. marola's extra moves there, so marola-merge-bot gets a pull_request-only bypass on every consumer and nowhere else. Tested: ruleset-sync.sh --self-test red then green; devkit quality gate Co-Authored-By: Claude --- .github/rulesets/bypass-extras.json | 4 +-- scripts/ruleset-sync.sh | 51 +++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/rulesets/bypass-extras.json b/.github/rulesets/bypass-extras.json index d4951bb..3d99f5d 100644 --- a/.github/rulesets/bypass-extras.json +++ b/.github/rulesets/bypass-extras.json @@ -1,10 +1,10 @@ { - "marola-dev/marola": [ + "@consumers": [ { "actor_id": 5257042, "actor_type": "Integration", "bypass_mode": "pull_request", - "reason": "The marola-pointer-sync App merges pointer-sync's PR (marola-dev/marola#739)." + "reason": "marola-merge-bot merges sync, Dependabot and devkit-bump PRs through a PR, never a push (MIP-0086 §5.7)." } ] } diff --git a/scripts/ruleset-sync.sh b/scripts/ruleset-sync.sh index d3b20cb..7f8ccad 100755 --- a/scripts/ruleset-sync.sh +++ b/scripts/ruleset-sync.sh @@ -5,11 +5,13 @@ # # ruleset-sync check [owner/repo ...] # default: the caller's repo (gh repo view) # ruleset-sync apply # create when missing, update in place when drifted -# ruleset-sync [--manifest FILE] [--extras FILE] [--dry-run] [--all-org ORG [--include-forks]] check|apply ... +# ruleset-sync [--manifest FILE] [--extras FILE] [--consumers FILE] [--dry-run] [--all-org ORG [--include-forks]] check|apply ... # ruleset-sync --self-test # # A repo keyed in .github/rulesets/bypass-extras.json expects the manifest's bypass list with its # extras merged by (actor_type, actor_id): a match overrides bypass_mode, anything else is added. +# The key "@consumers" applies to every repo in .github/consumers.txt (--consumers FILE), before +# that repo's own extras: the merge bot's bypass on every devkit consumer (MIP-0086 §5.7). # check prints ok/missing/drifted per repo (stderr) and a unified diff for a drifted one # (stdout); it exits non-zero if any repo is missing or drifted. apply prints what it did # (stderr) and the gh call it ran or, under --dry-run, would have run (stdout) instead. Never @@ -19,8 +21,9 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" manifest_default="$root/.github/rulesets/main-rule.json" extras_default="$root/.github/rulesets/bypass-extras.json" +consumers_default="$root/.github/consumers.txt" -usage() { sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } # normalize_ruleset -> stdout: the manifest's own shape (pretty, sorted keys), so a # live `gh api .../rulesets/` response compares byte-for-byte against the manifest. The @@ -34,11 +37,18 @@ normalize_ruleset() { ' } +# extras_of -> stdout: 's extras as one JSON list, the "@consumers" ones first. +extras_of() { + local c=false + if awk -v r="$1" '!/^[[:space:]]*(#|$)/ && $1 == r { f = 1 } END { exit !f }' "$consumers"; then c=true; fi + jq -c --arg r "$1" --argjson c "$c" '(if $c then .["@consumers"] // [] else [] end) + (.[$r] // [])' "$extras" +} + # expected_ruleset -> stdout: the manifest with 's extras merged in, reasons dropped # (GitHub doesn't store them, so left in they would be drift on every check). expected_ruleset() { - jq --arg r "$1" --slurpfile x "$extras" ' - .bypass_actors = reduce (($x[0][$r] // [])[] | del(.reason)) as $e (.bypass_actors // []; + jq --argjson x "$(extras_of "$1")" ' + .bypass_actors = reduce ($x[] | del(.reason)) as $e (.bypass_actors // []; if any(.[]; .actor_type == $e.actor_type and .actor_id == $e.actor_id) then map(if .actor_type == $e.actor_type and .actor_id == $e.actor_id then .bypass_mode = $e.bypass_mode else . end) else . + [$e] end) @@ -48,7 +58,7 @@ expected_ruleset() { # want_for : sets $want (the file apply sends), $want_norm and $want_label for . A repo # with no extras sends the manifest itself, byte for byte. want_for() { - if jq -e --arg r "$1" '(.[$r] // []) | length > 0' "$extras" >/dev/null; then + if [ "$(extras_of "$1" | jq length)" -gt 0 ]; then want="$work/${1//\//_}.json" expected_ruleset "$1" > "$want" want_norm="$(normalize_ruleset < "$want")" @@ -204,6 +214,9 @@ JSON local extras="$tmp/bypass-extras.json" cat > "$extras" <<'JSON' { + "@consumers": [ + { "actor_id": 8, "actor_type": "Integration", "bypass_mode": "pull_request", "reason": "synthetic: the merge bot" } + ], "acme/extras-repo": [ { "actor_id": null, "actor_type": "OrganizationAdmin", "bypass_mode": "pull_request", "reason": "synthetic: overrides the base actor's mode" }, { "actor_id": 7, "actor_type": "Integration", "bypass_mode": "pull_request", "reason": "synthetic: an actor the base lacks" } @@ -211,8 +224,10 @@ JSON } JSON + printf '# c\nacme/consumer-repo\n' > "$tmp/consumers.txt" + local script="${BASH_SOURCE[0]}" - run_script() { PATH="$tmp/bin:$PATH" STUB_DIR="$tmp/stub" STUB_LOG="$tmp/stub/log" bash "$script" --manifest "$manifest" --extras "$extras" "$@" 2>&1; } + run_script() { PATH="$tmp/bin:$PATH" STUB_DIR="$tmp/stub" STUB_LOG="$tmp/stub/log" bash "$script" --manifest "$manifest" --extras "$extras" --consumers "$tmp/consumers.txt" "$@" 2>&1; } echo "-- an identical ruleset, alongside an unrelated one on the same repo --" jq '.id = 1' "$manifest" > "$tmp/stub/detail.acme_ok-repo.1.json" @@ -351,6 +366,27 @@ JSON check "extras_entry_needs_reason ($f): makes no call" "$(cat "$tmp/stub/log")" "" done + local merge='{"actor_id":8,"actor_type":"Integration","bypass_mode":"pull_request"}' + local base_admin='{"actor_id":null,"actor_type":"OrganizationAdmin","bypass_mode":"always"}' + jq -n '[{id:6,name:"main-rule"}]' > "$tmp/stub/list.acme_consumer-repo.json" + live_consumer() { jq --argjson b "$1" '.id = 6 | .bypass_actors = $b' "$manifest" > "$tmp/stub/detail.acme_consumer-repo.6.json"; } + + echo "-- consumer_gets_the_consumers_extra --" + live_consumer "[$base_admin,$merge]" + out="$(run_script check acme/consumer-repo)" && rc=0 || rc=$? + check "consumer_gets_the_consumers_extra: exit 0" "$rc" "0" + live_consumer "[$base_admin]" + out="$(run_script check acme/consumer-repo)" && rc=0 || rc=$? + check "consumer_drifted_without_it: exit 1" "$rc" "1" + has "consumer_drifted_without_it: diff names it" "$out" '- "actor_id": 8' + : > "$tmp/stub/log" + out="$(run_script apply acme/consumer-repo)" && rc=0 || rc=$? + has "apply_adds_it_to_a_consumer" "$(cat "$tmp/stub/log")" "$merge" + + echo "-- non_consumer_never_gets_it --" + out="$(run_script check acme/ok-repo)" && rc=0 || rc=$? + check "non_consumer_never_gets_it: exit 0" "$rc" "0" + if [ "$fails" -eq 0 ]; then echo "ruleset-sync self-test: ok"; return 0; fi echo "ruleset-sync self-test: $fails failure(s)" >&2; return 1 } @@ -359,6 +395,7 @@ JSON manifest="$manifest_default" extras="$extras_default" +consumers="$consumers_default" all_org="" include_forks=false cmd="" @@ -368,6 +405,7 @@ while [ $# -gt 0 ]; do case "$1" in --manifest) manifest="${2:?--manifest needs a file}"; shift 2 ;; --extras) extras="${2:?--extras needs a file}"; shift 2 ;; + --consumers) consumers="${2:?--consumers needs a file}"; shift 2 ;; --dry-run) dry=1; shift ;; --all-org) all_org="${2:?--all-org needs an org}"; shift 2 ;; --include-forks) include_forks=true; shift ;; @@ -387,6 +425,7 @@ ruleset_name="$(jq -r .name "$manifest")" manifest_norm="$(normalize_ruleset < "$manifest")" [ -f "$extras" ] || { echo "ruleset-sync: extras not found: $extras" >&2; exit 1; } +[ -f "$consumers" ] || { echo "ruleset-sync: consumers not found: $consumers" >&2; exit 1; } # A reason is what makes an extra reviewable: an exception to the shared bypass list nobody can # explain is the one this file exists to stop. bad_extras="$(jq -r ' From 1eb96f37aebacab2adfc216d898645ababf92d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Guilhermo=20de=20Barros=20Val=C3=A9rio?= Date: Mon, 12 Oct 2026 21:40:11 +0200 Subject: [PATCH 2/3] test(ruleset-sync): a consumer's own extra outranks @consumers; document @consumers Tested: ruleset-sync.sh --self-test (new case red with extras_of's order reversed, green as written); devkit quality gate Co-Authored-By: Claude --- docs/4-reference_tools.md | 2 +- scripts/ruleset-sync.sh | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/4-reference_tools.md b/docs/4-reference_tools.md index 087e938..fd17bf5 100644 --- a/docs/4-reference_tools.md +++ b/docs/4-reference_tools.md @@ -53,7 +53,7 @@ extension rather than a devkit tool. | `pr-label` | `pr-label.sh` | `just pr-label` | Apply the deterministic `area/*`, `layer/*`, `kind/*` taxonomy (`scripts/lib/pr_labels.sh`) to one PR | | `backfill-pr-labels` | `backfill-pr-labels.sh` | `just pr-labels-backfill` | The same for every merged or closed PR with no label; `--limit`, `--nlp` (compare with `pr-label-nlp`), `--nlp-apply-unscoped` (fill only an `area/unscoped` gap) | | `pr-label-nlp` | `pr_label_nlp.py` | (none) | A local TF-IDF classifier for `area/*`, kept to compare against the deterministic one, never applied on its own. `--pr N` or `--title`/`--body` | -| `ruleset-sync` | `ruleset-sync.sh` | `just rulesets-check`, `just rulesets-apply` | `check [owner/repo…]` diffs a repo's live branch ruleset against `.github/rulesets/main-rule.json`, plus that repo's bypass actors in `.github/rulesets/bypass-extras.json` (each with a reason); `apply ` creates or updates it; `--all-org ORG` covers an org | +| `ruleset-sync` | `ruleset-sync.sh` | `just rulesets-check`, `just rulesets-apply` | `check [owner/repo…]` diffs a repo's live branch ruleset against `.github/rulesets/main-rule.json`, plus that repo's bypass actors in `.github/rulesets/bypass-extras.json` (each with a reason; the `@consumers` key applies to every repo in `.github/consumers.txt`, `--consumers FILE` to override); `apply ` creates or updates it; `--all-org ORG` covers an org | | `agents-check` | `agents-check.sh` | `just agents-check` | Compare AGENTS.md's invariants block byte for byte with the pinned devkit's `agents/invariants.md` (`--block` or `MAROLA_INVARIANTS_BLOCK` to use another) | | `docs-lint` | `docs_lint.py` | (none) | MIP-0074 §7's stale-content check over `README.md` and `docs/**/*.md`: undefined or unmarked foreign recipes, another repo's paths, split-era wording, `docs/index.md`, links leaving the repo | | `wiring` | `wiring.py` | `just wiring` | MIP-0076 §5.1's four tables (artifact, dispatch, pin bump, deploy), parsed from the umbrella's and every submodule's workflows, pin files, scripts, justfiles, Dockerfiles, compose files and `build.sbt`, with each devkit workflow call's ref (each call is read at that ref: from a local devkit clone's tags, else fetched from GitHub, a tag cached under `~/.cache/marola-wiring`). `wiring [FILE]` prints the block or rewrites it between FILE's `wiring:start`/`wiring:end` markers; `--check [FILE]` fails on a stale block, a dispatch sent or listened for on one side only, or an artifact nobody reads that the umbrella's `wiring.allow` (`: ` per line) does not list; run it in an umbrella checkout with submodules | diff --git a/scripts/ruleset-sync.sh b/scripts/ruleset-sync.sh index 7f8ccad..ed512a8 100755 --- a/scripts/ruleset-sync.sh +++ b/scripts/ruleset-sync.sh @@ -383,6 +383,12 @@ JSON out="$(run_script apply acme/consumer-repo)" && rc=0 || rc=$? has "apply_adds_it_to_a_consumer" "$(cat "$tmp/stub/log")" "$merge" + echo "-- consumer_own_extra_outranks_consumers --" + jq '.["acme/consumer-repo"] = [{actor_id: 8, actor_type: "Integration", bypass_mode: "always", reason: "synthetic: the repo overrides the shared mode"}]' "$extras" > "$tmp/own-extras.json" + live_consumer "[$base_admin,${merge/pull_request/always}]" + out="$(run_script --extras "$tmp/own-extras.json" check acme/consumer-repo)" && rc=0 || rc=$? + check "consumer_own_extra_outranks_consumers: own mode matches" "$rc" "0" + echo "-- non_consumer_never_gets_it --" out="$(run_script check acme/ok-repo)" && rc=0 || rc=$? check "non_consumer_never_gets_it: exit 0" "$rc" "0" From 934bb1d33315a87091a49a654cf61f71311943d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Guilhermo=20de=20Barros=20Val=C3=A9rio?= Date: Mon, 12 Oct 2026 21:45:17 +0200 Subject: [PATCH 3/3] =?UTF-8?q?docs(changelog):=20ruleset-sync's=20@consum?= =?UTF-8?q?ers=20extra=20(MIP-0086=20=C2=A75.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #100 Tested: devkit quality gate Co-Authored-By: Claude --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76939f5..f7f0032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ marketplace `ref` at once. - `gemini-review.yml` has no `cost-trailer` input any more; a caller that passes it must remove it. - `gemini-review.yml` falls back to `gemini-3.6-flash`; Google deprecated `gemini-3.5-flash` and redirects it there (#106). An org or repo `GEMINI_MODEL` variable still wins. +- `ruleset-sync` reads an `@consumers` key in `bypass-extras.json` and applies it to every repo in + `.github/consumers.txt` (`--consumers FILE` overrides), before that repo's own extras: + `marola-merge-bot` (5257042) becomes a pull-request-only bypass actor on every consumer (MIP-0086 + §5.7, #100). `just rulesets-apply` over the consumers after this release also moves org and repo + admins from `always` to `pull_request` where v0.8.2's rule was never applied. ## v0.8.3 — 2026-10-10