Skip to content

Commit 5da417f

Browse files
feat(custom_properties): support include/exclude to preserve unmanaged 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 (#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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4dac294 commit 5da417f

9 files changed

Lines changed: 695 additions & 50 deletions

File tree

docs/sample-settings/settings.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,52 @@ branches:
203203

204204
# Custom properties
205205
# See https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2026-03-10
206+
#
207+
# Custom properties cannot be deleted through the API, so a property that exists
208+
# on the repository but is absent from this config has its value set to `null`.
209+
# That means safe-settings assumes ownership of every custom property on the
210+
# repository. If other automation (a GitHub App, a workflow) sets property values
211+
# you want left alone, list them under `exclude` using the object form below.
206212
custom_properties:
207213
- name: test
208214
value: test
209215

216+
# The object form additionally protects properties from being cleared:
217+
#
218+
# custom_properties:
219+
# include:
220+
# - name: test
221+
# value: test
222+
# exclude:
223+
# # Regexes - NOT globs - matched against the property name. Casing does not
224+
# # matter; patterns are lowercased before matching.
225+
# # Never clear the value of any property starting with "app-":
226+
# - name: ^app-
227+
# # Never clear the value of the "deploy-status" property:
228+
# - name: ^deploy-status$
229+
#
230+
# To manage only the properties you declare and leave every other property on the
231+
# repository untouched, exclude everything with `.*`:
232+
#
233+
# custom_properties:
234+
# include:
235+
# - name: test
236+
# value: test
237+
# exclude:
238+
# - name: .*
239+
#
240+
# Notes:
241+
# * These are regexes, so "match everything" is `.*`. A bare `*` is not a valid
242+
# regex; see the invalid-pattern note below for what happens if you use one.
243+
# * `exclude` only prevents removal. A property listed in `include` is always
244+
# created/updated, even if it also matches an `exclude` pattern.
245+
# * `exclude` on its own (no `include`) still means "safe-settings manages the
246+
# custom properties on this repo" - every property not matching a pattern is
247+
# cleared. To manage nothing, omit the `custom_properties` section entirely.
248+
# * An invalid regex fails closed: the error is reported and every property on
249+
# that repo is excluded, so a typo protects values rather than clearing them.
250+
# Properties in `include` are still applied.
251+
210252
# See the docs (https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/configuring-autolinks-to-reference-external-resources) for a description of autolinks and replacement values.
211253
autolinks:
212254
- key_prefix: "JIRA-"

lib/plugins/custom_properties.js

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,91 @@
11
const Diffable = require('./diffable')
22
const NopCommand = require('../nopcommand')
33

4+
// Detects the `{ include: [...], exclude: [...] }` config shape. Either key on
5+
// its own is enough; a missing `include` is treated as an empty include list.
6+
function isExcludeAwareConfig (entries) {
7+
return !!entries &&
8+
typeof entries === 'object' &&
9+
!Array.isArray(entries) &&
10+
(Array.isArray(entries.include) || Array.isArray(entries.exclude))
11+
}
12+
413
module.exports = class CustomProperties extends Diffable {
5-
constructor (...args) {
6-
super(...args)
14+
constructor (nop, github, repo, entries, log, errors) {
15+
// Two config shapes are supported:
16+
//
17+
// custom_properties: | custom_properties:
18+
// - name: jira-team | include:
19+
// value: Platform | - name: jira-team
20+
// | value: Platform
21+
// | exclude:
22+
// | - name: ^app-
23+
//
24+
// The object shape lets a config declare the properties it manages
25+
// (`include`) while protecting properties owned by other automation
26+
// (`exclude`) from being nulled out. The plain array shape is unchanged.
27+
let include = entries
28+
let exclude = []
29+
30+
if (isExcludeAwareConfig(entries)) {
31+
include = Array.isArray(entries.include) ? entries.include : []
32+
exclude = Array.isArray(entries.exclude) ? entries.exclude : []
33+
}
34+
35+
super(nop, github, repo, include, log, errors)
36+
37+
const { patterns, excludeAll } = this.compileExcludePatterns(exclude)
38+
this.exclude = patterns
39+
this.excludeAll = excludeAll
740

841
if (this.entries) {
942
this.normalizeEntries()
1043
}
1144
}
1245

46+
// Compile the `exclude[].name` regex strings, skipping entries without a
47+
// string name. An unparseable pattern is recorded as a config error for this
48+
// repo rather than thrown: child plugins are constructed outside of any
49+
// try/catch in `Settings.updateRepos`, so throwing here would reject the
50+
// org-wide `Promise.all` and abort the sync for every other repo too.
51+
//
52+
// An invalid pattern fails closed - every property on the repo is treated as
53+
// excluded. A typo in an exclude pattern is a request to protect something,
54+
// so the safe reading is "protect everything until the config is fixed"
55+
// rather than "protect nothing", which would clear the very values the
56+
// pattern was written to defend. Properties in `include` are still enforced.
57+
compileExcludePatterns (exclude) {
58+
return exclude.reduce((state, item) => {
59+
if (!item || typeof item.name !== 'string') {
60+
return state
61+
}
62+
63+
try {
64+
// Property names are lowercased before matching, so lowercase the
65+
// pattern too - an uppercase pattern would otherwise be a valid regex
66+
// that silently matches nothing and clears the properties it names.
67+
state.patterns.push(new RegExp(item.name.toLowerCase()))
68+
} catch (e) {
69+
this.logError(`Invalid custom property exclude pattern "${item.name}": ${e.message || e}. Excluding all custom properties for this repo so no values are cleared.`)
70+
state.excludeAll = true
71+
}
72+
73+
return state
74+
}, { patterns: [], excludeAll: false })
75+
}
76+
77+
// Exclude patterns are matched against the normalized (lowercased) property
78+
// name. Patterns are lowercased when compiled, so config casing does not
79+
// matter. Note these are regexes, not globs: `.*` matches everything, `*` is
80+
// not a valid pattern.
81+
isExcluded (name) {
82+
if (this.excludeAll) {
83+
return true
84+
}
85+
86+
return typeof name === 'string' && this.exclude.some(rx => rx.test(name))
87+
}
88+
1389
// Force all names to lowercase to avoid comparison issues.
1490
normalizeEntries () {
1591
this.entries = this.entries.reduce((normalizedEntries, entry) => {
@@ -90,6 +166,10 @@ module.exports = class CustomProperties extends Diffable {
90166

91167
// Custom Properties on repository does not support deletion, so we set the value to null
92168
async remove ({ name }) {
169+
if (this.isExcluded(name)) {
170+
this.log.debug(`Custom Property "${name}" matches an exclude pattern; leaving its value untouched`)
171+
return Promise.resolve([])
172+
}
93173
return this.modifyProperty('Delete', { name, value: null })
94174
}
95175

schema/dereferenced/repos.json

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -778,19 +778,57 @@
778778
},
779779
"custom_properties": {
780780
"description": "Custom properties",
781-
"type": "array",
782-
"items": {
783-
"description": "A custom property entry",
784-
"type": "object",
785-
"properties": {
786-
"name": {
787-
"type": "string"
788-
},
789-
"value": {
790-
"type": "string"
781+
"oneOf": [
782+
{
783+
"type": "array",
784+
"items": {
785+
"description": "A custom property entry",
786+
"type": "object",
787+
"properties": {
788+
"name": {
789+
"type": "string"
790+
},
791+
"value": {
792+
"type": "string"
793+
}
794+
}
795+
}
796+
},
797+
{
798+
"type": "object",
799+
"properties": {
800+
"include": {
801+
"description": "Custom properties managed by safe-settings",
802+
"type": "array",
803+
"items": {
804+
"description": "A custom property entry",
805+
"type": "object",
806+
"properties": {
807+
"name": {
808+
"type": "string"
809+
},
810+
"value": {
811+
"type": "string"
812+
}
813+
}
814+
}
815+
},
816+
"exclude": {
817+
"description": "Never clear the value of any custom property whose name matches one of these regexes",
818+
"type": "array",
819+
"items": {
820+
"description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear",
821+
"type": "object",
822+
"properties": {
823+
"name": {
824+
"type": "string"
825+
}
826+
}
827+
}
828+
}
791829
}
792830
}
793-
}
831+
]
794832
},
795833
"variables": {
796834
"description": "Repository or org-level Actions variables",

schema/dereferenced/settings.json

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,19 +1956,57 @@
19561956
},
19571957
"custom_properties": {
19581958
"description": "Custom properties",
1959-
"type": "array",
1960-
"items": {
1961-
"description": "A custom property entry",
1962-
"type": "object",
1963-
"properties": {
1964-
"name": {
1965-
"type": "string"
1966-
},
1967-
"value": {
1968-
"type": "string"
1959+
"oneOf": [
1960+
{
1961+
"type": "array",
1962+
"items": {
1963+
"description": "A custom property entry",
1964+
"type": "object",
1965+
"properties": {
1966+
"name": {
1967+
"type": "string"
1968+
},
1969+
"value": {
1970+
"type": "string"
1971+
}
1972+
}
1973+
}
1974+
},
1975+
{
1976+
"type": "object",
1977+
"properties": {
1978+
"include": {
1979+
"description": "Custom properties managed by safe-settings",
1980+
"type": "array",
1981+
"items": {
1982+
"description": "A custom property entry",
1983+
"type": "object",
1984+
"properties": {
1985+
"name": {
1986+
"type": "string"
1987+
},
1988+
"value": {
1989+
"type": "string"
1990+
}
1991+
}
1992+
}
1993+
},
1994+
"exclude": {
1995+
"description": "Never clear the value of any custom property whose name matches one of these regexes",
1996+
"type": "array",
1997+
"items": {
1998+
"description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear",
1999+
"type": "object",
2000+
"properties": {
2001+
"name": {
2002+
"type": "string"
2003+
}
2004+
}
2005+
}
2006+
}
19692007
}
19702008
}
1971-
}
2009+
]
19722010
},
19732011
"variables": {
19742012
"description": "Repository or org-level Actions variables",

schema/dereferenced/suborgs.json

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -812,19 +812,57 @@
812812
},
813813
"custom_properties": {
814814
"description": "Custom properties",
815-
"type": "array",
816-
"items": {
817-
"description": "A custom property entry",
818-
"type": "object",
819-
"properties": {
820-
"name": {
821-
"type": "string"
822-
},
823-
"value": {
824-
"type": "string"
815+
"oneOf": [
816+
{
817+
"type": "array",
818+
"items": {
819+
"description": "A custom property entry",
820+
"type": "object",
821+
"properties": {
822+
"name": {
823+
"type": "string"
824+
},
825+
"value": {
826+
"type": "string"
827+
}
828+
}
829+
}
830+
},
831+
{
832+
"type": "object",
833+
"properties": {
834+
"include": {
835+
"description": "Custom properties managed by safe-settings",
836+
"type": "array",
837+
"items": {
838+
"description": "A custom property entry",
839+
"type": "object",
840+
"properties": {
841+
"name": {
842+
"type": "string"
843+
},
844+
"value": {
845+
"type": "string"
846+
}
847+
}
848+
}
849+
},
850+
"exclude": {
851+
"description": "Never clear the value of any custom property whose name matches one of these regexes",
852+
"type": "array",
853+
"items": {
854+
"description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear",
855+
"type": "object",
856+
"properties": {
857+
"name": {
858+
"type": "string"
859+
}
860+
}
861+
}
862+
}
825863
}
826864
}
827-
}
865+
]
828866
},
829867
"variables": {
830868
"description": "Repository or org-level Actions variables",

0 commit comments

Comments
 (0)