Skip to content

feat(github-app): allow several github apps to be used - #5038

Open
thomasnemer wants to merge 6 commits into
github-aws-runners:mainfrom
doctolib-lab:use-multiple-gh-apps
Open

feat(github-app): allow several github apps to be used#5038
thomasnemer wants to merge 6 commits into
github-aws-runners:mainfrom
doctolib-lab:use-multiple-gh-apps

Conversation

@thomasnemer

Copy link
Copy Markdown
Contributor

Summary

  • Add support for configuring additional GitHub Apps to distribute API calls across multiple rate limit buckets, making the module scalable for large enterprise deployments
  • New additional_github_apps variable (optional, no breaking changes) accepts a list of extra apps with id, key_base64, and optionally installation_id (all support direct values or SSM references)
  • Lambda functions randomly select an app per authentication flow and thread the selection through the entire JWT → installation token → API call chain for consistency
  • Installation ID resolution is optimized: primary app reuses the webhook payload, additional apps use a pre-configured installation_id when provided, falling back to an API lookup only when needed

Context

At enterprise scale with many runner configurations, all Lambda functions (scale-up, scale-down, pool, job-retry) share a single GitHub App's rate limit bucket (15,000 req/hour on GitHub Enterprise Cloud). This ceiling cannot be raised per-app — the only way to scale is to spread load across multiple apps. This change makes the effective limit N × 15,000 req/hour, without introducing breaking changes.

Closes #5037

@thomasnemer
thomasnemer marked this pull request as ready for review February 18, 2026 15:46
@thomasnemer
thomasnemer requested review from a team as code owners February 18, 2026 15:46
@edersonbrilhante

Copy link
Copy Markdown
Contributor

@thomasnemer can you fix the conflicts?

@thomasnemer

Copy link
Copy Markdown
Contributor Author

For sure. I'll rebase early next week.

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 19c2869 to 9d0d0dc Compare March 9, 2026 10:53
@thomasnemer

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante done :)

@npalm

npalm commented Mar 9, 2026

Copy link
Copy Markdown
Member

@thomasnemer can you check the failing pipepline?

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from bb58cca to 28ea8a2 Compare March 10, 2026 10:22
@thomasnemer

Copy link
Copy Markdown
Contributor Author

@npalm done :) (+ rebased and resolved a conflict on scale-up.ts)

@thomasnemer

Copy link
Copy Markdown
Contributor Author

@npalm : How confident are we with merging this PR for it to be part of the next release? Not pushing forward at all, just planning for internal maintenance of our non-production environment :)

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 7eae4a4 to 5f70d56 Compare April 8, 2026 10:22
@thomasnemer

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante 👋 Is there anything preventing this PR to go forward that I can help with?

@gfliker

gfliker commented May 20, 2026

Copy link
Copy Markdown

bumping this.
Thanks

@edersonbrilhante

Copy link
Copy Markdown
Contributor

@thomasnemer the project got more maintainers to speed the reviews. Can you fix the conflicts?

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch 2 times, most recently from 529c80c to e3bec7d Compare June 22, 2026 11:41
@thomasnemer

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante sorry I missed the ping last week 🙇. The PR has been rebased and conflicts solved.

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch 2 times, most recently from bc76eb5 to 1171bfb Compare June 25, 2026 10:12

@edersonbrilhante edersonbrilhante left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasnemer Sorry to ask again, can you fix the conflicts? Next review I will prioritise your PR :)

@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 1171bfb to 5e9fd31 Compare July 6, 2026 07:56
@thomasnemer

Copy link
Copy Markdown
Contributor Author

@thomasnemer Sorry to ask again, can you fix the conflicts? Next review I will prioritise your PR :)

It's rebased and conflict free now @edersonbrilhante :)

@edersonbrilhante

Copy link
Copy Markdown
Contributor

@thomasnemer I will this in my environment during this week. Are you using this feature in your environment?

@thomasnemer

Copy link
Copy Markdown
Contributor Author

@thomasnemer I will this in my environment during this week. Are you using this feature in your environment?

@edersonbrilhante Yes, in our staging and production environments, with 3 gh apps (main one + 2 additional) to balance the load.

Copilot AI review requested due to automatic review settings July 22, 2026 10:46
@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 30da9a4 to 9bfaf89 Compare July 22, 2026 10:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 5 comments.

Comment thread lambdas/functions/control-plane/src/github/rate-limit.ts
Comment thread modules/runners/README.md
Comment thread modules/multi-runner/README.md
Comment thread modules/multi-runner/README.md
Comment thread lambdas/functions/control-plane/src/scale-runners/scale-down.ts
Copilot AI review requested due to automatic review settings July 22, 2026 15:45
@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 9bfaf89 to 1959f2c Compare July 22, 2026 15:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

lambdas/functions/control-plane/src/github/rate-limit.ts:33

  • TypeScript type mismatch: metricGitHubAppRateLimit passes an optional appIndex (number | undefined) into getAppId(), which currently expects a number. This fails compilation under strictNullChecks and also risks passing undefined through to SSM lookup logic.
    const updateMetric = yn(process.env.ENABLE_METRIC_GITHUB_APP_RATE_LIMIT);
    if (updateMetric) {
      const appId = await getAppId(appIndex);
      const metric = createSingleMetric('GitHubAppRateLimitRemaining', MetricUnit.Count, remaining, {
        AppId: appId,

modules/runners/README.md:165

  • The input documentation says "round-robin" distribution, but the implementation (and the rest of this paragraph) describes random selection per auth flow. This is contradictory and can confuse users about expected behavior.
| <a name="input_github_app_parameters"></a> [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.<br/><br/>Supports multiple GitHub Apps for round-robin API rate limit distribution.<br/>Each list element corresponds to one GitHub App and is a map containing<br/>`name` and `arn` keys referencing SSM parameters. The first element is the<br/>primary app (the one whose webhook secret is used for incoming webhook<br/>validation). All apps must be installed on the same repositories/organizations.<br/><br/>The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly<br/>select an app from the list for each GitHub API call, distributing rate<br/>limit consumption across all configured apps. | <pre>object({<br/>    key_base64      = list(map(string))<br/>    id              = list(map(string))<br/>    installation_id = list(object({ name = string, arn = string }))<br/>  })</pre> | n/a | yes |

Comment thread modules/ssm/variables.tf
Comment thread lambdas/functions/control-plane/src/github/auth.ts
…tftest

- Restore iam_overrides conditional and aws_iam_role.runner[0].arn index
  in scale-up IAM policy (dropped during conflict resolution)
- Update pool.tftest.hcl github_app_parameters to list format
Copilot AI review requested due to automatic review settings July 22, 2026 16:45
@thomasnemer
thomasnemer force-pushed the use-multiple-gh-apps branch from 1959f2c to f3a4e52 Compare July 22, 2026 16:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

modules/runners/README.md:165

  • This row says the module supports "round-robin" distribution, but the implementation in lambdas/functions/control-plane/src/github/auth.ts selects an app via Math.floor(Math.random() * credentials.length) (random selection), not deterministic round-robin. Update wording to avoid implying deterministic ordering.
| <a name="input_github_app_parameters"></a> [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.<br/><br/>Supports multiple GitHub Apps for round-robin API rate limit distribution.<br/>Each list element corresponds to one GitHub App and is a map containing<br/>`name` and `arn` keys referencing SSM parameters. The first element is the<br/>primary app (the one whose webhook secret is used for incoming webhook<br/>validation). All apps must be installed on the same repositories/organizations.<br/><br/>The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly<br/>select an app from the list for each GitHub API call, distributing rate<br/>limit consumption across all configured apps. | <pre>object({<br/>    key_base64      = list(map(string))<br/>    id              = list(map(string))<br/>    installation_id = list(object({ name = string, arn = string }))<br/>  })</pre> | n/a | yes |

modules/multi-runner/README.md:9

  • The section title/wording says "round-robin", but the lambdas select a GitHub App randomly (see createGithubAppAuth in lambdas/functions/control-plane/src/github/auth.ts). Consider renaming the section to avoid suggesting deterministic round-robin.
### GitHub App round-robin

To distribute GitHub API rate limit usage, this module supports configuring multiple GitHub Apps via the `additional_github_apps` variable. The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each API call, spreading the load across all configured apps.

Comment thread modules/ssm/outputs.tf Outdated
Comment thread modules/ssm/outputs.tf Outdated
Comment thread modules/ssm/outputs.tf Outdated
- modules/ssm/outputs.tf: use tostring(idx) when indexing for_each
  resources keyed by stringified indices; bare numeric [idx] would fail
  with an invalid-index error when additional_github_apps is non-empty
- pool.ts: pass appIdx as trailing arg to createRunners so pool-originated
  JIT-config rate-limit metrics are attributed to the correct app rather
  than always defaulting to app 0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 13:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (4)

lambdas/functions/control-plane/src/scale-runners/scale-down.ts:61

  • The cached Octokit client is not tagged with which GitHub App index was used to authenticate it. Downstream calls (e.g., rate-limit metrics) therefore can’t attribute requests to the correct app, which makes multi-app rate-limit metrics misleading.
  const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx);
  const octokit = await createOctokitClient(ghAuth.token, ghesApiUrl);
  githubCache.clients.set(key, octokit);

lambdas/functions/control-plane/src/scale-runners/scale-down.ts:85

  • In multi-app mode, this metric call always defaults to appIndex=0, even when the Octokit client was authenticated with a different app. This will misattribute rate-limit telemetry and can hide which app is actually being throttled.
    // appIndex is not threaded here: the octokit client is cached per-owner and does not
    // retain which app authenticated it, so the rate-limit metric labels the primary app (index 0).
    metricGitHubAppRateLimit(state.headers);

modules/runners/README.md:165

  • This input description says “round-robin API rate limit distribution”, but the implementation and the rest of the text describe random selection. To avoid confusing users, please use consistent terminology (e.g., “random” / “load-balanced”).
| <a name="input_github_app_parameters"></a> [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.<br/><br/>Supports multiple GitHub Apps for round-robin API rate limit distribution.<br/>Each list element corresponds to one GitHub App and is a map containing<br/>`name` and `arn` keys referencing SSM parameters. The first element is the<br/>primary app (the one whose webhook secret is used for incoming webhook<br/>validation). All apps must be installed on the same repositories/organizations.<br/><br/>The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly<br/>select an app from the list for each GitHub API call, distributing rate<br/>limit consumption across all configured apps. | <pre>object({<br/>    key_base64      = list(map(string))<br/>    id              = list(map(string))<br/>    installation_id = list(object({ name = string, arn = string }))<br/>  })</pre> | n/a | yes |

modules/multi-runner/README.md:7

  • The section title says “round-robin”, but the behavior described here (and implemented in the lambdas) is random selection. Renaming this heading avoids implying a deterministic rotation strategy.
### GitHub App round-robin

Comment thread modules/multi-runner/outputs.tf
…termination-watcher list mismatch

The local.github_app_parameters restructure (id/key_base64 became lists,
index 0 = primary app) silently broke four consumers of the old single-object
shape, none caught by CI since validate defers value evaluation:

- outputs.tf: ssm_parameters iterated lists as objects (v.name on a list
  fails at plan/apply); replace with merge() that keeps the upstream keys
  id/key_base64/webhook_secret pointing at the primary app plus the new
  per-app github_app_id_<idx>/github_app_key_base64_<idx>/github_app_webhook_secret keys
- modules/multi-runner/outputs.tf: add the same backward-compat keys to the
  existing merge() so external consumers indexing the old keys keep working
- main.tf + modules/multi-runner/termination-watcher.tf: pass id[0]/key_base64[0]
  (primary app single object) instead of the full list to the termination-watcher
  module input typed map(string)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 15:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

lambdas/functions/control-plane/src/github/auth.ts:212

  • createAuth can throw a hard-to-diagnose runtime error if no app credentials are configured (empty PARAMETER_* env vars) or if an out-of-range appIndex is ever passed. Today selected can become undefined and the next line will crash when accessing selected.appId. Add explicit validation so failures are deterministic and actionable.
  const credentials = await getAppCredentials();
  const selected =
    appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];

  logger.debug(`Selected GitHub App ${selected.appId} for authentication`);

lambdas/functions/control-plane/src/scale-runners/scale-down.ts:84

  • Rate-limit metrics are now potentially misattributed in multi-app mode: the Octokit client may be authenticated with a non-primary app, but metricGitHubAppRateLimit is called without the corresponding appIndex so it will default to index 0. This makes the new per-app metric labeling unreliable.
    // appIndex is not threaded here: the octokit client is cached per-owner and does not
    // retain which app authenticated it, so the rate-limit metric labels the primary app (index 0).
    metricGitHubAppRateLimit(state.headers);

modules/runners/README.md:165

  • This description says "round-robin" but the implementation selects the app randomly (see Math.random() in lambdas/functions/control-plane/src/github/auth.ts). The docs should match the actual selection strategy.
| <a name="input_github_app_parameters"></a> [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.<br/><br/>Supports multiple GitHub Apps for round-robin API rate limit distribution.<br/>Each list element corresponds to one GitHub App and is a map containing<br/>`name` and `arn` keys referencing SSM parameters. The first element is the<br/>primary app (the one whose webhook secret is used for incoming webhook<br/>validation). All apps must be installed on the same repositories/organizations.<br/><br/>The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly<br/>select an app from the list for each GitHub API call, distributing rate<br/>limit consumption across all configured apps. | <pre>object({<br/>    key_base64      = list(map(string))<br/>    id              = list(map(string))<br/>    installation_id = list(object({ name = string, arn = string }))<br/>  })</pre> | n/a | yes |

modules/multi-runner/README.md:7

  • The docs call this "round-robin" but the implementation selects apps randomly (see Math.random() in lambdas/functions/control-plane/src/github/auth.ts). Renaming the section avoids implying deterministic rotation.
### GitHub App round-robin

modules/multi-runner/README.md:13

  • This bullet says "round-robin" but selection is random (see Math.random() in lambdas/functions/control-plane/src/github/auth.ts). Consider adjusting wording to match behavior.
- Its **app ID and private key** are included in the round-robin pool alongside the additional apps.

modules/multi-runner/README.md:17

  • This sentence says "round-robin" but the control-plane uses random selection (see Math.random() in lambdas/functions/control-plane/src/github/auth.ts). Align terminology to prevent operator confusion.
The **webhook lambda** does not participate in round-robin: it only validates incoming webhook signatures using the primary app's webhook secret and never calls the GitHub API.

@thomasnemer

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante Are we good to go?

@edersonbrilhante

Copy link
Copy Markdown
Contributor

@thomasnemer Can you fix? Heads up, I am working to refactor the module to introduce plugin layout. Ref: #5234

@Brend-Smits Brend-Smits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've actually been testing this change on my fork and it's working very well so far! Would love to get this merged as soon as possible as it's something lots of people would benefit from.
It's a bit difficult though as we can't make edits on yours branch.
Have a look at solving the merge conflicts, then let's merge this.

@guicaulada guicaulada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to see this approved, we want this capability upstream too. We've been running a nearly identical implementation in production (vendored on v7.10.0: same per-flow app selection, stored installation ids, and SSM/IAM layout), so these notes come from operating this design at scale.

One thing we think should be fixed before this ships in a release:

The PR removes the 404 stale-installation fallback (createGithubInstallationClient in scale-up.ts, and the try/catch in octokit.ts's getOctokit). That fallback protects existing single-app deployments today: when a webhook payload carries a stale installationId (app uninstalled/reinstalled while messages were in flight), the current code catches the 404, re-resolves the installation via the API, and retries. With this PR the primary-app path trusts payload.installationId and the 404 propagates, so every scale-up for that repo fails until stale payloads drain from the queue. It's rare, but silent and hard to diagnose.

The restore is small and composes with app selection, the catch block can re-resolve using the already-selected appIndex. Since the conflict rebase touches these files anyway, that would be a good time to fix it. If you'd rather keep this PR as-is, we're happy to open the restoring PR ourselves right after merge, we'd just ask it get's in before the next release is cut.

Things we'd suggest as follow-ups (none blocking, we can contribute the first two from our production fork):

  1. Manifest parameter instead of colon-joined env vars. PARAMETER_GITHUB_APP_*_NAME grows linearly with app count and Lambda's total environment limit is 4 KB — with typical parameter path lengths that caps out around 15–20 apps, which matters for the large-enterprise use case this PR targets. A small SSM manifest parameter listing the per-app parameter names keeps the env constant regardless of app count. Internal contract only (TF and lambdas deploy together), so changing it later breaks nothing.
  2. Rate-limit-aware app selection. Uniform random keeps sending ~1/N of traffic to an app that's already throttled. We track x-ratelimit-remaining per app from response headers, put apps in a short cooldown on secondary rate limits, and pick the app with the most budget left (random start offset so cold starts don't converge). Selection is internal, so this slots in cleanly later.
  3. Validate installation-id list alignment in loadAppCredentials: id/key counts are checked, but the positionally-aligned installation-id list isn't; drift silently shifts installation ids across apps.
  4. Reuse loaded credentials in rate-limit.ts:getAppId re-reads SSM per app and throws on out-of-range indexes inside a swallowed try/catch, silently stopping the metric.
  5. Doc callout that additional apps must be installed on the same orgs/repos: a misconfigured app today surfaces only as intermittent installation 404s, which random selection makes especially confusing.

We'll open PRs for the follow-ups we plan to pick up, so nothing here needs to hold up the merge beyond the rebase.

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.

Support for multiple GitHub Apps to overcome API rate limits at scale

7 participants