feat(webhooks): accept GitLab system hook events for deployments (#12185)

This commit is contained in:
Andras Bacsai
2026-10-18 12:49:16 +02:00
committed by GitHub
parent 7e077d9501
commit 4553454f89
3 changed files with 307 additions and 1 deletions
+28
View File
@@ -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<string, mixed> $payload
* @return Collection<string, mixed>
*/
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.
*
@@ -95,7 +95,7 @@
</x-application.settings-section>
<x-application.settings-section title="Webhook"
description="Configure this webhook URL in your GitLab project settings (Settings → Webhooks).">
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.">
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<x-forms.input readonly label="Webhook URL"
@@ -0,0 +1,278 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
InstanceSettings::forceCreate(['id' => 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<string, mixed>
*/
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<string, mixed>
*/
function gitlabSystemHookRuntimePush(int $projectId = 15): array
{
$payload = gitlabSystemHookDocumentedPush($projectId);
$payload['object_kind'] = 'push';
$payload['project']['id'] = $projectId;
return $payload;
}
/**
* @return array<string, mixed>
*/
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<string, mixed> $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([]);
});
});