github-community-projects / github-community-projects/safe-settings
App Installation Plugin — Manage GitHub App installation settings via safe-settings
- Dominant language
- JavaScript
- Stars
- 921
- Forks
- 226
- Avg merge
- 18h 3m
- Merged PRs (30d)
- 14
Description
## Summary
Add a new plugin system to safe-settings where the **target** of the operation is a **GitHub App installation** rather than a repository. This enables managing which repos an app has access to (`repository_selection`) through the same config hierarchy (org → suborg → repo) that safe-settings already uses for repo-level settings.
## Motivation
Currently, safe-settings manages settings applied to repositories. However, managing GitHub App installations (which repos an app can access) is a common organizational need that is currently unmanaged and subject to configuration drift. This feature brings app installation management under the same declarative, drift-correcting model.
Additionally, this design lays groundwork for future enhancements where the target is neither a repo nor an app (e.g., Copilot policies).
## Decisions
- **Enterprise auth**: safe-settings must be installed on the enterprise with required permissions as a prerequisite. If not, report as an error. Reuses the existing app credentials (private key, app ID) already available in the Probot runtime.
- **Enterprise slug**: Extracted from webhook event payloads (e.g., `payload.enterprise.slug`) — no env var needed.
- **Sync phase**: `syncAppInstallations()` is a **separate phase** (not inside `updateOrg()`).
- **Repo selection criteria**: Fixed set only — **name**, **team**, **custom properties** to start.
- **Conflict resolution**: Org-level "all repos" **takes precedence** over suborg/repo-level exclusions.
- **Delta-based processing**: Only compute changes from the affected config files. Full recomputation only on cron/manual full sync.
## Design
### Config Hierarchy & Repository Selection
```yaml
# settings.yml (org-level)
app_installations:
- app_slug: copilot
repository_selection: all
- app_slug: dependabot
repository_selection: all
# suborgs/team-a.yml
app_installations:
- app_slug: copilot
# repos selected by suborg criteria (suborgproperties, suborgteams, suborgrepos)
# repos/my-repo.yml
app_installations:
- app_slug: copilot
# this specific repo is added to the app
```
### Config Resolution — Delta-Based Approach
**Efficiency goal**: Only process apps that are "marked for change" (appear in changed config files). Compute only the DELTA from changed files — never recompute all suborgs or all repos for an incremental change.
Each changed config file produces per-app:
- `repository_selection` — repos to ADD to the app installation
- `repository_unselection` — repos to REMOVE from the app installation
Only apps with non-empty selection/unselection are "marked for change" and processed.
#### Per config level:
**1. Org-level (`settings.yml` changes)**:
- Apps present with `repository_selection: all` → mark for change, set to "all" (no repo enumeration — use API's native "all" selection)
- Apps removed from settings.yml (compared to previous version) → `repository_selection: []` (empty, triggering full removal via live state comparison)
**2. Suborg-level (`suborgs/*.yml` changes)**:
- Resolve ONLY the changed suborg's repos (not all suborgs) — uses existing suborg targeting (teams/properties/names)
- `repository_selection` = repos from this suborg's current targeting
- `repository_unselection` = repos removed from this suborg's targeting (load previous version of THIS file only — 1 API call — reuses `getReposRemovedFromSubOrgTargeting` pattern)
- Cost: 1 API call for previous file + resolution of 1 suborg
**3. Repo-level (`repos/*.yml` changes)**:
- `repository_selection` = this repo name (if app is in current config)
- `repository_unselection` = this repo name (if app was removed — load previous version of THIS file — 1 API call)
- Cost: 1 API call for previous file, no resolution needed
**4. Full sync (cron/manual)**:
- Full desired-state recomputation for all managed apps across all config layers
- Compare against live API state
- This is the only time the expensive full computation runs
#### Merge and process:
- Collect all per-app `repository_selection` / `repository_unselection` across changed files
- Merge: union selections, union unselections
- Org-level "all" takes precedence (overrides any unselections)
- Process only apps marked for change
- Batch add/remove in chunks of 50
### Architecture Components
#### 1. `lib/plugins/appInstallations.js` — Plugin class
A new plugin (not extending Diffable) that:
- Accepts per-app `repository_selection` and `repository_unselection` (pre-computed)
- `sync()` — Calls enterprise API to add/remove repos in batches of 50
- For full sync mode: `find()` gets live state, compares with full desired state
- **`disable_plugins` support**: Can be disabled at any layer. When disabled, the plugin is skipped.
- **`additive_plugins` support**: When listed, suppresses `repository_unselection` (only adds, never removes).
#### 2. `lib/appOctokitClient.js` — Enterprise App Token Client
- **Prerequisite**: safe-settings must be installed on the enterprise with "Enterprise organization installations" permission. If not, surface a clear error.
- **Enterprise slug**: From webhook payload (`payload.enterprise.slug`)
- Reuses existing Probot app credentials (private key, app ID)
- Calls [Enterprise Organization Installations API](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/organization-installations?apiVersion=2026-03-10)
- Methods: `addReposToInstallation(installationId, repoIds)`, `removeReposFromInstallation(installationId, repoIds)`, `listInstallationRepos(installationId)`
- **Auto-batching**: Splits repo lists into chunks of 50
#### 3. `lib/repoSelector.js` — Repo Selection Utility
Resolves repos from **fixed criteria only**:
- **Name** — explicit repo names
- **Team** — repos belonging to a team (reuses `getReposForTeam`)
- **Custom properties** — repos matching property values (reuses `getRepositoriesByProperty`)
- **All** — all repos (org-level takes precedence)
#### 4. Changes to `lib/settings.js`
- Add `syncAppInstallations` as a **separate phase**
- Extract affected apps from changed config files
- Compute `repository_selection` / `repository_unselection` per app (delta only)
- Merge across layers, respecting org "all" precedence
- Call plugin to reconcile
- **`disable_plugins`/`additive_plugins`**: Gated via strip map and additive set
#### 5. Changes to `index.js` — Webhook Handlers
- Add [`installation`](https://docs.github.com/en/enterprise-cloud@latest/webhooks/webhook-events-and-payloads#installation) and `installation_target` webhook event handlers
- On drift (human changes app repo access), trigger sync to revert
### Future-Proofing: Target Abstraction
```
Target {
type: 'repo' | 'app_installation' | 'copilot_policy' | ...
getDesiredState(config, context)
getCurrentState(octokit)
reconcile(desired, current, octokit)
}
```
For now, implement only `AppInstallationTarget`. Existing repo-targeting stays as-is but could adopt this pattern later.
## Implementation Order
1. `lib/repoSelector.js` — Repo selection utility (name, team, custom properties)
2. `lib/appOctokitClient.js` — Enterprise Octokit client (payload slug, batching at 50)
3. `lib/plugins/appInstallations.js` — Plugin (delta + full sync modes, disable/additive aware)
4. `lib/settings.js` — Wire as separate phase with delta-based processing
5. `index.js` — Webhook handlers (`installation`, `installation_target`)
6. Unit tests
## File Changes Summary
| File | Change |
|------|--------|
| `lib/plugins/appInstallations.js` | **New** — App installation plugin |
| `lib/appOctokitClient.js` | **New** — Enterprise Octokit client (auto-batching) |
| `lib/repoSelector.js` | **New** — Repo selection utility |
| `lib/settings.js` | Add `syncAppInstallations` separate phase |
| `index.js` | Add `installation`/`installation_target` handlers |
| `test/unit/lib/plugins/appInstallations.test.js` | **New** |
| `test/unit/lib/repoSelector.test.js` | **New** |
| `test/unit/lib/appOctokitClient.test.js` | **New** |
Contributor guide
Assessment
This issue has not been assessed yet.