Skip to content

Commit 1c90857

Browse files
feat(profile): protectionFloors, and label diffs that loosen protection (#82)
* feat(profile): protectionFloors, and label diffs that loosen protection Fixes #42. Claude-Session: https://claude.ai/code/session_01T7j4GUt15DJp7G9UK4tMNE * docs(profile): name the Floors heading so the cross-reference resolves Claude-Session: https://claude.ai/code/session_01T7j4GUt15DJp7G9UK4tMNE * fix(settings-diff): do not label implicit-default or call-less diffs as loosening Claude-Session: https://claude.ai/code/session_01T7j4GUt15DJp7G9UK4tMNE
1 parent 1a37a42 commit 1c90857

5 files changed

Lines changed: 241 additions & 24 deletions

File tree

‎plugins/core/references/profile-schema.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ repo, that is a request for a profile key that doesn't exist yet, not a reason t
269269
| `protection.requiredReviews.dismissStale` | bool | `dismiss_stale_reviews`. |
270270
| `protection.requiredReviews.countsBotApproval` | bool | **No GitHub counterpart** — see **What the profile does not govern**. |
271271
| `protection.enforceAdmins` | bool | `enforce_admins`. |
272+
| `protectionFloors` | array of strings, optional | Protection keys whose profile value is a **minimum**, not an exact value — see **Floors**. A sibling of `protection`, not inside it, so readers that predate it ignore it rather than reject it. |
272273
| `labels` | array of strings | Labels that must exist. Missing ones are a finding; **extra ones are not** — a repo's own labels are its business. |
273274
| `review` | object | Passed through to the repo config's `review` block by `bootstrap` (`bots`, `approvalThreshold`, `scoreSource`, `responderTier`, `impasseRounds`, `sameFileRoundCap` — the schema for them is in [`config-schema.md`](config-schema.md)). Not a GitHub setting. |
274275
| `requireIssueForDeferredWork` | bool | Passed through to `createPr.requireIssueForDeferredWork`. Not a GitHub setting. |
@@ -402,6 +403,49 @@ Three consequences worth stating, because each is a case where the obvious imple
402403
optional integer there and `null` is the response's spelling. A warning printed above a call that
403404
still loses the thing is a warning read after the paste.
404405

406+
### Floors
407+
408+
`defaults.protection.*` is otherwise an exact value, so a profile saying `enforceAdmins: false`
409+
would generate a PUT that switches `enforce_admins` **off** on a repo that has it on deliberately.
410+
For keys where stricter is always acceptable, the value is a floor instead:
411+
412+
```jsonc
413+
"defaults": {
414+
"protection": { "enforceAdmins": false, … },
415+
"protectionFloors": ["enforceAdmins"] // these values are minima, not exact values
416+
}
417+
```
418+
419+
Entries name profile-side protection keys, dotted for nested ones. For a listed key, a branch whose
420+
value is **stricter** is conformant — no finding, and the PUT carries the branch's existing value —
421+
while a **looser** one is still a finding. Which direction is stricter is a fixed table, not
422+
inferred from the value's type:
423+
424+
| Key | Stricter is |
425+
| --- | --- |
426+
| `requiredLinearHistory`, `enforceAdmins`, `strictRequiredChecks`, `requiredReviews.dismissStale` | `true` |
427+
| `allowForcePushes`, `allowDeletions` | `false` |
428+
| `requiredReviews.count` | the larger number |
429+
430+
Only those keys may be listed (`profile-resolve.sh --validate` rejects any other, including
431+
`requiredReviews.countsBotApproval`, which has no GitHub setting). Nothing is a floor unless listed:
432+
more required approvals in a one-maintainer org is a deadlock rather than a tightening, so a profile
433+
opts in per key. `protectionFloors` is fixed org-wide like the rest of `defaults`; it is additive and
434+
optional and does not bump `profileVersion`. `new-repo` has no existing branch to be stricter, so it
435+
applies the profile's value.
436+
437+
**Loosening is labelled.** Whether or not a key is a floor, any difference whose fix would make the
438+
branch *less* protected — `enforce_admins true → false` on a key that is not a floor, a required
439+
check the PUT would drop — is reported in its own `LOOSENING` section above the call:
440+
441+
```text
442+
LOOSENING — applying the call below would make the branch LESS protected here:
443+
LOOSENS — main enforce_admins true → false (profile value; not a floor)
444+
```
445+
446+
Without the label a weakening diff reads exactly like a tightening one, and the workflow is a human
447+
pasting the call. The report is still report-only.
448+
405449
The one widening nothing can avoid is a check the **profile adds** to a branch whose existing checks
406450
are app-pinned: there is no pin to carry across, so any app could satisfy it. That is one warning
407451
naming the added checks, and it is the only protection warning left.

‎plugins/core/scripts/profile-resolve.sh‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ KNOWN_VERSION=1
3838
# profile is fixed org-wide, and that is not a convention — it is this list.
3939
RESOLVABLE='["requiredChecks","coverage","commands","dependabot","mergeQueue","protection"]'
4040

41+
# The protection keys `defaults.protectionFloors` may name. Each has a defined strict
42+
# direction in settings-diff.sh; `requiredReviews.countsBotApproval` has no GitHub setting
43+
# behind it, so it cannot be a floor.
44+
FLOORABLE='["requiredLinearHistory","allowForcePushes","allowDeletions","enforceAdmins","strictRequiredChecks","requiredReviews.count","requiredReviews.dismissStale"]'
45+
4146
usage() {
4247
cat >&2 <<'USAGE'
4348
usage: profile-resolve.sh --profile FILE (--validate | --languages | --repo SLUG --language KEY)
@@ -80,7 +85,7 @@ jq -e 'type == "object"' "$profile" >/dev/null 2>&1 \
8085
# ── Shape ────────────────────────────────────────────────────────────────────
8186
# One jq pass, emitting one message per problem so a broken profile is fixed in one
8287
# round rather than one error at a time.
83-
errors="$(jq -r --argjson known "$KNOWN_VERSION" --argjson resolvable "$RESOLVABLE" '
88+
errors="$(jq -r --argjson known "$KNOWN_VERSION" --argjson resolvable "$RESOLVABLE" --argjson floorable "$FLOORABLE" '
8489
def is_str_array: type == "array" and (all(.[]; type == "string"));
8590
def is_nonempty_str_array: type == "array" and (all(.[]; type == "string" and length > 0 and (test("\\n") | not)));
8691
@@ -170,6 +175,17 @@ errors="$(jq -r --argjson known "$KNOWN_VERSION" --argjson resolvable "$RESOLVAB
170175
and (((.defaults.files.prTemplateSource | type) != "string") or (.defaults.files.prTemplateSource | length) == 0)
171176
then ["defaults.files.prTemplateSource must be a non-empty string"] else [] end )
172177
else [] end )
178+
# `protectionFloors` marks protection values as minima (see profile-schema.md). The
179+
# strict direction is a fixed table in settings-diff.sh, so a key outside it would be
180+
# silently ignored there — reject it here, where the author can still fix it.
181+
, ( if (.defaults | type) == "object" and (.defaults | has("protectionFloors"))
182+
then ( if ((.defaults.protectionFloors | type) != "array")
183+
then ["defaults.protectionFloors must be an array of protection key names"]
184+
else [ .defaults.protectionFloors[]
185+
| select(. as $k | $floorable | index([$k]) | not)
186+
| "defaults.protectionFloors names \(. | tojson), which is not a key that can be a floor — one of: "
187+
+ ($floorable | join(", ")) ] end )
188+
else [] end )
173189
, ( if (.languages | type) != "object" then ["languages must be an object"]
174190
elif (.languages | length) == 0 then ["languages is empty — nothing can resolve against this profile"]
175191
else [] end )

‎plugins/core/scripts/settings-diff.sh‎

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,50 @@ findings="$(jq -n \
149149
// ([$prot.required_status_checks.checks[]?.context])
150150
// []) end;
151151
152+
# ── protection keys the profile has an opinion on ──────────────────────────
153+
# `mode` is which direction is STRICTER: "true" (on is stricter), "false" (off is
154+
# stricter) or "max" (larger is stricter). It is a fixed table rather than a rule
155+
# inferred from the value type, because it is a statement about GitHub'"'"'s semantics: for
156+
# `allow_force_pushes` the strict value is false, and for a review count a larger number
157+
# is stricter in the API even though a bigger one can deadlock a small team.
158+
def prot_specs:
159+
[ { key: "required_linear_history", pkey: "requiredLinearHistory", mode: "true",
160+
want: $e.protection.requiredLinearHistory, got: $prot.required_linear_history.enabled }
161+
, { key: "allow_force_pushes", pkey: "allowForcePushes", mode: "false",
162+
want: $e.protection.allowForcePushes, got: $prot.allow_force_pushes.enabled }
163+
, { key: "allow_deletions", pkey: "allowDeletions", mode: "false",
164+
want: $e.protection.allowDeletions, got: $prot.allow_deletions.enabled }
165+
, { key: "enforce_admins", pkey: "enforceAdmins", mode: "true",
166+
want: $e.protection.enforceAdmins, got: $prot.enforce_admins.enabled }
167+
, { key: "required_status_checks.strict", pkey: "strictRequiredChecks", mode: "true",
168+
want: ($e.protection.strictRequiredChecks // false),
169+
got: ($prot.required_status_checks.strict // false),
170+
# The default `false` is the profile'"'"'s silence, and the PUT keeps what the branch has when
171+
# the profile is silent, so a stricter branch is not being loosened by anything.
172+
implicit: ($e.protection.strictRequiredChecks == null) }
173+
, { key: "required_approving_review_count", pkey: "requiredReviews.count", mode: "max",
174+
want: $e.protection.requiredReviews.count,
175+
got: ($prot.required_pull_request_reviews.required_approving_review_count // 0) }
176+
, { key: "dismiss_stale_reviews", pkey: "requiredReviews.dismissStale", mode: "true",
177+
want: $e.protection.requiredReviews.dismissStale,
178+
got: ($prot.required_pull_request_reviews.dismiss_stale_reviews // false) }
179+
] | map(select(.want != null));
180+
181+
# Is the branch'"'"'s value STRICTER than the profile'"'"'s? Applying the profile'"'"'s value to
182+
# such a branch would loosen it.
183+
def stricter:
184+
if .mode == "true" then (.got == true and .want == false)
185+
elif .mode == "false" then (.got == false and .want == true)
186+
else ((.got | type) == "number" and (.want | type) == "number" and .got > .want) end;
187+
188+
# `protectionFloors` names profile keys whose value is a minimum, not an exact value.
189+
def is_floor: . as $s | (($e.protectionFloors // []) | index([$s.pkey])) != null;
190+
191+
# The floors a stricter branch is HOLDING: no finding, and the PUT keeps the branch'"'"'s value.
192+
def held_floors:
193+
if prot_present | not then [] else [ prot_specs[] | select(is_floor and stricter) | .pkey ] end;
194+
def holds(pk): (held_floors | index([pk])) != null;
195+
152196
# Protections the profile has no opinion on are CARRIED THROUGH the replacement, not
153197
# dropped. The PUT replaces the whole object, so a body built from the profile alone
154198
# would silently switch off a safeguard the repo had — conversation resolution, a
@@ -196,13 +240,17 @@ findings="$(jq -n \
196240
197241
def profile_reviews:
198242
($e.protection.requiredReviews // {}) as $r
199-
| ( (if ($r | has("count")) then { required_approving_review_count: $r.count } else {} end)
200-
+ (if ($r | has("dismissStale")) then { dismiss_stale_reviews: $r.dismissStale } else {} end) );
243+
| ( (if ($r | has("count")) and (holds("requiredReviews.count") | not)
244+
then { required_approving_review_count: $r.count } else {} end)
245+
+ (if ($r | has("dismissStale")) and (holds("requiredReviews.dismissStale") | not)
246+
then { dismiss_stale_reviews: $r.dismissStale } else {} end) );
201247
202248
# profile value, else what the branch already had, else off. Written out rather than
203249
# reached with `//`, because the jq alternative operator treats `false` as empty and
204250
# would quietly promote every disabled setting to the next fallback.
205251
def resolve(prof; obs): if (prof) != null then (prof) elif (obs) != null then (obs) else false end;
252+
# A floor the branch is holding keeps the branch'"'"'s own value instead of the profile'"'"'s.
253+
def pick(pk; prof; obs): if holds(pk) then (obs) else resolve(prof; obs) end;
206254
def obs_enabled(k): if prot_present and (($prot[k] | type) == "object") then $prot[k].enabled else null end;
207255
208256
# App-pinned required checks round-trip too: the PUT accepts
@@ -234,27 +282,24 @@ findings="$(jq -n \
234282
msg: "\($branch) has no branch protection at all (\($prot.message)) — every profile rule is unmet",
235283
fix: "see the single PUT below" }]
236284
else
237-
( [ { key: "required_linear_history", want: $e.protection.requiredLinearHistory, got: $prot.required_linear_history.enabled }
238-
, { key: "allow_force_pushes", want: $e.protection.allowForcePushes, got: $prot.allow_force_pushes.enabled }
239-
, { key: "allow_deletions", want: $e.protection.allowDeletions, got: $prot.allow_deletions.enabled }
240-
, { key: "enforce_admins", want: $e.protection.enforceAdmins, got: $prot.enforce_admins.enabled }
241-
, { key: "required_status_checks.strict", want: ($e.protection.strictRequiredChecks // false),
242-
got: ($prot.required_status_checks.strict // false) }
243-
, { key: "required_approving_review_count", want: $e.protection.requiredReviews.count,
244-
got: ($prot.required_pull_request_reviews.required_approving_review_count // 0) }
245-
, { key: "dismiss_stale_reviews", want: $e.protection.requiredReviews.dismissStale,
246-
got: ($prot.required_pull_request_reviews.dismiss_stale_reviews // false) }
247-
]
248-
| map(select(.want != null))
249-
| map(select(.want != .got)
285+
( prot_specs
286+
# A listed floor that the branch meets or beats is conformant, not drift.
287+
| map(select(.want != .got and ((is_floor and stricter) | not))
250288
| { sev: "FAIL", section: "protection",
251-
msg: "\($branch) protection \(.key) is \(.got | tojson), profile wants \(.want | tojson)" }) )
289+
msg: "\($branch) protection \(.key) is \(.got | tojson), profile wants \(.want | tojson)" }
290+
# Not a floor, and the branch is stricter: the call below would weaken it.
291+
+ (if stricter and ((.implicit // false) | not)
292+
then { loosens: true,
293+
loosenMsg: "LOOSENS — \($branch) \(.key) \(.got | tojson) → \(.want | tojson) (profile value; not a floor)" }
294+
else {} end)) )
252295
+ ( (($e.requiredChecks // []) - (prot_contexts + ruleset_contexts))
253296
| map({ sev: "FAIL", section: "protection",
254297
msg: "\($branch) does not require the check \"\(.)\"" }) )
255298
+ ( ((prot_contexts) - ($e.requiredChecks // []))
256299
| map({ sev: "WARN", section: "protection",
257-
msg: "\($branch) requires the check \"\(.)\", which the profile does not name. The PUT below would REMOVE it, because it replaces the whole object — add it to the profile'"'"'s requiredChecks first if it should stay." }) )
300+
msg: "\($branch) requires the check \"\(.)\", which the profile does not name. The PUT below would REMOVE it, because it replaces the whole object — add it to the profile'"'"'s requiredChecks first if it should stay.",
301+
loosens: true,
302+
loosenMsg: "LOOSENS — \($branch) drops the required check \"\(.)\" (not in the profile'"'"'s requiredChecks)" }) )
258303
+ unpinned_addition_findings
259304
end;
260305
@@ -309,7 +354,7 @@ findings="$(jq -n \
309354
protectionKnown: (prot_present or prot_unprotected),
310355
protectionBody: ({
311356
required_status_checks:
312-
( { strict: resolve($e.protection.strictRequiredChecks;
357+
( { strict: pick("strictRequiredChecks"; $e.protection.strictRequiredChecks;
313358
(if prot_present then $prot.required_status_checks.strict else null end)) }
314359
# `contexts` is deprecated but still required by the PUT schema, so it is sent
315360
# whether or not `checks` is; `checks` adds the per-context app pin on top.
@@ -321,24 +366,37 @@ findings="$(jq -n \
321366
| ([obs_checks[] | select(.context == $c) | .app_id] | first) as $a
322367
| if $a == null then { context: $c } else { context: $c, app_id: $a } end ] }
323368
else {} end) ),
324-
enforce_admins: resolve($e.protection.enforceAdmins; obs_enabled("enforce_admins")),
369+
enforce_admins: pick("enforceAdmins"; $e.protection.enforceAdmins; obs_enabled("enforce_admins")),
325370
required_pull_request_reviews:
326371
((observed_reviews + profile_reviews) as $r | if ($r | length) == 0 then null else $r end),
327372
restrictions: preserved_restrictions,
328-
required_linear_history: resolve($e.protection.requiredLinearHistory; obs_enabled("required_linear_history")),
329-
allow_force_pushes: resolve($e.protection.allowForcePushes; obs_enabled("allow_force_pushes")),
330-
allow_deletions: resolve($e.protection.allowDeletions; obs_enabled("allow_deletions"))
373+
required_linear_history: pick("requiredLinearHistory"; $e.protection.requiredLinearHistory; obs_enabled("required_linear_history")),
374+
allow_force_pushes: pick("allowForcePushes"; $e.protection.allowForcePushes; obs_enabled("allow_force_pushes")),
375+
allow_deletions: pick("allowDeletions"; $e.protection.allowDeletions; obs_enabled("allow_deletions"))
331376
} + preserved_protections) }
332377
')"
333378

334379
# ── Render ───────────────────────────────────────────────────────────────────
335380
printf 'settings-diff — %s (branch %s)\n\n' "$repo" "$branch"
336381

337382
printf '%s' "$findings" | jq -r '
338-
.findings[]
383+
.findings[] | select(((.loosens // false) and .sev == "FAIL") | not)
339384
| "\(.sev) \(.msg)" + (if .fix then "\n fix: \(.fix)" else "" end) + "\n"
340385
'
341386

387+
# A diff whose fix WEAKENS the branch reads, in a plain list, exactly like one that
388+
# tightens it — and the workflow is a human pasting the call below. So those get their own
389+
# section, above the call, one line each. (A loosening FAIL is listed only there; the WARN
390+
# about a dropped check keeps its fuller explanation in the list as well.)
391+
# Only when the call is actually printed: a dropped-check WARN on its own prints no PUT, and
392+
# a section pointing at a call that is not there would be worse than none.
393+
if printf '%s' "$findings" | jq -e '.protectionDiffers and .protectionKnown and ([.findings[] | select(.loosens)] | length > 0)' >/dev/null; then
394+
printf 'LOOSENING — applying the call below would make the branch LESS protected here:\n'
395+
printf '%s' "$findings" | jq -r '.findings[] | select(.loosens) | " \(.loosenMsg)"'
396+
printf ' Read each before pasting. To keep a stricter value, list the key in the profile'"'"'s\n'
397+
printf ' protectionFloors (where stricter is always acceptable) or add it to the profile.\n\n'
398+
fi
399+
342400
# Branch protection is REPLACED by its PUT, never patched: a call carrying only the
343401
# diverging key clears every key it omits. So one call, carrying the whole desired state,
344402
# with the per-key differences above as its reasons.
@@ -357,6 +415,8 @@ printf '%s' "$findings" | jq -r '
357415
| ([.findings[] | select(.sev == "WARN")] | length) as $w
358416
| ([.findings[] | select(.sev == "SKIP")] | length) as $s
359417
| "Summary: \($f) difference(s), \($w) warning(s), \($s) not verified."
418+
+ (([.findings[] | select(.loosens)] | length) as $l
419+
| if $l > 0 and .protectionDiffers and .protectionKnown then "\n\($l) of those would loosen the branch (see LOOSENING above)." else "" end)
360420
+ "\nNot checked here: protection.requiredReviews.countsBotApproval — GitHub has no setting behind it."
361421
'
362422

0 commit comments

Comments
 (0)