diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index d38ef54d6..6acd3b0a4 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -13,8 +13,10 @@ class RestartService public function handle(Service $service, bool $pullLatestImages) { - StopService::run($service); - - return StartService::run($service, $pullLatestImages); + return StartService::run( + service: $service, + pullLatestImages: $pullLatestImages, + stopBeforeStart: true, + ); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index d3d99ff78..89817506d 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -19,7 +19,7 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - if ($stopBeforeStart) { + if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); @@ -53,4 +53,9 @@ class StartService return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } + + private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool + { + return $stopBeforeStart && ! $pullLatestImages; + } } diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 09563a2c3..666e98a18 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; - $server->update([ - 'ip' => '1.2.3.4', - ]); + if (isCloud()) { + $server->update([ + 'ip' => '1.2.3.4', + ]); + } else { + $server->forceDisableServer(); + } } } } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index f1fd0c40f..0463790eb 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -81,6 +81,10 @@ trait MatchesManualWebhookApplications $path = data_get($parts, 'path'); } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { $path = Str::after($gitRepository, ':'); + // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". + // Strip the leading numeric port segment so the path matches the webhook + // payload's owner/repo, consistent with convertGitUrl() in shared.php. + $path = preg_replace('#^\d+/#', '', $path) ?? $path; } else { $path = $gitRepository; } diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index 0c0c000d5..d40d6778e 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -198,7 +198,7 @@ @if ($showWhatsNewModal) -
Release notes
', + 'published_at' => Carbon::parse('2026-05-01'), + 'is_read' => false, + ], + ]); + } + + public function getUnreadCountForUser(User $user): int + { + return 1; + } + }); + + Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar']) + ->call('openWhatsNewModal') + ->assertSee('Changelog') + ->assertSee('z-[60]', false) + ->assertSee('closeWhatsNewModal', false); +}); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index be2417462..3f49ff43d 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -509,6 +509,48 @@ describe('Manual Webhook Repository Matching', function () { expect($response->getContent())->not->toContain('No applications found'); }); + test('gitlab matches scp-style ssh repository URL with custom port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + + test('gitlab matches scp-style ssh repository URL without port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + test('github matches repository case-insensitively', function () { $app = createApplicationWithWebhook(overrides: [ 'git_repository' => 'https://github.com/Test-Org/Test-Repo.git', diff --git a/tests/Unit/Service/StartServicePullLatestRestartTest.php b/tests/Unit/Service/StartServicePullLatestRestartTest.php new file mode 100644 index 000000000..ce138ba65 --- /dev/null +++ b/tests/Unit/Service/StartServicePullLatestRestartTest.php @@ -0,0 +1,36 @@ +invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse(); +}); + +it('still stops a service before a regular restart', function () { + $method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting'); + + expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue() + ->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse(); +}); + +it('routes service restart actions through start service with deferred stop semantics', function () { + $service = Mockery::mock(Service::class); + + $stopService = Mockery::mock(StopService::class); + $stopService->shouldNotReceive('handle'); + app()->instance(StopService::class, $stopService); + + $startService = Mockery::mock(StartService::class); + $startService->shouldReceive('handle') + ->once() + ->with($service, true, true) + ->andReturn('restart queued'); + app()->instance(StartService::class, $startService); + + expect(RestartService::run($service, true))->toBe('restart queued'); +});