onQueue('default'); } /** * Queue a sync when the deployment belongs to a GitHub App that can write deployments. */ public static function dispatchFor(ApplicationDeploymentQueue $deployment): void { if (filled($deployment->parent_deployment_uuid)) { // Deployments to additional servers are part of the main deployment. return; } // A soft-deleted application still needs its last status on GitHub. $source = Application::withTrashed()->find($deployment->application_id)?->source; if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) { return; } self::dispatch($deployment->id); } /** * Mark the GitHub Deployments of a closed pull request as inactive. This also covers * deployments that GitHub does not know yet, so a late sync cannot create one. */ public static function deactivatePullRequest(Application $application, int $pullRequestId): void { ApplicationDeploymentQueue::query() ->where('application_id', $application->id) ->where('pull_request_id', $pullRequestId) ->where(fn ($query) => $query->whereNull('github_deployment_state')->orWhere('github_deployment_state', '!=', 'inactive')) ->pluck('id') ->each(fn (int $deploymentQueueId) => self::dispatch($deploymentQueueId, deactivate: true)); } /** * All syncs of one deployment share this lock, and handle() reads the state while it * holds the lock. Thus a deactivation cannot happen between that read and the requests. * * @return array */ public function middleware(): array { return [(new WithoutOverlapping("github-deployment:{$this->deploymentQueueId}"))->releaseAfter(self::LOCK_RELEASE_DELAY)->expireAfter(self::LOCK_EXPIRES_AFTER)]; } /** * A sync can wait for a lock that is held up to its expiry. Fixed at dispatch. */ public function retryUntil(): \DateTimeInterface { return now()->addSeconds(self::LOCK_EXPIRES_AFTER + 60); } public function handle(): void { $deployment = ApplicationDeploymentQueue::find($this->deploymentQueueId); $application = $deployment ? Application::withTrashed()->find($deployment->application_id) : null; $source = $application?->source; if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) { return; } // Inactive is the end state: a closed pull request must not show as deployed again. if ($deployment->github_deployment_state === 'inactive') { return; } $state = $this->deactivate ? 'inactive' : $this->githubState($deployment->status); if ($state === null || $state === $deployment->github_deployment_state) { return; } $repository = gitRepositorySlug($application->git_repository); $githubDeploymentId = $deployment->github_deployment_id; if (! $githubDeploymentId) { if ($state === 'inactive') { // Keep the marker, so a late sync does not create a GitHub Deployment. ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => 'inactive']); return; } // The deployment ended before GitHub knew about it. The deployment job sets // horizon_job_worker together with IN_PROGRESS, so a failure without it never started. if ($state === 'error' || ($state === 'failure' && blank($deployment->horizon_job_worker))) { return; } $githubDeploymentId = $this->createGithubDeployment($deployment, $application, $source, $repository); ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_id' => $githubDeploymentId]); } githubApi($source, "repos/{$repository}/deployments/{$githubDeploymentId}/statuses", 'post', array_filter([ 'state' => $state, 'description' => $this->description($state), 'log_url' => $this->logUrl($deployment, $application), 'environment_url' => $state === 'success' ? $this->environmentUrl($deployment, $application) : null, ]), timeout: self::HTTP_TIMEOUT_SECONDS); ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => $state]); } public function failed(Throwable $exception): void { Log::warning('Failed to sync the GitHub deployment.', [ 'deployment_queue_id' => $this->deploymentQueueId, 'error' => $exception->getMessage(), ]); } private function githubState(string $status): ?string { return match ($status) { ApplicationDeploymentStatus::IN_PROGRESS->value => 'in_progress', ApplicationDeploymentStatus::FINISHED->value => 'success', ApplicationDeploymentStatus::FAILED->value => 'failure', ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'error', default => null, }; } private function createGithubDeployment(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): int { $isPreview = $deployment->pull_request_id !== 0; $environment = $isPreview ? "{$application->name} (preview)" : $application->name; $response = githubApi($source, "repos/{$repository}/deployments", 'post', [ 'ref' => $this->ref($deployment, $application, $source, $repository), 'environment' => Str::limit($environment, 255, ''), 'description' => Str::limit($isPreview ? "Coolify preview for PR #{$deployment->pull_request_id}" : 'Coolify deployment', 140, ''), // GitHub would merge the default branch into the ref, or wait for all commit checks. 'auto_merge' => false, 'required_contexts' => [], 'transient_environment' => $isPreview, 'production_environment' => ! $isPreview, ], timeout: self::HTTP_TIMEOUT_SECONDS); return (int) $response['data']->get('id'); } /** * GitHub links a deployment to a pull request only when its ref is the head branch. A fork * branch is not in the base repository, so a fork preview uses the head commit. GitHub * resolves a branch to its current commit, so the branch is used only while it still * points to the deployed commit. */ private function ref(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): string { $commit = filled($deployment->commit) && $deployment->commit !== 'HEAD' ? $deployment->commit : null; if ($deployment->pull_request_id !== 0) { $pullRequest = githubApi($source, "repos/{$repository}/pulls/{$deployment->pull_request_id}", timeout: self::HTTP_TIMEOUT_SECONDS)['data']; $headRepository = (string) data_get($pullRequest, 'head.repo.full_name', ''); $baseRepository = (string) data_get($pullRequest, 'base.repo.full_name', $repository); $headBranch = data_get($pullRequest, 'head.ref'); $headCommit = (string) data_get($pullRequest, 'head.sha'); $isDeployedCommit = $commit === null || strcasecmp($commit, $headCommit) === 0; if (filled($headBranch) && strcasecmp($headRepository, $baseRepository) === 0 && $isDeployedCommit) { return $headBranch; } return $commit ?? $headCommit; } // The branch can move past the deployed commit before the GitHub deployment is created. return $commit ?? $application->git_branch; } private function description(string $state): string { return match ($state) { 'in_progress' => 'Deploying with Coolify.', 'success' => 'Deployed with Coolify.', 'failure' => 'The Coolify deployment failed.', 'error' => 'The Coolify deployment was cancelled.', default => 'The pull request was closed.', }; } private function logUrl(ApplicationDeploymentQueue $deployment, Application $application): string { return base_url()."/project/{$application->environment->project->uuid}/environment/{$application->environment->uuid}/application/{$application->uuid}/deployment/{$deployment->deployment_uuid}"; } private function environmentUrl(ApplicationDeploymentQueue $deployment, Application $application): ?string { if ($deployment->pull_request_id !== 0) { $preview = $application->previews()->where('pull_request_id', $deployment->pull_request_id)->first(); $domains = $application->build_pack === 'dockercompose' ? collect(json_decode((string) $preview?->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first() : $preview?->fqdn; } else { $domains = $application->build_pack === 'dockercompose' ? collect(json_decode((string) $application->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first() : $application->fqdn; } $url = trim((string) str($domains)->explode(',')->first()); return str_starts_with($url, 'http://') || str_starts_with($url, 'https://') ? $url : null; } }