diff --git a/docs/github-settings/4. teams.md b/docs/github-settings/4. teams.md
index 565398a1e..523bc49c8 100644
--- a/docs/github-settings/4. teams.md
+++ b/docs/github-settings/4. teams.md
@@ -61,5 +61,33 @@ teams:
external_group: "Engineering - Expert Services"
```
+
+
|
+ exclude array
+Exclude a list of repos for this team. The team is applied to every repo in scope except those whose names match one of these glob patterns.
+ |
+
+```yaml
+teams:
+ - name: SuperFriends
+ permission: maintain
+ exclude:
+ - secret-repo
+```
+
+ |
+|
+ include array
+Include a list of repos for this team. The team is applied only to repos whose names match one of these glob patterns.
+ |
+
+```yaml
+teams:
+ - name: SuperFriends
+ permission: maintain
+ include:
+ - public-*
+```
+
|
diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml
index 85f50df9d..7f57d55a7 100644
--- a/docs/sample-settings/settings.yml
+++ b/docs/sample-settings/settings.yml
@@ -161,6 +161,16 @@ teams:
- name: globalteam
permission: push
visibility: closed
+ - name: docs-team
+ permission: pull
+ # You can include a list of repos for this team and only those repos would have this team
+ include:
+ - actions-demo
+ - name: ops-team
+ permission: push
+ # You can exclude a list of repos for this team and all repos except these repos would have this team
+ exclude:
+ - actions-demo
# Branch protection rules
# See https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2022-11-28#update-branch-protection for available options
diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json
index d4955ad26..fd500ae60 100644
--- a/schema/dereferenced/settings.json
+++ b/schema/dereferenced/settings.json
@@ -419,62 +419,54 @@
"description": "Teams",
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "The name of the team."
- },
- "description": {
- "type": "string",
- "description": "The description of the team."
- },
- "maintainers": {
- "type": "array",
- "description": "List GitHub usernames for organization members who will become team maintainers.",
- "items": {
- "type": "string"
- }
- },
- "repo_names": {
- "type": "array",
- "description": "The full name (e.g., \"organization-name/repository-name\") of repositories to add the team to.",
- "items": {
- "type": "string"
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "permission": {
+ "type": "string",
+ "description": "The permission to grant the team on this repository. We accept the following permissions to be set: `pull`, `triage`, `push`, `maintain`, `admin` and you can also specify a custom repository role name, if the owning organization has defined any. If no permission is specified, the team's `permission` attribute will be used to determine what permission to grant the team on this repository."
+ }
}
},
- "privacy": {
- "type": "string",
- "description": "The level of privacy this team should have. The options are: \n**For a non-nested team:** \n * `secret` - only visible to organization owners and members of this team. \n * `closed` - visible to all members of this organization. \nDefault: `secret` \n**For a parent or child team:** \n * `closed` - visible to all members of this organization. \nDefault for child team: `closed`",
- "enum": [
- "secret",
- "closed"
- ]
- },
- "notification_setting": {
- "type": "string",
- "description": "The notification setting the team has chosen. The options are: \n * `notifications_enabled` - team members receive notifications when the team is @mentioned. \n * `notifications_disabled` - no one receives notifications. \nDefault: `notifications_enabled`",
- "enum": [
- "notifications_enabled",
- "notifications_disabled"
- ]
- },
- "permission": {
- "type": "string",
- "description": "**Closing down notice**. The permission that new repositories will be added to the team with when none is specified.",
- "enum": [
- "pull",
- "push"
+ {
+ "type": "object",
+ "required": [
+ "name"
],
- "default": "pull"
- },
- "parent_team_id": {
- "type": "integer",
- "description": "The ID of a team to set as the parent team."
+ "properties": {
+ "name": {
+ "description": "The name (or slug) of the team to grant access to the repo.",
+ "type": "string"
+ },
+ "privacy": {
+ "description": "The level of privacy the team should have when it is created by safe-settings. The options are `secret` (visible only to organization owners and members of the team) or `closed` (visible to all members of the organization).",
+ "type": "string",
+ "enum": [
+ "secret",
+ "closed"
+ ]
+ },
+ "external_group": {
+ "description": "The display name of an external IdP group to link to this team.",
+ "type": "string"
+ },
+ "exclude": {
+ "description": "Exclude this team from repos whose names match one of these glob patterns.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "include": {
+ "description": "Apply this team only to repos whose names match one of these glob patterns.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
}
- },
- "required": [
- "name"
]
}
},
@@ -1223,6 +1215,49 @@
"type": "boolean",
"description": "New, reviewable commits pushed will dismiss previous pull request review approvals."
},
+ "dismissal_restriction": {
+ "title": "DismissalRestriction",
+ "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.",
+ "type": "object",
+ "properties": {
+ "allowed_actors": {
+ "type": "array",
+ "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.",
+ "items": {
+ "title": "Actor",
+ "description": "An actor allowed to dismiss pull request reviews",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the actor that can dismiss reviews."
+ },
+ "type": {
+ "type": "string",
+ "description": "The type of the actor",
+ "enum": [
+ "User",
+ "Team",
+ "IntegrationInstallation",
+ "RepositoryRole"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "type"
+ ]
+ }
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether to restrict review dismissal to specific actors."
+ }
+ },
+ "required": [
+ "enabled"
+ ]
+ },
"require_code_owner_review": {
"type": "boolean",
"description": "Require an approving review in pull requests that modify files that have a designated code owner."
@@ -1937,8 +1972,25 @@
}
}
},
+ "app_installations": {
+ "description": "Manage which repositories a GitHub App installation can access. The target is a GitHub App installation rather than a repository. Repo selection follows the config hierarchy: org-level settings.yml selects all repos in the org; suborgs/*.yml selects repos by the suborg's targeting criteria; repos/*.yml adds the specific repo. Requires safe-settings to be installed on the enterprise with 'Enterprise organization installations' permission.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "app_slug"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_slug": {
+ "type": "string",
+ "description": "The slug of the GitHub App installation to manage."
+ }
+ }
+ }
+ },
"additive_plugins": {
- "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
+ "description": "List of plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Supported plugins: labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles, app_installations. Declare only in settings.yml (org level) to keep behavior consistent across all repos.",
"type": "array",
"items": {
"type": "string",
@@ -1952,7 +2004,8 @@
"custom_properties",
"variables",
"rulesets",
- "custom_repository_roles"
+ "custom_repository_roles",
+ "app_installations"
]
}
},
@@ -1977,7 +2030,8 @@
"custom_properties",
"custom_repository_roles",
"variables",
- "archive"
+ "archive",
+ "app_installations"
]
},
{
@@ -2003,7 +2057,8 @@
"custom_properties",
"custom_repository_roles",
"variables",
- "archive"
+ "archive",
+ "app_installations"
]
},
"target": {
diff --git a/schema/settings.json b/schema/settings.json
index 02c8b36fd..2580ecff1 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -136,7 +136,49 @@
"description": "Teams",
"type": "array",
"items": {
- "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1teams/post/requestBody/content/application~1json/schema"
+ "allOf": [
+ {
+ "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1teams~1{team_slug}~1repos~1{owner}~1{repo}/put/requestBody/content/application~1json/schema"
+ },
+ {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "description": "The name (or slug) of the team to grant access to the repo.",
+ "type": "string"
+ },
+ "privacy": {
+ "description": "The level of privacy the team should have when it is created by safe-settings. The options are `secret` (visible only to organization owners and members of the team) or `closed` (visible to all members of the organization).",
+ "type": "string",
+ "enum": [
+ "secret",
+ "closed"
+ ]
+ },
+ "external_group": {
+ "description": "The display name of an external IdP group to link to this team.",
+ "type": "string"
+ },
+ "exclude": {
+ "description": "Exclude this team from repos whose names match one of these glob patterns.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "include": {
+ "description": "Apply this team only to repos whose names match one of these glob patterns.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
}
},
"branches": {
diff --git a/smoke-test.js b/smoke-test.js
index 2b4d9dba7..30ef1a643 100644
--- a/smoke-test.js
+++ b/smoke-test.js
@@ -70,8 +70,11 @@ const CONFIG_PATH = process.env.CONFIG_PATH || '.github'
const APP_ID = process.env.APP_ID
const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n')
-const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo']
+const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo', 'smoke-team-filter']
const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers', 'jefeish-edj-test']
+// Teams exercised by the team include/exclude filter phase (Phase 18).
+const SMOKE_FILTER_REPO = 'smoke-team-filter'
+const SMOKE_FILTER_TEAMS = ['smoke-filter-included', 'smoke-filter-excluded', 'smoke-filter-nomatch']
// Principals created on demand for the ruleset name-resolution phase (Phase 16)
const SMOKE_NR_TEAM = 'safe-settings-smoke-nr-team'
@@ -1206,6 +1209,34 @@ const REPO_YML_NO_VARS = `repository:
variables: []
`
+// Config for Phase 18: a repo-level config whose team entries carry
+// include/exclude repo filters. Only the team whose include glob matches the
+// repo name (or that is not excluded) should be applied by safe-settings.
+// - smoke-filter-included: include matches -> applied
+// - smoke-filter-excluded: exclude matches -> NOT applied
+// - smoke-filter-nomatch : include does not match -> NOT applied
+const REPO_TEAM_FILTER_YML = `repository:
+ name: ${SMOKE_FILTER_REPO}
+ description: Repo for team include/exclude smoke test
+ private: true
+ auto_init: true
+ force_create: true
+
+teams:
+ - name: ${SMOKE_FILTER_TEAMS[0]}
+ permission: maintain
+ include:
+ - ${SMOKE_FILTER_REPO}
+ - name: ${SMOKE_FILTER_TEAMS[1]}
+ permission: pull
+ exclude:
+ - ${SMOKE_FILTER_REPO}
+ - name: ${SMOKE_FILTER_TEAMS[2]}
+ permission: pull
+ include:
+ - no-such-repo-*
+`
+
// ─── Test Phases ─────────────────────────────────────────────────────────────
async function setup () {
@@ -2471,6 +2502,7 @@ async function teardown () {
log('Deleting test teams...')
for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) }
try { await deleteTeam(ORG, SMOKE_NR_TEAM) } catch { /* ok */ }
+ for (const team of SMOKE_FILTER_TEAMS) { try { await deleteTeam(ORG, team) } catch { /* ok */ } }
log('Deleting custom repository role...')
try { await deleteCustomRepositoryRole(ORG, 'security-engineer') } catch { /* ok */ }
@@ -3095,6 +3127,64 @@ async function phase17AppInstallations () {
// ─── Main ────────────────────────────────────────────────────────────────────
+async function phase18TeamIncludeExclude () {
+ logPhase('Phase 18: Team include/exclude repo filters')
+ const branch = 'smoke-test-phase18'
+ const defaultBranch = await getDefaultBranch()
+
+ // Clean any leftover teams from a previous aborted run so the "not applied"
+ // assertions can't be satisfied by stale repo-team associations.
+ await deleteRepo(ORG, SMOKE_FILTER_REPO)
+ for (const t of SMOKE_FILTER_TEAMS) { await deleteTeam(ORG, t) }
+
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+ await createBranch(ORG, ADMIN_REPO, branch)
+ await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/${SMOKE_FILTER_REPO}.yml`, REPO_TEAM_FILTER_YML, branch, 'Add team include/exclude filter config')
+
+ const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: team include/exclude filters', branch, defaultBranch)
+ log('Waiting for NOP check run...')
+ await sleep(WEBHOOK_SETTLE_MS)
+ const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha)
+ assert(checkRun !== null, 'Check run completed')
+ if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`)
+
+ if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return
+ await sleep(WEBHOOK_SETTLE_MS)
+
+ const repo = await poll(async () => {
+ try { return (await octokit.rest.repos.get({ owner: ORG, repo: SMOKE_FILTER_REPO })).data } catch { return null }
+ }, { desc: `${SMOKE_FILTER_REPO} to be created` })
+ assert(repo !== null, `Repo "${SMOKE_FILTER_REPO}" was created`)
+
+ // The included team should be applied (poll — safe-settings may still be working).
+ const includedTeam = await poll(async () => {
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: SMOKE_FILTER_REPO })
+ return teams.find(t => t.slug === SMOKE_FILTER_TEAMS[0]) || null
+ } catch { return null }
+ }, { desc: `included team ${SMOKE_FILTER_TEAMS[0]} to be added`, timeout: 60000 })
+ assert(includedTeam !== null, `Team "${SMOKE_FILTER_TEAMS[0]}" applied (include glob matches repo)`)
+ if (includedTeam) {
+ const hasMaintain = includedTeam.permission === 'maintain' || (includedTeam.permissions && includedTeam.permissions.maintain === true)
+ assert(hasMaintain, `Included team has maintain permission (got: ${includedTeam.permission})`)
+ }
+
+ // The excluded and non-matching teams must NOT be applied.
+ const finalTeams = await (async () => {
+ try {
+ const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: SMOKE_FILTER_REPO })
+ return teams.map(t => t.slug)
+ } catch { return [] }
+ })()
+ assert(!finalTeams.includes(SMOKE_FILTER_TEAMS[1]), `Team "${SMOKE_FILTER_TEAMS[1]}" NOT applied (exclude glob matches repo)`)
+ assert(!finalTeams.includes(SMOKE_FILTER_TEAMS[2]), `Team "${SMOKE_FILTER_TEAMS[2]}" NOT applied (include glob does not match repo)`)
+
+ // Cleanup for standalone --phase 18 runs (teardown also cleans these).
+ await deleteRepo(ORG, SMOKE_FILTER_REPO)
+ for (const t of SMOKE_FILTER_TEAMS) { await deleteTeam(ORG, t) }
+ await deleteBranch(ORG, ADMIN_REPO, branch)
+}
+
async function main () {
const { App } = await import('octokit')
const app = new App({ appId: APP_ID, privateKey: PRIVATE_KEY })
@@ -3162,7 +3252,8 @@ async function main () {
['Phase 14: regressions', phase14RegressionCoverage],
['Phase 15: Ruleset array drift', phase15RulesetArrayDrift],
['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution],
- ['Phase 17: App installation management', phase17AppInstallations]
+ ['Phase 17: App installation management', phase17AppInstallations],
+ ['Phase 18: Team include/exclude filters', phase18TeamIncludeExclude]
]
// When --phase is given, only run setup (phase 0) + the requested phase(s).
diff --git a/test/unit/lib/plugins/teams.test.js b/test/unit/lib/plugins/teams.test.js
index 8879611c3..f7c599d8b 100644
--- a/test/unit/lib/plugins/teams.test.js
+++ b/test/unit/lib/plugins/teams.test.js
@@ -97,6 +97,53 @@ describe('Teams', () => {
}
})
+ describe('filtering teams by include/exclude', () => {
+ beforeEach(() => {
+ github.repos.listTeams.mockResolvedValue({ data: [] })
+ })
+
+ it('does not add a team when the repo matches an exclude glob', async () => {
+ const plugin = configure([
+ { name: addedTeamName, permission: 'pull', exclude: ['test*'] }
+ ])
+
+ await plugin.sync()
+
+ expect(github.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled()
+ })
+
+ it('does not add a team when the repo is not in an include glob', async () => {
+ const plugin = configure([
+ { name: addedTeamName, permission: 'pull', include: ['other-*'] }
+ ])
+
+ await plugin.sync()
+
+ expect(github.teams.addOrUpdateRepoPermissionsInOrg).not.toHaveBeenCalled()
+ })
+
+ it('adds a team when the repo matches an include glob', async () => {
+ when(github.teams.getByName)
+ .calledWith({ org, team_slug: addedTeamName })
+ .mockResolvedValue({ data: { id: addedTeamId } })
+
+ const plugin = configure([
+ { name: addedTeamName, permission: 'pull', include: ['test*'] }
+ ])
+
+ await plugin.sync()
+
+ expect(github.teams.addOrUpdateRepoPermissionsInOrg).toHaveBeenCalledWith({
+ org,
+ team_id: addedTeamId,
+ team_slug: addedTeamName,
+ owner: org,
+ repo: 'test',
+ permission: 'pull'
+ })
+ })
+ })
+
describe('external_group linking', () => {
const externalGroupName = 'Engineering - Expert Services'
const externalGroupId = 42