Skip to content
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,54 @@ See [`docs/sample-settings/settings.yml`](docs/sample-settings/settings.yml) for
> - '*-config'
> ```

#### Preserving custom properties that `safe-settings` does not manage

Custom properties cannot be deleted through the API, so a property that exists on a
repository but is absent from `custom_properties` has its value set to `null`. By
default `safe-settings` therefore owns *every* custom property on the repository. Use
the object form to declare what it manages and leave everything else untouched:

```yml
custom_properties:
include:
- name: ruleset-tier
value: strict
exclude:
# Never clear the value of any property starting with "app-"
- name: ^app-
```

To manage only the properties you declare and leave all others alone, exclude
everything with `.*`:

```yml
custom_properties:
include:
- name: ruleset-tier
value: strict
exclude:
- name: .*
```

> [!NOTE]
> Unlike the repository patterns above, these are **regular expressions matched
> against the property name**, not globs — "match everything" is `.*`, and a bare `*`
> is not a valid pattern. Casing does not matter.
>
> - `exclude` only prevents clearing. A property in `include` is always applied, even
> if it also matches an `exclude` pattern
> - `exclude` on its own still means `safe-settings` manages this repository's custom
> properties — every property not matching a pattern is cleared. To manage nothing,
> omit the `custom_properties` section entirely
> - An invalid pattern, or an object form with neither `include` nor `exclude`, is
> reported as a config error and fails closed: every property on that repository is
> left untouched, so a typo protects values rather than clearing them
> - `exclude` patterns accumulate across scopes, so a repository can add patterns to
> the ones defined for the org or suborg without restating them

See [`docs/sample-settings/settings.yml`](docs/sample-settings/settings.yml) for a
commented example.

### Additional values

In addition to the values in the file above, the settings file can have some additional values:
Expand Down
16 changes: 16 additions & 0 deletions docs/sample-settings/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,26 @@ branches:

# Custom properties
# See https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2026-03-10
# A property absent from this config has its value cleared. To leave properties
# owned by other automation untouched, use the object form shown below - see
# "Preserving custom properties that `safe-settings` does not manage" in the README.
custom_properties:
- name: test
value: test

# custom_properties:
# include:
# - name: test
# value: test
# exclude:
# # Regexes - not globs - matched against the property name
# - name: ^app-
# # Or `.*` to manage only the properties listed under `include`
# - name: ^deploy-status$
# * The object form must contain `include`, `exclude`, or both. A malformed
# `custom_properties` (`{}`, say) is reported and also fails closed, leaving
# every property on the repo untouched.

# 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.
autolinks:
- key_prefix: "JIRA-"
Expand Down
68 changes: 66 additions & 2 deletions lib/plugins/custom_properties.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,75 @@
const Diffable = require('./diffable')
const NopCommand = require('../nopcommand')

// Config shapes, precedence and the fail-closed behavior below are documented in
// README.md, "Preserving custom properties that `safe-settings` does not manage",
// with a commented example in docs/sample-settings/settings.yml.
function isExcludeAwareConfig (entries) {
return !!entries &&
typeof entries === 'object' &&
!Array.isArray(entries) &&
(Array.isArray(entries.include) || Array.isArray(entries.exclude))
}

module.exports = class CustomProperties extends Diffable {
constructor (...args) {
super(...args)
constructor (nop, github, repo, entries, log, errors) {
let include = entries
let exclude = []
let malformed = false

if (isExcludeAwareConfig(entries)) {
include = Array.isArray(entries.include) ? entries.include : []
exclude = Array.isArray(entries.exclude) ? entries.exclude : []
} else if (entries !== null && entries !== undefined && !Array.isArray(entries)) {
// Neither config shape, e.g. `custom_properties: {}`. Fail closed rather than
// letting a TypeError escape the constructor and reject the org-wide sync.
include = []
malformed = true
}

super(nop, github, repo, include, log, errors)

const { patterns, excludeAll } = this.compileExcludePatterns(exclude)
this.exclude = patterns
this.excludeAll = excludeAll || malformed

if (malformed) {
this.logError('`custom_properties` must be a list of properties or an object with `include` and/or `exclude` keys. Ignoring it and excluding all custom properties for this repo so no values are cleared.')
}

if (this.entries) {
this.normalizeEntries()
}
}

// An invalid pattern is recorded as a config error rather than thrown, because
// child plugins are constructed outside any try/catch in `Settings.updateRepos`.
compileExcludePatterns (exclude) {
return exclude.reduce((state, item) => {
if (!item || typeof item.name !== 'string') {
return state
}

try {
// Lowercased to match the normalized property names.
state.patterns.push(new RegExp(item.name.toLowerCase()))
} catch (e) {
this.logError(`Invalid custom property exclude pattern "${item.name}": ${e.message || e}. Excluding all custom properties for this repo so no values are cleared.`)
state.excludeAll = true
}

return state
}, { patterns: [], excludeAll: false })
}

isExcluded (name) {
if (this.excludeAll) {
return true
}

return typeof name === 'string' && this.exclude.some(rx => rx.test(name))
}

// Force all names to lowercase to avoid comparison issues.
normalizeEntries () {
this.entries = this.entries.reduce((normalizedEntries, entry) => {
Expand Down Expand Up @@ -90,6 +150,10 @@ module.exports = class CustomProperties extends Diffable {

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

Expand Down
72 changes: 61 additions & 11 deletions schema/dereferenced/repos.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,19 +778,69 @@
},
"custom_properties": {
"description": "Custom properties",
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
"oneOf": [
{
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
{
"type": "object",
"properties": {
"include": {
"description": "Custom properties managed by safe-settings",
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"exclude": {
"description": "Never clear the value of any custom property whose name matches one of these regexes",
"type": "array",
"items": {
"description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear",
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
}
},
"anyOf": [
{
"required": [
"include"
]
},
{
"required": [
"exclude"
]
}
]
}
}
]
},
"variables": {
"description": "Repository or org-level Actions variables",
Expand Down
72 changes: 61 additions & 11 deletions schema/dereferenced/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1956,19 +1956,69 @@
},
"custom_properties": {
"description": "Custom properties",
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
"oneOf": [
{
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
{
"type": "object",
"properties": {
"include": {
"description": "Custom properties managed by safe-settings",
"type": "array",
"items": {
"description": "A custom property entry",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"exclude": {
"description": "Never clear the value of any custom property whose name matches one of these regexes",
"type": "array",
"items": {
"description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear",
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
}
},
"anyOf": [
{
"required": [
"include"
]
},
{
"required": [
"exclude"
]
}
]
}
}
]
},
"variables": {
"description": "Repository or org-level Actions variables",
Expand Down
Loading