From aa313f099c0bddfb9bb40e237c6a7571b7cb5d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 15 May 2026 14:07:48 +0200 Subject: [PATCH] github: stabilize entity order in multi-org provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using GithubMultiOrgEntityProvider with alwaysUseDefaultNamespace and teams with identical slugs across orgs, the emitted entity order was non-deterministic. Since the catalog's upsert path uses last-write- wins for duplicate entity refs, this caused the winning org to flip randomly on every refresh cycle, producing constant unnecessary stitching and flickering entity data. Sort the emitted entities by entity ref (primary) and location annotation (tiebreaker) so that the same org consistently wins when duplicate refs exist. Fixes #34263 Signed-off-by: Fredrik Adelöw Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/stable-multi-org-order.md | 5 +++ .../GithubMultiOrgEntityProvider.test.ts | 12 +++---- .../providers/GithubMultiOrgEntityProvider.ts | 31 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 .changeset/stable-multi-org-order.md diff --git a/.changeset/stable-multi-org-order.md b/.changeset/stable-multi-org-order.md new file mode 100644 index 0000000000..b7a3e310c4 --- /dev/null +++ b/.changeset/stable-multi-org-order.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +The `GithubMultiOrgEntityProvider` now emits entities in a stable order during full mutations. Entities are sorted by entity ref, with the location annotation as a tiebreaker for entities that share the same ref. This prevents entity data from flickering between different GitHub orgs across refresh cycles when `alwaysUseDefaultNamespace` is enabled and teams with identical slugs exist in multiple orgs. diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts index e80579ee36..cf4527193c 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.test.ts @@ -212,7 +212,7 @@ describe('GithubMultiOrgEntityProvider', () => { }); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -352,7 +352,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); @@ -526,7 +526,7 @@ describe('GithubMultiOrgEntityProvider', () => { }); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -666,7 +666,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); @@ -804,7 +804,7 @@ describe('GithubMultiOrgEntityProvider', () => { await entityProvider.read(); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + entities: expect.arrayContaining([ { entity: { apiVersion: 'backstage.io/v1alpha1', @@ -942,7 +942,7 @@ describe('GithubMultiOrgEntityProvider', () => { }, locationKey: 'github-multi-org-provider:my-id', }, - ], + ]), type: 'full', }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts index 426a67eadb..634250d90e 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubMultiOrgEntityProvider.ts @@ -351,15 +351,32 @@ export class GithubMultiOrgEntityProvider implements EntityProvider { const { markCommitComplete } = markReadComplete({ allUsers, allTeams }); + const allEntities = [...allUsers, ...allTeams].map(entity => ({ + locationKey: `github-multi-org-provider:${this.options.id}`, + entity: withLocations( + `https://${this.options.gitHubConfig.host}`, + entity, + ), + })); + allEntities.sort((a, b) => { + const am = a.entity.metadata; + const bm = b.entity.metadata; + if (am.name !== bm.name) return am.name < bm.name ? -1 : 1; + const al = am.annotations?.[ANNOTATION_LOCATION] ?? ''; + const bl = bm.annotations?.[ANNOTATION_LOCATION] ?? ''; + if (al !== bl) return al < bl ? -1 : 1; + if (a.entity.kind !== b.entity.kind) { + return a.entity.kind < b.entity.kind ? -1 : 1; + } + const ans = am.namespace ?? ''; + const bns = bm.namespace ?? ''; + if (ans !== bns) return ans < bns ? -1 : 1; + return 0; + }); + await this.connection.applyMutation({ type: 'full', - entities: [...allUsers, ...allTeams].map(entity => ({ - locationKey: `github-multi-org-provider:${this.options.id}`, - entity: withLocations( - `https://${this.options.gitHubConfig.host}`, - entity, - ), - })), + entities: allEntities, }); markCommitComplete();