Skip to content
Draft
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
45 changes: 45 additions & 0 deletions docs/sample-settings/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,55 @@ branches:

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

# The object form additionally protects properties from being cleared:
#
# custom_properties:
# include:
# - name: test
# value: test
# exclude:
# # Regexes - NOT globs - matched against the property name. Casing does not
# # matter; patterns are lowercased before matching.
# # Never clear the value of any property starting with "app-":
# - name: ^app-
# # Never clear the value of the "deploy-status" property:
# - name: ^deploy-status$
#
# To manage only the properties you declare and leave every other property on the
# repository untouched, exclude everything with `.*`:
#
# custom_properties:
# include:
# - name: test
# value: test
# exclude:
# - name: .*
#
# Notes:
# * These are regexes, so "match everything" is `.*`. A bare `*` is not a valid
# regex; see the invalid-pattern note below for what happens if you use one.
# * `exclude` only prevents removal. A property listed in `include` is always
# created/updated, even if it also matches an `exclude` pattern.
# * `exclude` on its own (no `include`) still means "safe-settings manages the
# custom properties on this repo" - every property not matching a pattern is
# cleared. To manage nothing, omit the `custom_properties` section entirely.
# * An invalid regex fails closed: the error is reported and every property on
# that repo is excluded, so a typo protects values rather than clearing them.
# Properties in `include` are still applied.
# * 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
98 changes: 96 additions & 2 deletions lib/plugins/custom_properties.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,105 @@
const Diffable = require('./diffable')
const NopCommand = require('../nopcommand')

// Detects the `{ include: [...], exclude: [...] }` config shape. Either key on
// its own is enough; a missing `include` is treated as an empty include list.
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) {
// Two config shapes are supported:
//
// custom_properties: | custom_properties:
// - name: jira-team | include:
// value: Platform | - name: jira-team
// | value: Platform
// | exclude:
// | - name: ^app-
//
// The object shape lets a config declare the properties it manages
// (`include`) while protecting properties owned by other automation
// (`exclude`) from being nulled out. The plain array shape is unchanged.
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 the array shape nor the include/exclude shape - for example
// `custom_properties: {}`. The schemas reject this, but they are authoring
// aids rather than a runtime gate, so the plugin cannot rely on them. Treat
// it as "manage nothing and clear nothing" instead of letting a downstream
// TypeError escape the constructor, which would reject the org-wide
// Promise.all in Settings.updateRepos and abort the sync for every repo.
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()
}
}

// Compile the `exclude[].name` regex strings, skipping entries without a
// string name. An unparseable pattern is recorded as a config error for this
// repo rather than thrown: child plugins are constructed outside of any
// try/catch in `Settings.updateRepos`, so throwing here would reject the
// org-wide `Promise.all` and abort the sync for every other repo too.
//
// An invalid pattern fails closed - every property on the repo is treated as
// excluded. A typo in an exclude pattern is a request to protect something,
// so the safe reading is "protect everything until the config is fixed"
// rather than "protect nothing", which would clear the very values the
// pattern was written to defend. Properties in `include` are still enforced.
compileExcludePatterns (exclude) {
return exclude.reduce((state, item) => {
if (!item || typeof item.name !== 'string') {
return state
}

try {
// Property names are lowercased before matching, so lowercase the
// pattern too - an uppercase pattern would otherwise be a valid regex
// that silently matches nothing and clears the properties it 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 })
}

// Exclude patterns are matched against the normalized (lowercased) property
// name. Patterns are lowercased when compiled, so config casing does not
// matter. Note these are regexes, not globs: `.*` matches everything, `*` is
// not a valid pattern.
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 +180,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