fix: full sync all app installations, not just the first - #1044
Open
rafaelleonardocruz wants to merge 2 commits into
Open
Conversation
`syncInstallation()` paginated `apps.listInstallations` but then only ever
operated on `installations[0]`. When the App is installed on more than one
organization, the scheduled CRON full sync and `npm run full-sync` swept a
single org and silently skipped every other installation, leaving those orgs
with no drift-correction safety net -- only webhook-driven correction.
`syncInstallation()` now iterates every installation, authenticating per
installation and building the same context (admin repo scoped to that
installation's account login) before calling `syncAllSettings`.
Each iteration is isolated in a try/catch so one broken installation (e.g.
suspended, revoked permissions, missing admin repo) cannot abort the sync of
the remaining ones. The failure is logged with the installation id and account
login and collected instead of thrown.
The return value is now an aggregate `{ results, errors }`: `results` holds the
successful per-installation return values in order, and `errors` concatenates
every `result.errors` plus one entry per failed iteration. This preserves the
`full-sync.js` contract, which inspects `settings.errors` and exits non-zero
when it is non-empty. `null` is still returned when there are no installations.
Observability: the CRON tick logs at `debug` and `syncInstallation` at `trace`,
so a scheduled sync was invisible at the default `LOG_LEVEL=info`. A single
`info` summary line (synced / failed counts) is now emitted at the end, with
per-installation detail kept at `debug`.
`info()` is intentionally left alone: its use of `installations[0]` is correct,
since the app slug it resolves is a property of the App, not of an installation.
Co-Authored-By: Claude <noreply@anthropic.com>
AI-Assisted: yes
AI-Tool: claude-code
Co-Authored-By: claude-code <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes the full-sync path so it reconciles all GitHub App installations (across multiple orgs), not just the first installation returned by apps.listInstallations. This makes scheduled and manual full-sync runs act as a true safety net for every org where the app is installed.
Changes:
- Update
syncInstallation()to iterate through every installation, authenticating and syncing per-installation, isolating failures, and aggregating{ results, errors }. - Add a single
info-level summary log line after the sweep to make CRON full-sync runs observable at default log levels. - Add a focused unit test suite covering multi-installation fan-out, error aggregation, failure isolation, and logging behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| index.js | Iterates all installations in syncInstallation(), aggregates results/errors, and logs a summary line. |
| test/unit/sync-installation.test.js | Adds unit tests to pin the new multi-installation full-sync behavior and logging. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`syncAllSettings` rethrows in normal mode, but in nop mode its catch reports the problem through `Settings.handleError` and then falls through without returning anything. The per-installation loop pushed that `undefined` into `results` and counted the installation as synced, with nothing recorded in `errors`. The effect was that `npm run full-sync` with `FULL_SYNC_NOP=true` reported success for an installation whose configuration had failed to load, and `full-sync.js` exited 0. Before installations were iterated, the same case returned `undefined` from `syncInstallation`, so reading `settings.errors` in `full-sync.js` threw and the run exited non-zero. That was crude, but it was loud. For a drift-correction safety net, silently reporting a broken installation as healthy is worse than failing noisily. A falsy result is now treated as a failure of that installation: it is counted in the failed total, logged with its id and account login, and contributes an error to the aggregate, so a nop full sync still exits non-zero. Successful results keep their existing handling. `syncAllSettings` itself is deliberately left alone. `syncSettings` has the same shape and other callers depend on the current behavior, so changing the nop return value belongs in its own change. Co-Authored-By: Claude <noreply@anthropic.com> AI-Assisted: yes AI-Tool: claude-code Co-Authored-By: claude-code <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
syncInstallation()paginatesapps.listInstallationsbut then operates oninstallations[0]only:https://github.com/github-community-projects/safe-settings/blob/main-enterprise/index.js#L234-L246
Both full-sync entry points go through it:
CRONsync (index.js,cron.schedule(process.env.CRON, () => { syncInstallation() }))full-sync.js,npm run full-sync)So when a single safe-settings App is installed on more than one organization, only one org is ever swept. The others are silently skipped — no error, no log line. They keep whatever drift they have until a webhook happens to fire for them, which means the periodic safety net that full-sync exists to provide simply does not apply to them.
We hit this running one App installed on two organizations: the hourly full sync only ever reconciled the first installation, and the second org — the one where we most needed a periodic backstop — was covered only by real-time webhook events.
Change
syncInstallation()now iterates every installation, authenticating per installation and building the same context as before (admin repo scoped to that installation'saccount.login) before callingsyncAllSettings.Three details worth reviewing:
Per-installation isolation. Each iteration is wrapped in
try/catch, so one broken installation (suspended, revoked permissions, missing admin repo) cannot abort the sync of the remaining ones — that would defeat the purpose of a safety net. The failure is logged with the installation id and account login, then collected.Aggregate return
{ results, errors }.resultsholds the successful per-installation return values in order;errorsconcatenates everyresult.errorsplus one entry per failed iteration. This keeps thefull-sync.jscontract working — it inspectssettings.errorsand exits non-zero when non-empty — and now it exits non-zero if any installation failed rather than only the first.nullis still returned when there are no installations, unchanged.One
infolog line. The CRON tick logs atdebugandsyncInstallationattrace, so at the defaultLOG_LEVEL=infoa scheduled full sync is completely invisible: you cannot tell from the logs whether it ran. A single summary line (Synced N of M installation(s); F failed) at the end makes the hourly run observable without adding noise. Per-installation detail stays atdebug.info()is deliberately not changed — its use ofinstallations[0]is legitimate, since the app slug it resolves is a property of the App rather than of any one installation.Tests
New
test/unit/sync-installation.test.js(8 tests), driving the exported plugin with a fakerobotand the injectableSettingsargument:syncAllcalled once per installation, each with its own ownernopflag is passed through to every installationerrors, and the id/account are loggednull, nothing syncedReverting
index.jsto its current state fails 6 of the 8, so the suite pins the new behaviour rather than merely passing alongside it.One heads-up so it is not attributed to this PR:
npx standard index.js/npx eslint index.jsreportindex.js:5:7 'Glob' is assigned a value but never used. That is pre-existing onmain-enterprise(verified by stashing this diff and re-running); removing the deadrequirefelt like it belonged in a separate cleanup PR rather than here.Notes / possible follow-ups
Happy to adjust the return shape (for example index-aligned
resultswith holes for failures, or keeping the bare single-installation return when only one exists) if you would prefer a different contract.🤖 Generated with Claude Code