Skip to content

Commit b731d54

Browse files
decyjphrCopilot
andcommitted
Address app installation review: remove bad drift handler, fix ordering, docs
- Remove installation.repositories_added/removed handler: an app only receives those events for its own installation, so they cannot detect drift on managed apps. Drift is reconciled by the scheduled full sync. - Process repository_unselection before repository_selection (delta and full sync) so a repo removed by one config and added by another ends up present - Add test asserting removal-before-addition ordering - Document app_installations in README (prerequisites, hierarchy, examples, sync behavior, drift note, disable/additive support) and add it to the configurable-items and disable_plugins lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4b2cec7 commit b731d54

4 files changed

Lines changed: 144 additions & 51 deletions

File tree

README.md

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,8 @@ plugins for a given scope. Each entry is either:
552552

553553
Valid plugin names: `repository`, `labels`, `collaborators`, `teams`,
554554
`milestones`, `branches`, `autolinks`, `validator`, `rulesets`, `environments`,
555-
`custom_properties`, `custom_repository_roles`, `variables`, `archive`.
555+
`custom_properties`, `custom_repository_roles`, `variables`, `archive`,
556+
`app_installations`.
556557

557558
#### Strip matrix (which source layers are removed before merge)
558559

@@ -661,6 +662,105 @@ additive_plugins:
661662
- collaborators
662663
```
663664

665+
### App installation management (`app_installations`)
666+
667+
Most safe-settings plugins target a **repository**. The `app_installations`
668+
plugin is different: its target is a **GitHub App installation**. It lets you
669+
declaratively manage *which repositories a GitHub App can access* (the app's
670+
`repository_selection`), using the same `org` → `suborg` → `repo` config
671+
hierarchy you already use for repository settings.
672+
673+
This is useful for controlling, as code, which repos apps such as Copilot,
674+
Dependabot, or your own internal apps are installed on across the org.
675+
676+
#### Prerequisites
677+
678+
- Safe-settings must be installed on the **enterprise** with the **Enterprise
679+
organization installations** permission (see the
680+
[Enterprise organization installations API](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations)).
681+
Managing app installations requires an enterprise-level token; the regular
682+
org installation token is not sufficient. If safe-settings is not installed
683+
on the enterprise with this permission, app installation sync is reported as
684+
an error and skipped.
685+
- The enterprise slug is read from the webhook event payload
686+
(`payload.enterprise.slug`); no extra environment variable is required.
687+
688+
#### How repository selection is resolved
689+
690+
The config layer where `app_installations` is declared determines which repos
691+
are selected for the app:
692+
693+
| Layer | File | Repos selected for the app |
694+
| --- | --- | --- |
695+
| Org | `settings.yml` | All repos in the org (`repository_selection: all`) |
696+
| Suborg | `suborgs/*.yml` | Repos matching the suborg's targeting (`suborgrepos`, `suborgteams`, `suborgproperties`) |
697+
| Repo | `repos/<repo>.yml` | That specific repo |
698+
699+
> [!important]
700+
> An app configured with `repository_selection: all` at the **org** level takes
701+
> precedence. Suborg/repo-level selections for that same app are ignored, and
702+
> repos are never removed from it by incremental (suborg/repo) changes — it is
703+
> reconciled only by the full (scheduled) sync.
704+
705+
#### Examples
706+
707+
Org-level `settings.yml` — give an app access to **all** repos in the org:
708+
709+
```yaml
710+
app_installations:
711+
- app_slug: my-internal-app
712+
repository_selection: all
713+
```
714+
715+
Suborg-level `suborgs/backend.yml` — give an app access to the repos targeted
716+
by this suborg (here, all repos with the `Team=backend` custom property):
717+
718+
```yaml
719+
suborgproperties:
720+
- Team: backend
721+
app_installations:
722+
- app_slug: my-internal-app
723+
```
724+
725+
Repo-level `repos/my-repo.yml` — add this specific repo to the app:
726+
727+
```yaml
728+
app_installations:
729+
- app_slug: my-internal-app
730+
```
731+
732+
Removing an app from a suborg/repo config (or changing the suborg's targeting)
733+
removes the affected repos from that app on the next sync, unless another layer
734+
still selects them.
735+
736+
#### Sync behavior
737+
738+
- **Incremental (delta) sync** runs when a `suborgs/*.yml` or `repos/*.yml`
739+
file changes. Only the apps affected by the changed file are reconciled: the
740+
previous version of the file is compared with the new one to compute repos to
741+
add (`repository_selection`) and repos to remove (`repository_unselection`).
742+
Removals are applied before additions, so a repo removed by one config and
743+
added by another ends up present.
744+
- **Full sync** runs on the schedule (cron), on manual sync, and when
745+
`settings.yml` changes. It recomputes the full desired state for every managed
746+
app across all layers and reconciles it against the live installation state.
747+
This is the mechanism that corrects any configuration drift.
748+
- Add/remove operations are automatically batched in chunks of 50 repos (the
749+
API limit).
750+
751+
> [!note]
752+
> Drift on managed apps is reconciled by the **full (cron) sync**, not by
753+
> webhooks. A GitHub App only receives `installation` repository events for its
754+
> *own* installation, so safe-settings cannot detect — via webhooks — when a
755+
> human changes another app's repository access. Keep the scheduled sync enabled
756+
> for timely drift correction.
757+
758+
#### Disabling and additive mode
759+
760+
`app_installations` honors both [`disable_plugins`](#disabling-plugins-disable_plugins)
761+
and [`additive_plugins`](#additive-plugins-additive_plugins). In additive mode
762+
the plugin only **adds** repos to installations and never removes them.
763+
664764
### The Settings Files
665765

666766
The settings files can be used to set the policies at the `org`, `suborg` or `repo` level.
@@ -680,6 +780,7 @@ The following can be configured:
680780
- `Repository name validation` using regex pattern
681781
- `Rulesets`
682782
- `Environments` - wait timer, required reviewers, prevent self review, protected branches deployment branch policy, custom deployment branch policy, variables, deployment protection rules
783+
- `App installations` - which repositories a GitHub App installation can access (see [App installation management](#app-installation-management-app_installations))
683784

684785
See [`docs/sample-settings/settings.yml`](docs/sample-settings/settings.yml) for a sample settings file.
685786

index.js

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -531,32 +531,16 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
531531
})
532532

533533
// ────────────────────────────────────────────────────────────────────────
534-
// App installation drift detection handlers
534+
// App installation target handler
535+
//
536+
// Note: We intentionally do NOT handle `installation.repositories_added` /
537+
// `installation.repositories_removed`. A GitHub App only receives those
538+
// events for its OWN installation, not for the managed apps (e.g. Copilot,
539+
// Dependabot) whose repository access safe-settings controls. They cannot
540+
// detect drift on managed apps, so drift is reconciled by the scheduled
541+
// (cron) full sync instead.
535542
// ────────────────────────────────────────────────────────────────────────
536543

537-
const installation_change_events = [
538-
'installation.repositories_added',
539-
'installation.repositories_removed'
540-
]
541-
542-
robot.on(installation_change_events, async context => {
543-
const { payload } = context
544-
const { sender } = payload
545-
robot.log.debug('App installation repos changed by ', JSON.stringify(sender))
546-
if (sender.type === 'Bot') {
547-
robot.log.debug('App installation repos changed by Bot')
548-
return
549-
}
550-
robot.log.debug('App installation repos changed by a Human — triggering sync to revert drift')
551-
552-
// Build a context that targets the admin repo for this org
553-
const orgLogin = payload.installation.account.login
554-
const updatedContext = Object.assign({}, context, {
555-
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
556-
})
557-
return syncAllSettings(false, updatedContext)
558-
})
559-
560544
robot.on('installation_target', async context => {
561545
const { payload } = context
562546
const { sender } = payload

lib/plugins/appInstallations.js

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,20 @@ class AppInstallations {
149149
continue
150150
}
151151

152-
// Resolve names to IDs for additions (need to look up IDs)
153-
if (toAdd.size > 0) {
154-
const addIds = await this._resolveRepoIds([...toAdd])
155-
await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
156-
this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
157-
}
158-
152+
// Resolve names to IDs. Process removals first, then additions, so a
153+
// repo that should be both removed (by one config) and added (by
154+
// another) ends up present.
159155
if (toRemove.size > 0) {
160156
const removeIds = [...toRemove].map(name => liveRepoMap.get(name)).filter(Boolean)
161157
await this.enterpriseClient.removeReposFromInstallation(desired.installation_id, removeIds)
162158
this.log.debug(`App '${appSlug}': removed ${removeIds.length} repos`)
163159
}
160+
161+
if (toAdd.size > 0) {
162+
const addIds = await this._resolveRepoIds([...toAdd])
163+
await this.enterpriseClient.addReposToInstallation(desired.installation_id, addIds)
164+
this.log.debug(`App '${appSlug}': added ${addIds.length} repos`)
165+
}
164166
} catch (e) {
165167
this.log.error(`Error in full sync for app '${appSlug}': ${e.message}`)
166168
this.errors.push({
@@ -254,18 +256,18 @@ class AppInstallations {
254256
return results
255257
}
256258

257-
if (hasSelections) {
258-
const addIds = await this._resolveRepoIds([...repository_selection])
259-
await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
260-
this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
261-
}
262-
263259
if (hasUnselections) {
264260
const removeIds = await this._resolveRepoIds([...repository_unselection])
265261
await this.enterpriseClient.removeReposFromInstallation(installation_id, removeIds)
266262
this.log.debug(`App '${app_slug}': removed ${removeIds.length} repos`)
267263
}
268264

265+
if (hasSelections) {
266+
const addIds = await this._resolveRepoIds([...repository_selection])
267+
await this.enterpriseClient.addReposToInstallation(installation_id, addIds)
268+
this.log.debug(`App '${app_slug}': added ${addIds.length} repos`)
269+
}
270+
269271
return results
270272
}
271273

test/unit/lib/plugins/appInstallations.test.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,32 @@ describe('AppInstallations', () => {
107107
expect(result[0].action.deletions).toBeNull()
108108
})
109109

110-
it('adds repos via enterprise client in non-nop mode', async () => {
111-
github.repos.get
112-
.mockResolvedValueOnce({ data: { id: 100 } })
113-
.mockResolvedValueOnce({ data: { id: 200 } })
110+
it('processes unselections before selections in non-nop mode', async () => {
111+
// repo-a (add) resolves to id 100; repo-b (remove) resolves to id 200
112+
github.repos.get.mockImplementation(({ repo }) => {
113+
if (repo === 'repo-a') return Promise.resolve({ data: { id: 100 } })
114+
if (repo === 'repo-b') return Promise.resolve({ data: { id: 200 } })
115+
return Promise.resolve({ data: { id: 0 } })
116+
})
117+
118+
const callOrder = []
119+
appGithub.request.mockImplementation((route) => {
120+
if (route.startsWith('DELETE')) callOrder.push('remove')
121+
if (route.startsWith('POST')) callOrder.push('add')
122+
return Promise.resolve({ data: {} })
123+
})
114124

115125
const plugin = new AppInstallations(false, github, appGithub, { owner: 'org', repo: 'admin' }, 'ent', log, errors)
116126
await plugin.syncDelta([{
117127
app_slug: 'copilot',
118128
installation_id: 1,
119-
repository_selection: new Set(['repo-a', 'repo-b']),
120-
repository_unselection: new Set()
129+
repository_selection: new Set(['repo-a']),
130+
repository_unselection: new Set(['repo-b'])
121131
}])
122132

123-
// Should have called request to add repos
124-
expect(appGithub.request).toHaveBeenCalledWith(
125-
expect.stringContaining('POST'),
126-
expect.objectContaining({
127-
repository_ids: [100, 200]
128-
})
129-
)
133+
// Removal must be applied before addition so a repo removed by one
134+
// config and added by another ends up present.
135+
expect(callOrder).toEqual(['remove', 'add'])
130136
})
131137
})
132138

0 commit comments

Comments
 (0)