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
103 changes: 102 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,8 @@ plugins for a given scope. Each entry is either:

Valid plugin names: `repository`, `labels`, `collaborators`, `teams`,
`milestones`, `branches`, `autolinks`, `validator`, `rulesets`, `environments`,
`custom_properties`, `custom_repository_roles`, `variables`, `archive`.
`custom_properties`, `custom_repository_roles`, `variables`, `archive`,
`app_installations`.

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

Expand Down Expand Up @@ -661,6 +662,105 @@ additive_plugins:
- collaborators
```

### App installation management (`app_installations`)

Most safe-settings plugins target a **repository**. The `app_installations`
plugin is different: its target is a **GitHub App installation**. It lets you
declaratively manage *which repositories a GitHub App can access* (the app's
`repository_selection`), using the same `org` → `suborg` → `repo` config
hierarchy you already use for repository settings.

This is useful for controlling, as code, which repos apps such as Copilot,
Dependabot, or your own internal apps are installed on across the org.

#### Prerequisites

- Safe-settings must be installed on the **enterprise** with the **Enterprise
organization installations** permission (see the
[Enterprise organization installations API](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations)).
Managing app installations requires an enterprise-level token; the regular
org installation token is not sufficient. If safe-settings is not installed
on the enterprise with this permission, app installation sync is reported as
an error and skipped.
- The enterprise slug is read from the webhook event payload
(`payload.enterprise.slug`); no extra environment variable is required.

#### How repository selection is resolved

The config layer where `app_installations` is declared determines which repos
are selected for the app:

| Layer | File | Repos selected for the app |
| --- | --- | --- |
| Org | `settings.yml` | All repos in the org (`repository_selection: all`) |
| Suborg | `suborgs/*.yml` | Repos matching the suborg's targeting (`suborgrepos`, `suborgteams`, `suborgproperties`) |
| Repo | `repos/<repo>.yml` | That specific repo |

> [!important]
> An app configured with `repository_selection: all` at the **org** level takes
> precedence. Suborg/repo-level selections for that same app are ignored, and
> repos are never removed from it by incremental (suborg/repo) changes — it is
> reconciled only by the full (scheduled) sync.

#### Examples

Org-level `settings.yml` — give an app access to **all** repos in the org:

```yaml
app_installations:
- app_slug: my-internal-app
repository_selection: all
```

Suborg-level `suborgs/backend.yml` — give an app access to the repos targeted
by this suborg (here, all repos with the `Team=backend` custom property):

```yaml
suborgproperties:
- Team: backend
app_installations:
- app_slug: my-internal-app
```

Repo-level `repos/my-repo.yml` — add this specific repo to the app:

```yaml
app_installations:
- app_slug: my-internal-app
```

Removing an app from a suborg/repo config (or changing the suborg's targeting)
removes the affected repos from that app on the next sync, unless another layer
still selects them.

#### Sync behavior

- **Incremental (delta) sync** runs when a `suborgs/*.yml` or `repos/*.yml`
file changes. Only the apps affected by the changed file are reconciled: the
previous version of the file is compared with the new one to compute repos to
add (`repository_selection`) and repos to remove (`repository_unselection`).
Removals are applied before additions, so a repo removed by one config and
added by another ends up present.
- **Full sync** runs on the schedule (cron), on manual sync, and when
`settings.yml` changes. It recomputes the full desired state for every managed
app across all layers and reconciles it against the live installation state.
This is the mechanism that corrects any configuration drift.
- Add/remove operations are automatically batched in chunks of 50 repos (the
API limit).

> [!note]
> Drift on managed apps is reconciled by the **full (cron) sync**, not by
> webhooks. A GitHub App only receives `installation` repository events for its
> *own* installation, so safe-settings cannot detect — via webhooks — when a
> human changes another app's repository access. Keep the scheduled sync enabled
> for timely drift correction.

#### Disabling and additive mode

`app_installations` honors both [`disable_plugins`](#disabling-plugins-disable_plugins)
and [`additive_plugins`](#additive-plugins-additive_plugins). In additive mode
the plugin only **adds** repos to installations and never removes them.

### The Settings Files

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

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

Expand Down
81 changes: 81 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let deploymentConfig

module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
let cachedEnterpriseInstallationId = null
async function syncAllSettings (nop, context, repo = context.repo(), ref, baseRef, changedFiles = {}) {
try {
deploymentConfig = await loadYamlFileSystem()
Expand All @@ -21,6 +22,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)

// Enrich context with enterprise info for app installation management
await enrichContextWithEnterprise(context)

// Load base branch config for NOP filtering (only show PR-introduced changes)
let baseConfig = null
if (nop && baseRef) {
Expand Down Expand Up @@ -88,6 +92,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)

// Enrich context with enterprise info for app installation management
await enrichContextWithEnterprise(context)

// Load base branch config for NOP filtering (only show PR-introduced changes)
let baseConfig = null
if (nop && baseRef) {
Expand Down Expand Up @@ -142,6 +149,47 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}
/**
* Enriches the context with enterprise info for app installation management.
* Extracts enterprise slug from the webhook payload, finds the enterprise
* installation from the app's installation list, and creates an Octokit
* client authenticated with the enterprise installation token.
*
* @param {object} context - Probot context
*/
async function enrichContextWithEnterprise (context) {
const { payload } = context
const enterprise = payload.enterprise || (payload.installation && payload.installation.enterprise)
if (enterprise && enterprise.slug) {
context.enterpriseSlug = enterprise.slug
try {
// Use cached enterprise installation ID if available
if (cachedEnterpriseInstallationId) {
context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
return
}

// Get a JWT-authenticated client to list all installations
const appGithub = await robot.auth()
const installations = await appGithub.paginate(
appGithub.apps.listInstallations.endpoint.merge({ per_page: 100 })
)
// Find the installation targeting this enterprise
const enterpriseInstallation = installations.find(
i => i.target_type === 'Enterprise' && i.account && i.account.slug === enterprise.slug
)
if (enterpriseInstallation) {
cachedEnterpriseInstallationId = enterpriseInstallation.id
context.appGithub = await robot.auth(cachedEnterpriseInstallationId)
} else {
robot.log.debug(`No enterprise installation found for slug '${enterprise.slug}'. App installation management will not be available.`)
}
} catch (e) {
robot.log.debug(`Could not create enterprise-authenticated client: ${e.message}`)
}
}
}

/**
* Loads the deployment config file from file system
* Do this once when the app starts and then return the cached value
Expand Down Expand Up @@ -482,6 +530,39 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
})

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

robot.on('installation_target', async context => {
const { payload } = context
const { sender } = payload
robot.log.debug('Installation target changed by ', JSON.stringify(sender))
if (sender.type === 'Bot') {
robot.log.debug('Installation target changed by Bot')
return
}
robot.log.debug('Installation target changed by a Human — triggering sync to revert drift')

const orgLogin = (payload.organization && payload.organization.login) ||
(payload.installation && payload.installation.account && payload.installation.account.login)
if (!orgLogin) {
robot.log.debug('Could not determine org login from installation_target event, skipping')
return
}
const updatedContext = Object.assign({}, context, {
repo: () => { return { repo: env.ADMIN_REPO, owner: orgLogin } }
})
return syncAllSettings(false, updatedContext)
})

robot.on('check_suite.requested', async context => {
const { payload } = context
const { repository } = payload
Expand Down
Loading
Loading