Skip to content

fix: sync the installation matching GH_ORG during full sync - #1053

Open
DeepDiver1975 wants to merge 1 commit into
github-community-projects:main-enterprisefrom
DeepDiver1975:deepdiver/full-sync-gh-org
Open

fix: sync the installation matching GH_ORG during full sync#1053
DeepDiver1975 wants to merge 1 commit into
github-community-projects:main-enterprisefrom
DeepDiver1975:deepdiver/full-sync-gh-org

Conversation

@DeepDiver1975

Copy link
Copy Markdown

Fixes #782.

Problem

syncInstallation() paginates apps.listInstallations and then unconditionally takes the first entry, deriving the admin repo owner from it:

https://github.com/github-community-projects/safe-settings/blob/main-enterprise/index.js#L234-L246

So when an app is installed on more than one account, which account a full sync reconciles is decided by the order GET /app/installations happens to return. That order is not documented as stable, and it is not creation order — in our case the API returned an unrelated account ahead of ours even though its installation id was higher (<other-account>#152779721 before <ours>#138202905).

This is not only about deployments that intend to manage several orgs. GET /app/installations lists installations for the app, and anyone can install a public app. Our app was public, an unrelated organisation installed it, and it took over index 0. From then on every scheduled sync read <other-account>/admin/.github/settings.yml, got a 404, and failed — our org silently went unmanaged for about two days. Had that account happened to contain an admin repo with a settings file, the sync would have applied their configuration to their org using our app's credentials. The operator has no way to express "only ever sync this account".

The natural way to say that already exists: GH_ORG. The GitHub Action recipe in docs/github-action.md sets it in the full-sync workflow, so operators reasonably assume it scopes the sync — but nothing read it. It was absent from lib/env.js and only consumed by the probot manifest flow.

Change

  • Add GH_ORG to lib/env.js.
  • When GH_ORG is set, select the installation whose account login matches it, case-insensitively (GitHub account names are).
  • When GH_ORG is set but the app has no installation on it, throw instead of silently falling back, so a mistargeted sync fails loudly rather than reconciling another account. The message lists the accounts the app is installed on, which is what one needs to debug it. full-sync.js already turns a throw into a non-zero exit with the message.
  • When GH_ORG is not set, behaviour is unchanged (installations[0]). The change is opt-in and no existing deployment is affected.
  • Log at info which installation and account is being synced. Nothing on the full-sync path recorded the account it acted on, which is a large part of why the above took two days to spot.

info() is deliberately left alone: it authenticates as an arbitrary installation purely to read the app slug, which is a property of the app, not of any one installation.

Relationship to earlier attempts

#783 (@hilmarf) proposed the same idea and was closed by its author after going stale. Two things blocked it, both addressed here:

  • @decyjphr's review point was that GH_ORG "is not a required env variable so in most cases it would not be set". filter installations by GH_ORG #783 made the find() unconditional, so an unset GH_ORG would have matched nothing and every existing deployment would have stopped syncing. Here the filter only applies when GH_ORG is set, and installations[0] remains the default — which is also what @Simon-Boyer suggested on that thread.
  • filter installations by GH_ORG #783 read env.GH_ORG, but GH_ORG was never in lib/env.js, so it was undefined regardless of the environment. This PR adds it.
  • @renan-alm noted the remaining work was tests. This PR has them.

#1044 makes full sync iterate all installations. The two are complementary rather than competing: that PR is about not skipping orgs you own, this one is about not syncing accounts you don't. If #1044 lands, GH_ORG becomes a filter over its loop rather than a pick-one, and I am happy to rebase into that shape.

The failure mode this creates is much easier to read once #1052 stops configManager from masking config-read errors as TypeError: Cannot read properties of undefined (reading 'data'); that is what the 404 above surfaced as. The two PRs are independent and touch different files.

Tests

New test/unit/sync-installation.test.js (7 tests), driving the exported plugin with a fake robot and the injectable Settings argument, asserting on the repo handed to Settings.syncAll:

  • two installations, GH_ORG matching the second -> that one is synced, and the context is authenticated as it
  • GH_ORG differing in case from the account name -> still matched
  • GH_ORG with no matching installation -> rejects, listing the installed accounts, and nothing is synced
  • GH_ORG unset -> falls back to the first installation
  • no installations -> resolves to null, nothing synced
  • the nop flag is passed through
  • the account being synced is logged

Reverting index.js and lib/env.js to main-enterprise fails 4 of the 7, so the suite pins the new behaviour rather than passing alongside it. test/unit/lib/env.test.js gains GH_ORG coverage in both the default and override blocks (1 further failure when reverted).

npx jest test/unit/sync-installation.test.js  -> 7 passed
npx jest --roots=lib --roots=test/unit        -> 145 passed, 14 skipped (137 passed before, no regressions)
npx standard <changed files>  -> clean
npx eslint   <changed files>  -> clean

Node 22.12.0.

Heads-up so it is not attributed to this PR: npx eslint index.js lib/env.js reports index.js:5:7 'Glob' is assigned a value but never used and a comma-dangle error in lib/env.js. Both are pre-existing on main-enterprise (verified by linting the pristine files; my added line only shifts the env.js one from line 11 to 12). Fixing them felt like it belonged in a separate cleanup PR.

Docs

README.md (environment variables), .env.example and docs/github-action.md now state what GH_ORG does for a full sync.

Possible follow-up

The CRON tick calls syncInstallation() without a rejection handler, so in the server flow this throw would become an unhandled rejection — as would any error thrown from syncAllSettings today, so it is not new. Adding a .catch() there seemed out of scope, but I am happy to include it if you would prefer.

`syncInstallation()` authenticated as `installations[0]` and derived the
admin repo owner from it, so the account a full sync reconciled depended
on the order `GET /app/installations` happened to return. The order is
not documented as stable, and it is not creation order, so an app
installed on more than one account can silently switch to a different
account: the sync then reads its configuration from
`<other-account>/<ADMIN_REPO>` and, if that repo exists, applies it there.

`GH_ORG` is the natural way to express which account to sync. The GitHub
Action recipe in docs/github-action.md already tells operators to set it,
but nothing read it: it was absent from lib/env.js and only consumed by
the manifest flow.

So add `GH_ORG` to lib/env.js and, when it is set, select the installation
whose account login matches it (case-insensitively, as GitHub account
names are). If it is set and the app has no installation on it, throw
rather than fall back, so a misconfigured sync fails loudly instead of
reconciling somebody else's account. When `GH_ORG` is unset the behavior
is unchanged, so this is opt-in and no existing deployment changes.

Also log at info level which installation and account is being synced.
Previously nothing on the full-sync path recorded the account it acted
on, which made a mistargeted sync hard to spot.

`info()` is deliberately left alone: it authenticates as an arbitrary
installation only to read the app slug, which is a property of the app
rather than of any installation.

Fixes github-community-projects#782.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

don't take fixed: installations[0]

1 participant