Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/rulesets/bypass-extras.json
Original file line number Diff line number Diff line change
@@ -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)."
}
]
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/4-reference_tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo…>` 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 <owner/repo…>` 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` (`<artifact>: <reason>` per line) does not list; run it in an umbrella checkout with submodules |
Expand Down
57 changes: 51 additions & 6 deletions scripts/ruleset-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
#
# ruleset-sync check [owner/repo ...] # default: the caller's repo (gh repo view)
# ruleset-sync apply <owner/repo ...> # 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
Expand All @@ -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 <stdin JSON> -> stdout: the manifest's own shape (pretty, sorted keys), so a
# live `gh api .../rulesets/<id>` response compares byte-for-byte against the manifest. The
Expand All @@ -34,11 +37,18 @@ normalize_ruleset() {
'
}

# extras_of <repo> -> stdout: <repo>'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 <repo> -> stdout: the manifest with <repo>'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)
Expand All @@ -48,7 +58,7 @@ expected_ruleset() {
# want_for <repo>: sets $want (the file apply sends), $want_norm and $want_label for <repo>. 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")"
Expand Down Expand Up @@ -204,15 +214,20 @@ 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" }
]
}
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"
Expand Down Expand Up @@ -351,6 +366,33 @@ 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 "-- 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"

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
}
Expand All @@ -359,6 +401,7 @@ JSON

manifest="$manifest_default"
extras="$extras_default"
consumers="$consumers_default"
all_org=""
include_forks=false
cmd=""
Expand All @@ -368,6 +411,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 ;;
Expand All @@ -387,6 +431,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 '
Expand Down
Loading