Skip to content

App Installation Plugin — Manage GitHub App installation settings via safe-settings #1005

Description

@decyjphr

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

  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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions