feat(custom_properties): support include/exclude to preserve unmanaged property values - #1036
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends the custom_properties plugin to support an { include, exclude } configuration shape, allowing safe-settings to manage only declared properties while preserving values owned by other automation via exclude regexes. It also updates schemas, docs, and unit tests to cover the new behavior while keeping the existing plain-array config shape unchanged.
Changes:
- Add
{ include, exclude }config-shape support tocustom_properties, including exclude-regex compilation and delete short-circuiting. - Update JSON schemas (and dereferenced copies) to accept both the legacy array shape and the new object shape.
- Document the ownership/clearing caveat and add unit tests for include/exclude behavior, casing, nop-mode, and invalid regex handling.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/plugins/custom_properties.js | Implements include/exclude shape detection, exclude regex compilation, and protected remove behavior. |
| test/unit/lib/plugins/custom_properties.test.js | Adds unit tests validating new include/exclude behavior and regressions for the legacy array shape. |
| schema/repos.json | Updates repo override schema to accept both array and object forms for custom_properties. |
| schema/settings.json | Updates org-level schema to accept both array and object forms for custom_properties. |
| schema/suborgs.json | Updates suborg-level schema to accept both array and object forms for custom_properties. |
| schema/dereferenced/repos.json | Mirrors the schema change in the dereferenced repo schema. |
| schema/dereferenced/settings.json | Mirrors the schema change in the dereferenced settings schema. |
| schema/dereferenced/suborgs.json | Mirrors the schema change in the dereferenced suborg schema. |
| docs/sample-settings/settings.yml | Documents the new object form and the “clearing sets value to null” ownership caveat. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }, | ||
| { | ||
| "type": "object", | ||
| "properties": { |
There was a problem hiding this comment.
Fixed — the object branch now requires at least one of the two keys:
"anyOf": [
{ "required": ["include"] },
{ "required": ["exclude"] }
]Verified with ajv against the dereferenced schema: custom_properties: {} and { bogus: 1 } are now rejected, while the array form, { include }, { exclude } and { include, exclude } all still validate. Applied to all three source schemas and their dereferenced counterparts.
| }, | ||
| { | ||
| "type": "object", | ||
| "properties": { |
There was a problem hiding this comment.
Fixed here too — same anyOf / required change as on schema/repos.json, so {} no longer validates.
5da417f to
224131c
Compare
|
@jordonpeterson please look at this feature already in the code in yadhav/fix-recent-issues branch. That should meet your requirements. |
c804d27 to
27ff620
Compare
…d property values Custom properties cannot be deleted through the API, so Diffable's removal path sets `value: null`. Any property present on a repository but absent from config was therefore cleared on every sync, including values written by other automation. Safe-settings effectively claimed exclusive ownership of the whole custom-properties surface on any repo with a `custom_properties` section. Adds the `include`/`exclude` config shape already used by the Labels plugin (github-community-projects#408). Properties matching an `exclude` regex are never cleared; properties listed in `include` are enforced exactly as before, even if they also match an exclude pattern. The plain-array config shape is unchanged. Both config-error paths are designed to fail safe, because the cost of misreading an exclude pattern is cleared property values: * Exclude patterns are lowercased when compiled. Property names are lowercased before matching, so an uppercase pattern would otherwise be a valid regex that silently matches nothing and clears the properties it was written to protect. * An unparseable pattern is recorded as a per-repo config error and fails closed - every property on that repo is treated as excluded, so a typo protects values rather than clearing them. This matters because these are regexes, not globs: the intuitive `*` is not a valid pattern. Properties in `include` are still applied. The error is not thrown, because child plugins are constructed outside any try/catch in Settings.updateRepos, so a throw there rejects the org-wide Promise.all and aborts the sync for every other repo. * The object form requires `include`, `exclude`, or both. A config that is neither the array shape nor the include/exclude shape (`custom_properties: {}`, say) is reported and also fails closed. The schemas reject that shape too, but they are authoring aids rather than a runtime gate - nothing in lib/ validates config against them - so the plugin does not rely on them. Without this the empty object reached `normalizeEntries` and threw a TypeError out of the constructor, which is the org-wide abort described above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27ff620 to
47c5a41
Compare
|
Thanks @decyjphr — The one gap is that it's all-or-nothing per plugin, whereas #986 asks to exclude specific properties while still clearing others. This PR covers that case and is tested, but it's entirely up to you whether that configurability is worth carrying — happy for this to be closed if not. Thanks! |
Lets a
custom_propertiesconfig declare the properties it manages and leaveproperties owned by other automation untouched, instead of clearing every property
it does not list.
Closes #986 — implements the
include/excludeshape requested there, mirroringthe Labels plugin as the issue suggests.
What
To manage only what you declare and leave every other property alone, exclude
everything with
.*:The existing plain-array shape is unchanged.
Why
Custom properties cannot be deleted through the API, so
Diffable's removal pathsets
value: null. Any property present on a repository but absent from config wastherefore cleared on every sync — including values written by other automation. In
effect safe-settings claimed exclusive ownership of the entire custom-properties
surface of any repo with a
custom_propertiessection, with no way to opt out shortof dropping the section altogether. That is exactly the case #986 describes:
org-wide properties should be enforced while per-repo ones are left alone.
Behavior
excluderegex is never cleared.includeis created/updated as before, even if it also matches anexcludepattern.includewins.excludeon its own still means safe-settings manages this repo's properties —everything not matching a pattern is cleared. To manage nothing, omit the section.
Config mistakes fail closed rather than clearing values — see the design notes below.
Changes
lib/plugins/custom_properties.js— config-shape detection, exclude-patterncompilation, exclude check in
remove()test/unit/lib/plugins/custom_properties.test.js— 28 new testsREADME.md— "Preserving custom properties thatsafe-settingsdoes notmanage", alongside the existing include/exclude notes for
collaborators,teamsand
labelsdocs/sample-settings/settings.yml— worked example, linking to the README forthe semantics
schema/{repos,settings,suborgs}.json— accept both shapes; require the objectform to carry
include,exclude, or bothschema/dereferenced/*.json— 6 of the 10 changed files are these generatedschemas. Their
custom_propertiesblocks are verified byte-equal tonpm run build:schemaoutput. They are spliced in rather than committed wholesalebecause a full regeneration also pulls in unrelated
repositories/teams/rulesetschurn from the live GitHub API description, which drifts on
main-enterpriseindependently of this change.
Testing
New behavior —
test/unit/lib/plugins/custom_properties.test.jspasses 33/33,covering exclude protection,
include-wins precedence, casing on both the APIresponse and the pattern, nop mode, degenerate configs, invalid regexes, and
malformed object configs.
Existing behavior is provably unchanged. Since this touches a path that nulls out
property values, the old behavior was verified directly rather than by inspection: a
15-scenario matrix (× apply and nop modes = 30 runs) was run against the plugin as it
exists on
main-enterpriseand against this branch, capturing API writes,NopCommands, normalized entries, and recorded errors for each. The two transcriptsare byte-identical. The matrix uses only pre-change config shapes — plain arrays,
null,undefined— and covers update, create, no-op, clearing unmanaged properties,the
property_namekey variant, mixed casing on both sides, nameless and non-objectentries, and multi-property syncs. 11 of the 30 runs perform API writes and 11 emit
NopCommands, so the comparison is exercising real output.The schemas were checked the same way: every config shape that validated against the
schema on
main-enterprisestill validates here — empty array,{name,value},{property_name,value}, multiple entries, name-only, and nocustom_propertieskey.Zero regressions.
npm run test:unit(what CI runs) passes 162 tests across 16 suites.standardisclean on both changed files, and
eslintreports the same 145 pre-existing problemsas the base commit — no new lint.
Verified against a real organization
The unit tests stub the GitHub client, so the plugin was also dry-run against a real
organization using
ProbotOctokitin nop mode — real API, real response shapes, realpagination — with the write endpoint replaced by a guard that throws if called, so no
modification was possible.
Setup: three custom properties defined on a scratch org and set on one repository.
safe-settings-managedis the property safe-settings declares;ext-deploy-statusand
ext-cost-centerstand in for values written by other automation.main-enterpriseext-cost-center,ext-deploy-status— 2 Delete commandsext-cost-center,ext-deploy-status— 2 Delete commands (identical)include+exclude: ^ext-The middle row is the backward-compatibility check: with a legacy config the plan this
branch produces is byte-identical to the plan the current plugin produces, against the
same live repository. The third row is the fix. All three runs recorded 0 write
attempts. The org was cleaned up afterwards.
Two things this surfaced that are worth flagging:
@octokit/plugin-rest-endpoint-methodsv17 renamed both endpoints this plugincalls —
getCustomPropertiesValuesandcreateOrUpdateCustomPropertiesValuesareboth
undefinedon the currently installed client, sofind()throws against a realAPI regardless of this PR. That is issue 2.1.21: CustomProperties plugin still crashes — modifyProperty() calls renamed octokit methods #981 / PR Fix CustomProperties plugin calls renamed in @octokit/plugin-rest-endpoint-methods v17 #1032; the dry run shimmed the old
names onto the new ones to get past it. Worth noting that Fix CustomProperties plugin calls renamed in @octokit/plugin-rest-endpoint-methods v17 #1032 is effectively a
prerequisite for this feature working at runtime. The unit tests do not catch it
because they stub the client methods by name.
deletions, becauseDiffable.sync()computes that summary fromcompareDeepbefore
remove()runs. No Delete command is emitted and nothing is written — thelisting is cosmetic, but a dry-run reader may find it confusing.
Design notes: why config errors fail closed, and where this diverges from Labels
The
include/excludevocabulary and the unanchored-regex matching followlib/plugins/labels.js. Two deliberate divergences, both because the cost ofmisreading an exclude pattern here is cleared property values rather than a stray
label:
matching, so an uppercase pattern would otherwise be a valid regex that silently
matches nothing and clears the properties it was written to protect.
every property on that repo is treated as excluded, so a typo protects values
rather than clearing them. This matters because these are regexes, not globs — the
intuitive
*is not a valid pattern, and under a fail-open reading it would clearevery unmanaged property. Properties in
includeare still applied.A malformed object config fails closed the same way. The object form requires
include,exclude, or both; the schemas reject anything else, but the plugin doesnot rely on them — they are authoring aids, not a runtime gate, since nothing in
lib/validates config against them. Without the runtime checkcustom_properties: {}reachednormalizeEntriesand threw aTypeErrorout of theconstructor.
Errors are recorded rather than thrown throughout: child plugins are constructed
outside any try/catch in
Settings.updateRepos, so throwing would reject the org-widePromise.alland abort the sync for every other repo. A bad config in one repo shouldnot take down the org's sync.
Happy to rebase if #1032 lands first — it touches the same plugin.