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
# 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
- 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 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
lib/repoSelector.js — Repo selection utility (name, team, custom properties)
lib/appOctokitClient.js — Enterprise Octokit client (payload slug, batching at 50)
lib/plugins/appInstallations.js — Plugin (delta + full sync modes, disable/additive aware)
lib/settings.js — Wire as separate phase with delta-based processing
index.js — Webhook handlers (installation, installation_target)
- 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 |
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
payload.enterprise.slug) — no env var needed.syncAppInstallations()is a separate phase (not insideupdateOrg()).Design
Config Hierarchy & Repository Selection
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 installationrepository_unselection— repos to REMOVE from the app installationOnly apps with non-empty selection/unselection are "marked for change" and processed.
Per config level:
1. Org-level (
settings.ymlchanges):repository_selection: all→ mark for change, set to "all" (no repo enumeration — use API's native "all" selection)repository_selection: [](empty, triggering full removal via live state comparison)2. Suborg-level (
suborgs/*.ymlchanges):repository_selection= repos from this suborg's current targetingrepository_unselection= repos removed from this suborg's targeting (load previous version of THIS file only — 1 API call — reusesgetReposRemovedFromSubOrgTargetingpattern)3. Repo-level (
repos/*.ymlchanges):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)4. Full sync (cron/manual):
Merge and process:
repository_selection/repository_unselectionacross changed filesArchitecture Components
1.
lib/plugins/appInstallations.js— Plugin classA new plugin (not extending Diffable) that:
repository_selectionandrepository_unselection(pre-computed)sync()— Calls enterprise API to add/remove repos in batches of 50find()gets live state, compares with full desired statedisable_pluginssupport: Can be disabled at any layer. When disabled, the plugin is skipped.additive_pluginssupport: When listed, suppressesrepository_unselection(only adds, never removes).2.
lib/appOctokitClient.js— Enterprise App Token Clientpayload.enterprise.slug)addReposToInstallation(installationId, repoIds),removeReposFromInstallation(installationId, repoIds),listInstallationRepos(installationId)3.
lib/repoSelector.js— Repo Selection UtilityResolves repos from fixed criteria only:
getReposForTeam)getRepositoriesByProperty)4. Changes to
lib/settings.jssyncAppInstallationsas a separate phaserepository_selection/repository_unselectionper app (delta only)disable_plugins/additive_plugins: Gated via strip map and additive set5. Changes to
index.js— Webhook Handlersinstallationandinstallation_targetwebhook event handlersFuture-Proofing: Target Abstraction
For now, implement only
AppInstallationTarget. Existing repo-targeting stays as-is but could adopt this pattern later.Implementation Order
lib/repoSelector.js— Repo selection utility (name, team, custom properties)lib/appOctokitClient.js— Enterprise Octokit client (payload slug, batching at 50)lib/plugins/appInstallations.js— Plugin (delta + full sync modes, disable/additive aware)lib/settings.js— Wire as separate phase with delta-based processingindex.js— Webhook handlers (installation,installation_target)File Changes Summary
lib/plugins/appInstallations.jslib/appOctokitClient.jslib/repoSelector.jslib/settings.jssyncAppInstallationsseparate phaseindex.jsinstallation/installation_targethandlerstest/unit/lib/plugins/appInstallations.test.jstest/unit/lib/repoSelector.test.jstest/unit/lib/appOctokitClient.test.js