diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c9f0f1eef..adc12ac91 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2088,6 +2088,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; } + private function resolvePinnedCommit(): ?string + { + if ($this->pull_request_id !== 0 || $this->rollback) { + return null; + } + + $pinnedCommit = str(data_get($this->application, 'git_commit_sha'))->trim()->value(); + if ($pinnedCommit === '' || $pinnedCommit === 'HEAD') { + return null; + } + + return $pinnedCommit; + } + private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) { @@ -2101,6 +2115,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } + $pinnedCommit = $this->resolvePinnedCommit(); + if ($pinnedCommit) { + $this->commit = $pinnedCommit; + if ($this->application_deployment_queue->commit !== $pinnedCommit) { + $this->application_deployment_queue->commit = $pinnedCommit; + $this->application_deployment_queue->save(); + } + } $this->generate_git_import_commands(); $local_branch = $this->branch; if ($this->pull_request_id !== 0) { @@ -2147,7 +2169,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ], ); } - if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { + if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && ! $pinnedCommit) { // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings) // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines // Note: Git warnings can be on the same line as the result (no newline) @@ -2194,6 +2216,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ] ); $this->create_workdir(); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git rev-parse HEAD"), + 'hidden' => true, + 'save' => 'checked_out_commit', + 'ignore_errors' => true, + ] + ); + if ($this->saved_outputs->get('checked_out_commit')) { + $checkedOutCommit = str($this->saved_outputs->get('checked_out_commit'))->value(); + if ($checkedOutCommit !== '') { + $this->application_deployment_queue->addLogEntry("Checked out commit: {$checkedOutCommit}", hidden: true); + } + } $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"), diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index c522cd0ca..f541b1943 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -28,6 +28,11 @@ function queue_application_deployment(Application $application, string $deployme $destination_id = $destination->id; } + $pinnedCommit = str($application->git_commit_sha ?? '')->trim()->value(); + if ($pull_request_id === 0 && ! $rollback && $pinnedCommit !== '' && $pinnedCommit !== 'HEAD') { + $commit = $pinnedCommit; + } + // Check if the deployment queue is full for this server $serverForQueueCheck = $server ?? Server::find($server_id); $queue_limit = $serverForQueueCheck->settings->deployment_queue_limit ?? 25; diff --git a/tests/Feature/PinnedCommitDeploymentTest.php b/tests/Feature/PinnedCommitDeploymentTest.php new file mode 100644 index 000000000..229ebba62 --- /dev/null +++ b/tests/Feature/PinnedCommitDeploymentTest.php @@ -0,0 +1,72 @@ +team = Team::factory()->create(); + $this->project = Project::create([ + 'name' => 'Pinned Commit Project', + 'team_id' => $this->team->id, + ]); + $this->environment = Environment::create([ + 'name' => 'production', + 'project_id' => $this->project->id, + ]); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->first(); +}); + +test('pinned commit overrides queued commit', function () { + $pinnedCommit = str_repeat('a', 40); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_type' => StandaloneDocker::class, + 'destination_id' => $this->destination->id, + 'git_commit_sha' => $pinnedCommit, + ]); + + $deploymentUuid = 'pinned-commit-deployment'; + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + commit: str_repeat('b', 40), + is_webhook: true + ); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $deploymentUuid)->first(); + expect($deployment)->not->toBeNull(); + expect($deployment->commit)->toBe($pinnedCommit); +}); + +test('un-pinned commit keeps the requested commit', function () { + $requestedCommit = str_repeat('c', 40); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_type' => StandaloneDocker::class, + 'destination_id' => $this->destination->id, + 'git_commit_sha' => 'HEAD', + ]); + + $deploymentUuid = 'head-commit-deployment'; + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + commit: $requestedCommit + ); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $deploymentUuid)->first(); + expect($deployment)->not->toBeNull(); + expect($deployment->commit)->toBe($requestedCommit); +}); diff --git a/tests/Unit/PinnedCommitResolutionTest.php b/tests/Unit/PinnedCommitResolutionTest.php new file mode 100644 index 000000000..448e23317 --- /dev/null +++ b/tests/Unit/PinnedCommitResolutionTest.php @@ -0,0 +1,117 @@ +getProperty($property); + $prop->setAccessible(true); + $prop->setValue($job, $value); +} + +function getJobProperty(object $job, string $property): mixed +{ + $reflection = new ReflectionClass($job); + $prop = $reflection->getProperty($property); + $prop->setAccessible(true); + + return $prop->getValue($job); +} + +it('keeps pinned commit when resolving git commit sha', function () { + $pinnedCommit = str_repeat('a', 40); + $remoteCommit = str_repeat('b', 40); + $lsRemoteOutput = $remoteCommit."\trefs/heads/main"; + + $settings = new ApplicationSetting([ + 'include_source_commit_in_build' => false, + 'use_build_secrets' => false, + 'is_git_submodules_enabled' => false, + 'is_git_lfs_enabled' => false, + 'is_git_shallow_clone_enabled' => false, + ]); + $application = Application::factory()->make([ + 'git_repository' => 'https://example.com/acme/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => $pinnedCommit, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + ]); + $application->setRelation('settings', $settings); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->commit = $pinnedCommit; + $queue->shouldReceive('save')->never(); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldReceive('execute_remote_command')->andReturnNull(); + + setJobProperty($job, 'application', $application); + setJobProperty($job, 'application_deployment_queue', $queue); + setJobProperty($job, 'deployment_uuid', 'deployment-uuid'); + setJobProperty($job, 'pull_request_id', 0); + setJobProperty($job, 'commit', 'HEAD'); + setJobProperty($job, 'rollback', false); + setJobProperty($job, 'git_type', null); + setJobProperty($job, 'saved_outputs', collect([ + 'git_commit_sha' => str($lsRemoteOutput), + ])); + setJobProperty($job, 'source', 'other'); + + $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'); + $method->setAccessible(true); + $method->invoke($job); + + expect(getJobProperty($job, 'commit'))->toBe($pinnedCommit); +}); + +it('updates commit from ls-remote when no pinned commit is set', function () { + $remoteCommit = str_repeat('c', 40); + $lsRemoteOutput = $remoteCommit."\trefs/heads/main"; + + $settings = new ApplicationSetting([ + 'include_source_commit_in_build' => false, + 'use_build_secrets' => false, + 'is_git_submodules_enabled' => false, + 'is_git_lfs_enabled' => false, + 'is_git_shallow_clone_enabled' => false, + ]); + $application = Application::factory()->make([ + 'git_repository' => 'https://example.com/acme/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + ]); + $application->setRelation('settings', $settings); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->commit = 'HEAD'; + $queue->shouldReceive('save')->once(); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldReceive('execute_remote_command')->andReturnNull(); + + setJobProperty($job, 'application', $application); + setJobProperty($job, 'application_deployment_queue', $queue); + setJobProperty($job, 'deployment_uuid', 'deployment-uuid'); + setJobProperty($job, 'pull_request_id', 0); + setJobProperty($job, 'commit', 'HEAD'); + setJobProperty($job, 'rollback', false); + setJobProperty($job, 'git_type', null); + setJobProperty($job, 'saved_outputs', collect([ + 'git_commit_sha' => str($lsRemoteOutput), + ])); + setJobProperty($job, 'source', 'other'); + + $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'); + $method->setAccessible(true); + $method->invoke($job); + + expect(getJobProperty($job, 'commit'))->toBe($remoteCommit); + expect($queue->commit)->toBe($remoteCommit); +});