From 4553454f89eddaa8ab70c6a7b7c0432cff888257 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:49:16 +0200 Subject: [PATCH] feat(webhooks): accept GitLab system hook events for deployments (#12185) --- app/Http/Controllers/Webhook/Gitlab.php | 28 ++ .../livewire/source/gitlab/change.blade.php | 2 +- .../Feature/Webhook/GitlabSystemHookTest.php | 278 ++++++++++++++++++ 3 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Webhook/GitlabSystemHookTest.php diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index c135aef328..577ea820c6 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -15,6 +15,7 @@ use App\Models\ApplicationPreview; use App\Models\GitlabApp; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -84,6 +85,9 @@ class Gitlab extends Controller try { $return_payloads = collect([]); $payload = $request->collect(); + if ($request->header('X-Gitlab-Event') === 'System Hook') { + $payload = $this->normalizeSystemHookPayload($payload); + } $x_gitlab_token = $request->header('X-Gitlab-Token'); $object_kind = $this->webhookString(data_get($payload, 'object_kind')); @@ -661,6 +665,30 @@ class Gitlab extends Controller } } + /** + * Give a system hook payload the project webhook shape. Current GitLab sends + * the project webhook data to system hooks, but the documented push payload + * has only `event_name` and a top-level `project_id`. + * + * @see https://docs.gitlab.com/administration/system_hooks/#push-events + * + * @param Collection $payload + * @return Collection + */ + private function normalizeSystemHookPayload(Collection $payload): Collection + { + if (! $payload->has('object_kind') && $payload->has('event_name')) { + $payload->put('object_kind', $payload->get('event_name')); + } + + $project = $payload->get('project'); + if (is_array($project) && ! array_key_exists('id', $project) && $payload->has('project_id')) { + $payload->put('project', ['id' => $payload->get('project_id')] + $project); + } + + return $payload; + } + /** * Read and validate the merge_request payload fields that the handlers use. * diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 2ba6bb1d63..a773769aa0 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -95,7 +95,7 @@ + description="Configure this webhook URL in your GitLab project settings (Settings → Webhooks). On self-managed GitLab, an admin can instead add the same URL and token once as a system hook (Admin Area → System hooks) with Push events and Merge request events enabled.">
0]); +}); + +function gitlabSystemHookSha(): string +{ + return 'da1560886d4f094c3e6c9ef40349f7d38b5d27d7'; +} + +function gitlabSystemHookSource(string $token): GitlabApp +{ + return GitlabApp::create([ + 'name' => "gitlab-system-hook-{$token}", + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => $token, + 'team_id' => Team::factory()->create()->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +} + +function gitlabSystemHookApplication(GitlabApp $source, int $projectId = 15, string $branch = 'main'): Application +{ + $project = Project::factory()->create(['team_id' => $source->team_id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $source->team_id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $application = Application::create([ + 'name' => "gitlab-system-hook-app-{$source->id}", + 'git_repository' => 'mike/diaspora', + 'git_branch' => $branch, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'source_id' => $source->id, + 'source_type' => GitlabApp::class, + 'repository_project_id' => $projectId, + ]); + $application->settings->update([ + 'is_auto_deploy_enabled' => true, + 'is_preview_deployments_enabled' => true, + ]); + + return $application->refresh(); +} + +/** + * A push payload in the shape of the GitLab system hook docs example: it has + * `event_name` and a top-level `project_id`, but no `object_kind` and no `project.id`. + * + * @see https://docs.gitlab.com/administration/system_hooks/#push-events + * + * @return array + */ +function gitlabSystemHookDocumentedPush(int $projectId = 15, string $ref = 'refs/heads/main'): array +{ + return [ + 'event_name' => 'push', + 'before' => '95790bf891e76fee5e1747ab589903a6a1f80f22', + 'after' => gitlabSystemHookSha(), + 'ref' => $ref, + 'checkout_sha' => gitlabSystemHookSha(), + 'user_id' => 4, + 'project_id' => $projectId, + 'project' => ['name' => 'Diaspora', 'path_with_namespace' => 'mike/diaspora', 'default_branch' => 'main'], + 'commits' => [[ + 'id' => gitlabSystemHookSha(), + 'message' => 'Add simple search to projects in public area', + 'added' => ['app.php'], + 'modified' => [], + 'removed' => [], + ]], + 'total_commits_count' => 1, + ]; +} + +/** + * A push payload as current GitLab sends it to system hooks + * (Gitlab::DataBuilder::Push, the same data as for project hooks). + * + * @return array + */ +function gitlabSystemHookRuntimePush(int $projectId = 15): array +{ + $payload = gitlabSystemHookDocumentedPush($projectId); + $payload['object_kind'] = 'push'; + $payload['project']['id'] = $projectId; + + return $payload; +} + +/** + * @return array + */ +function gitlabSystemHookMergeRequest(int $projectId = 15, string $action = 'open'): array +{ + return [ + 'object_kind' => 'merge_request', + 'event_type' => 'merge_request', + 'project' => ['id' => $projectId, 'path_with_namespace' => 'mike/diaspora'], + 'object_attributes' => [ + 'action' => $action, + 'iid' => 1, + 'url' => 'https://gitlab.example.com/mike/diaspora/-/merge_requests/1', + 'title' => 'MS-Viewport', + 'source_branch' => 'ms-viewport', + 'target_branch' => 'main', + 'source_project_id' => $projectId, + 'target_project_id' => $projectId, + 'last_commit' => ['id' => gitlabSystemHookSha(), 'message' => 'Fix viewport'], + ], + ]; +} + +/** + * @param array $payload + */ +function gitlabSystemHookSend(TestCase $test, array $payload, ?string $token, string $event = 'System Hook'): TestResponse +{ + $headers = ['HTTP_X-Gitlab-Event' => $event]; + if ($token !== null) { + $headers['HTTP_X-Gitlab-Token'] = $token; + } + + return $test->call('POST', '/webhooks/source/gitlab/events', [], [], [], $headers + [ + 'REMOTE_ADDR' => '203.0.113.20', + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload, JSON_THROW_ON_ERROR)); +} + +function gitlabSystemHookDeployments(Application $application): array +{ + return ApplicationDeploymentQueue::query()->where('application_id', $application->id)->pluck('commit')->all(); +} + +describe('GitLab system hook push', function () { + test('a push to the tracked branch queues a deployment', function (array $payload) { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source); + + gitlabSystemHookSend($this, $payload, 'system-hook-token') + ->assertOk() + ->assertJsonPath('0.application', $application->name); + + expect(gitlabSystemHookDeployments($application))->toBe([gitlabSystemHookSha()]); + })->with([ + 'documented payload' => [fn () => gitlabSystemHookDocumentedPush()], + 'runtime payload' => [fn () => gitlabSystemHookRuntimePush()], + ]); + + test('a push to another branch queues no deployment', function () { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source); + + gitlabSystemHookSend($this, gitlabSystemHookDocumentedPush(ref: 'refs/heads/develop'), 'system-hook-token')->assertOk(); + + expect(gitlabSystemHookDeployments($application))->toBe([]); + }); + + test('a push for a project that no application tracks queues no deployment', function () { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source, projectId: 15); + + gitlabSystemHookSend($this, gitlabSystemHookDocumentedPush(projectId: 99), 'system-hook-token') + ->assertOk() + ->assertExactJson([]); + + expect(gitlabSystemHookDeployments($application))->toBe([]); + }); + + test('a token only deploys applications of its own source', function () { + $source = gitlabSystemHookSource('system-hook-token'); + $otherSource = gitlabSystemHookSource('other-source-token'); + $application = gitlabSystemHookApplication($source); + $otherApplication = gitlabSystemHookApplication($otherSource); + + gitlabSystemHookSend($this, gitlabSystemHookDocumentedPush(), 'system-hook-token')->assertOk(); + + expect(gitlabSystemHookDeployments($application))->toBe([gitlabSystemHookSha()]) + ->and(gitlabSystemHookDeployments($otherApplication))->toBe([]); + }); + + test('a missing or invalid token is rejected', function (?string $token) { + $application = gitlabSystemHookApplication(gitlabSystemHookSource('system-hook-token')); + + gitlabSystemHookSend($this, gitlabSystemHookDocumentedPush(), $token)->assertUnauthorized(); + + expect(gitlabSystemHookDeployments($application))->toBe([]); + })->with([ + 'missing' => [null], + 'invalid' => ['wrong-token'], + ]); +}); + +describe('GitLab system hook merge request', function () { + test('an opened merge request queues a preview deployment', function () { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source); + + gitlabSystemHookSend($this, gitlabSystemHookMergeRequest(), 'system-hook-token')->assertOk(); + + expect(ApplicationPreview::query()->where('application_id', $application->id)->pluck('pull_request_id')->all())->toBe([1]) + ->and(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->sole()->pull_request_id)->toBe(1); + }); +}); + +describe('GitLab system hook events without a deployment', function () { + test('the event is ignored without an error', function (array $payload) { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source); + + gitlabSystemHookSend($this, $payload, 'system-hook-token') + ->assertOk() + ->assertJsonPath('status', 'failed') + ->assertJsonPath('message', 'Event not allowed. Only push and merge_request events are allowed.'); + + expect(gitlabSystemHookDeployments($application))->toBe([]); + })->with([ + 'project_create' => [['event_name' => 'project_create', 'project_id' => 15, 'name' => 'Diaspora', 'path_with_namespace' => 'mike/diaspora']], + 'user_create' => [['event_name' => 'user_create', 'user_id' => 41, 'username' => 'js', 'email' => 'js@gitlabhq.com']], + 'repository_update' => [['event_name' => 'repository_update', 'project_id' => 15, 'changes' => [['ref' => 'refs/heads/main', 'after' => gitlabSystemHookSha()]], 'refs' => ['refs/heads/main']]], + 'tag_push' => [['event_name' => 'tag_push', 'ref' => 'refs/tags/v1.0.0', 'after' => gitlabSystemHookSha(), 'project_id' => 15, 'commits' => []]], + ]); +}); + +describe('GitLab project webhook', function () { + test('a push to the tracked branch still queues a deployment', function () { + $source = gitlabSystemHookSource('system-hook-token'); + $application = gitlabSystemHookApplication($source); + $payload = gitlabSystemHookRuntimePush(); + unset($payload['event_name'], $payload['project_id']); + + gitlabSystemHookSend($this, $payload, 'system-hook-token', event: 'Push Hook')->assertOk(); + + expect(gitlabSystemHookDeployments($application))->toBe([gitlabSystemHookSha()]); + }); + + test('a payload without object_kind is still rejected', function () { + $application = gitlabSystemHookApplication(gitlabSystemHookSource('system-hook-token')); + + gitlabSystemHookSend($this, gitlabSystemHookDocumentedPush(), 'system-hook-token', event: 'Push Hook') + ->assertOk() + ->assertJsonPath('message', 'Event not allowed. Only push and merge_request events are allowed.'); + + expect(gitlabSystemHookDeployments($application))->toBe([]); + }); +});