From 492b1747675488ec99421f3b179a10f92386389a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:40 +0200 Subject: [PATCH] fix(github): route webhooks to the source of their app installation Closes #11271 --- app/Http/Controllers/Webhook/Github.php | 7 +- ...hubAppMultipleInstallationsWebhookTest.php | 131 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/GithubAppMultipleInstallationsWebhookTest.php diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index ac83d7ae6f..c6a1596143 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -398,7 +398,12 @@ class Github extends Controller // Just pong return response('pong'); } - $github_app = GithubApp::where('app_id', $x_github_hook_installation_target_id)->first(); + // One GitHub App can be installed on several accounts, each added as its own source. + $installation_id = data_get($payload, 'installation.id'); + $github_app = is_numeric($installation_id) + ? GithubApp::where('app_id', $x_github_hook_installation_target_id)->where('installation_id', (int) $installation_id)->first() + : null; + $github_app ??= GithubApp::where('app_id', $x_github_hook_installation_target_id)->first(); if (is_null($github_app)) { return response('Nothing to do. No GitHub App found.'); } diff --git a/tests/Feature/GithubAppMultipleInstallationsWebhookTest.php b/tests/Feature/GithubAppMultipleInstallationsWebhookTest.php new file mode 100644 index 0000000000..523fe5946f --- /dev/null +++ b/tests/Feature/GithubAppMultipleInstallationsWebhookTest.php @@ -0,0 +1,131 @@ +create(); + + $createSource = fn (string $name, int $installationId): GithubApp => GithubApp::create([ + 'name' => $name, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'installation_id' => $installationId, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $team->id, + ]); + + $this->personalSource = $createSource('personal-account', 11111); + $this->orgSource = $createSource('organization', 22222); + + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $this->createApplicationForSource = fn (GithubApp $source, int $repositoryId): Application => Application::create([ + 'name' => "app-{$source->name}", + 'git_repository' => "{$source->name}/repo", + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'repository_project_id' => $repositoryId, + 'source_id' => $source->id, + 'source_type' => GithubApp::class, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +}); + +function multiInstallationDeployedApplicationIds(): array +{ + return ApplicationDeploymentQueue::query()->pluck('application_id')->map(fn (mixed $id): int => (int) $id)->unique()->values()->all(); +} + +function sendMultiInstallationGithubPush(TestCase $test, int $repositoryId, ?int $installationId): TestResponse +{ + $payload = [ + 'ref' => 'refs/heads/main', + 'before' => '1111111111111111111111111111111111111111', + 'after' => '2222222222222222222222222222222222222222', + 'repository' => ['id' => $repositoryId, 'full_name' => 'owner/repo'], + 'commits' => [['message' => 'Update', 'added' => [], 'removed' => [], 'modified' => ['README.md']]], + ]; + if ($installationId !== null) { + $payload['installation'] = ['id' => $installationId]; + } + $body = json_encode($payload, JSON_THROW_ON_ERROR); + + return $test->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +it('deploys an application of the second installation of a shared GitHub App', function () { + $application = ($this->createApplicationForSource)($this->orgSource, 222); + + sendMultiInstallationGithubPush($this, 222, 22222) + ->assertOk() + ->assertDontSee('No applications found'); + + expect(multiInstallationDeployedApplicationIds())->toBe([$application->id]); +}); + +it('deploys an application of the first installation of a shared GitHub App', function () { + $application = ($this->createApplicationForSource)($this->personalSource, 111); + + sendMultiInstallationGithubPush($this, 111, 11111)->assertOk(); + + expect(multiInstallationDeployedApplicationIds())->toBe([$application->id]); +}); + +it('falls back to the GitHub App id when no source has the installation id', function () { + $application = ($this->createApplicationForSource)($this->personalSource, 111); + + sendMultiInstallationGithubPush($this, 111, 99999)->assertOk(); + expect(multiInstallationDeployedApplicationIds())->toBe([$application->id]); + + ApplicationDeploymentQueue::query()->delete(); + sendMultiInstallationGithubPush($this, 111, null)->assertOk(); + expect(multiInstallationDeployedApplicationIds())->toBe([$application->id]); +}); + +it('does not match a source of another GitHub App with the same installation id', function () { + GithubApp::query()->whereKey($this->orgSource->id)->update(['app_id' => 999]); + ($this->createApplicationForSource)($this->orgSource, 222); + + sendMultiInstallationGithubPush($this, 222, 22222) + ->assertOk() + ->assertSee("No applications found with branch 'main'", false); + + expect(ApplicationDeploymentQueue::query()->count())->toBe(0); +});