Repository navigation
fix(branches): refuse a non-boolean control at parse, and let a null "off" converge - #599
Conversation
…"off" converge
Every boolean of the protection PUT body is now typed in the schema: the eight controls and the three review settings. A quoted value such as "yes" is refused when the file is parsed, on a literal branch and on a wildcard rule alike, in the wording required_signatures already had. Before, the PUT answered 422 at apply, and a wildcard rule's mutation was sent the string.
A null is refused on a literal branch wherever the PUT does not mark the boolean nullable, as "has no empty state; write true or false". A wildcard rule still takes that null: the rule mutation types every twin as a nullable Boolean, so GitHub can accept the file.
Under enforce_admins and allow_force_pushes null is the PUT's own spelling of off, so the section now reads it as false in one place. A check against GitHub's {enabled: false} is clean, where it reported "expected empty, live has false" on every run. A wildcard rule's mutation is sent false, where a null it stored failed the next rules read.
A copied {url, enabled} wrapper is refused at the control's own path with the same advice; the key sweep used to report it one step deeper, at .enabled. The published JSON schema types the new booleans, so the known-divergence entry for the copied-GET scenario is deleted: both validators refuse it now.
The PUT's 422 hint names only what GitHub's descriptor says the PUT judges against the repository, since the parser refuses a malformed shape first. The missing-branch refusal no longer leads with the section key twice.
File size check0 over a hard cap (fails), 6 warning(s).
Split the file, wrap the line, shorten or exempt the comment, or list the path in 5 managed file(s) skipped; repo-platform owns them. |
Member shape check20 finding(s).
Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in 4 generated file(s) skipped. |
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues were identified for the branch-protection validation and null-handling changes.
No open issues 🎉
What changed in this PR
This PR strengthens branch-protection validation in GitHub Settings as Code and makes nullable off controls converge across literal branches and wildcard rules.
Changes:
- Reject non-boolean controls and unsupported literal-branch nulls during parsing, with actionable errors.
- Normalize
nulltofalseforenforce_adminsandallow_force_pushesbefore comparison and writes. - Add regression coverage and update diagnostics, schema documentation, and migration guidance.
| File | Description |
|---|---|
test/sections/refusal-messages.test.ts |
Register boolean helpers' data arguments. |
test/sections/branches/schema.test.ts |
Cover boolean validation, null contracts, and wrapper errors. |
test/sections/branches/scenarios/branches-null-off-control-converges.yml |
Verify null-as-off convergence and repeat-run stability. |
test/sections/branches/scenarios/branches-non-boolean-control-rejected.yml |
Verify invalid controls fail before requests. |
test/sections/branches/scenarios/branches-get-response-copied-rejected.yml |
Update the expected wrapper-error path. |
test/sections/branches/branches.test.ts |
Test normalized writes and missing-branch wording. |
test/schema-corpus.test.ts |
Remove the obsolete copied-response schema exception. |
src/sections/branches/schema.ts |
Type protection booleans and provide targeted errors. |
src/sections/branches/keys.ts |
Clarify the control-list contract in a comment. |
src/sections/branches/index.ts |
Reject unsupported nulls and normalize nullable off controls. |
src/sections/branches/graphql-vocabulary.ts |
Include typed controls in REST-carried key coverage. |
src/sections/branches/endpoints.ts |
Replace outdated 422 advice with repository-state causes. |
docs/upgrading/v2-to-v3/refusals-lists.md |
Update the documented wrapper-error path. |
docs/sections/branches.docs.yml |
Document boolean validation and null behavior. |
docs/reference/coverage.md |
Refresh generated branch-protection coverage notes. |
Review effort: Balanced
…hema
The published schema accepted `protection: {allow_deletions: null}` on a literal branch while the runtime refused it. The refusal now lives on BranchConfig in schema.ts beside its published twin: one conditional that types the same nine keys boolean when the name is not a wildcard pattern, over the regex the runtime tests. index.ts keeps the wildcard key sweep alone.
The rows in schema.test.ts now judge every PUT boolean with both validators, and a scenario whose settings hold only that null puts the document in front of the corpus gate.
The PUT's 422 hint says what it is: causes the parser does not refuse. A list of 101 actors can be counted from the file, so "only GitHub can judge" was wrong. The test that restated two refusals main already had is deleted, and three comments on constants are one line each.
With the engine now leaving alone a line that leads with its section key, the missing-branch refusal prints as `branches[<name>]: ...`, where a run printed `branches: branches[<name>]: ...`. The scenario lines held only a substring both forms contain, so one stdout line now pins the printed error. The comment about the published twin moves above `.meta(`, so the members of that object are uncommented alike.
Before
After
Every refused input failed whenever a write carried it. Owner's exception: a literal
nullunder a non-nullable boolean ran clean while the live GET omitted that control (GitHub's descriptor allows it), and is refused now.Owner's call: the last row stays accepted (capture above). GitHub's nullable
Booleanmay take it, and check would readexpected empty, live has falseevery run. Precedent:required_signatures: null, refused there.The published JSON schema refuses the same inputs, so an editor flags them.
How
control()andtoggle()type every PUT boolean.nullReadAsOffreads the two nullable controls asfalse.BranchProtectionConfigkeys asboolean | null, so a TypeScript caller passing"yes"stops compiling.SettingsFile.safeParse()called directly now refuses a literalnullunder a non-nullable control.Proof
main.Technical details
control()andtoggle()carry therequired_signatureswording to each typed boolean.enforce_adminstoallow_fork_syncing, anddismiss_stale_reviews,require_code_owner_reviews,require_last_push_approval.BranchConfig,SettingsFile, andSectionInput<"branches">, where the eleven keys were an unknown passthrough.BranchConfiginschema.tsraises zod'sinvalid_typeissue for a null under a PUT boolean GitHub does not mark nullable.<path> has no empty state; write true or false, the linerequired_signatures: nullalready gets.conditional()call inBranchConfig's meta publishes that rule, typing the nine keysbooleanwhen the name holds none of*,?,[.WILDCARD_NAME, the regexisWildcardPatterntests.schema.test.tsjudge each PUT boolean with both validators, for a quoted value and for null on each kind of entry.refuseNullsThePutRefusesinschema.tsnames both contracts.SettingsFile's inferred type is what a library consumer writes, so the null-as-off reading is a function in the section.enforce_adminsorallow_force_pushesis sent asfalse.false, that null diffs against a missing field as a declaredfalsealways did, so one apply sends the PUT and the next run is clean.urlandenabledlines under a wrapped control fold into the control's one line.required_signaturesandrequired_status_checks.strictrefuse null on every entry, as before.paths["/repos/{owner}/{repo}/branches/{branch}/protection"].get.responses["200"](Branch Protection) has norequiredlist, so its answer may omit any control.mainplanned no PUT and nothing carried the null.mainplanned the PUT carrying the null, which the PUT answers 422.restrictionsordismissal_restrictionson a personal repository, the 100-actor total, andrequired_linear_historywithout squash or rebase merging.branches[<name>]:, a line the engine prints as is, so a run no longer prints the section key twice.branches-missing-branch-advisory-apply-fails.ymlnow holds the printed::error::branches[release]: ...line, which fails on the engine before that rule.mainand then this branch.safeParse, why: the null rule sits inBranchConfig's shape, whichSettingsFilecomposes, where an untyped key passed before.UpdateBranchProtectionRuleInput.allowsDeletionsisBoolean, andBranchProtectionRule.allowsDeletionsisBoolean!.mainhold 49 PUT booleans (42 controls, 7 review settings) and 7required_signaturesvalues, every one a boolean.main.branches-get-response-copied-rejected.ymland the copied-GET case inschema.test.tsexpect the wrapper atbranches[0].protection.enforce_admins.schema.test.tsshares a review holder, since a wrapper under a typed control stops at the control.branches.test.tsexpectsbranches[main]: ...without the doubled key.test/schema-corpus.test.tslisted the copied-GET scenario as schema-looser, and its own gate named the entry stale once both validators refused it.test/sections/refusal-messages.test.tsregisterscontrolandtoggleas data arguments, likeactorList.docs/upgrading/v2-to-v3/refusals-lists.mdquotes the wrapper refusal at its new path.branches-non-boolean-control-rejected,branches-null-off-control-converges, andbranches-literal-null-control-rejected, each red onmain.bun capture.ts /repo scenarios.yml, the script below, once on a copy oforigin/mainand once on this branch.fixpoint: apply_idempotent, which runs the second apply and the check.bun census.ts /repo, the second script below.bun test test/sections/branches/schema.test.ts test/sections/branches/branches.test.ts.origin/maingive 152 pass and 35 fail.bun test/e2e/run.ts --sections branches./repo/settings.yml, and debug request lines are left out.mainand then this branch, two runs each.mainand then this branch.main.src/sections/branches/{schema,index,keys,endpoints,graphql-vocabulary}.ts,test/sections/branches/{schema,branches}.test.ts, five files undertest/sections/branches/scenarios/,docs/sections/branches.docs.yml,docs/reference/coverage.md(regenerated).