diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php index 661abc8d43..c4cfd155be 100644 --- a/app/Actions/Application/CleanupPreviewDeployment.php +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -4,6 +4,7 @@ namespace App\Actions\Application; use App\Enums\ApplicationDeploymentStatus; use App\Jobs\DeleteResourceJob; +use App\Jobs\SyncGithubDeploymentJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -37,6 +38,13 @@ class CleanupPreviewDeployment 'status' => 'success', ]; + // GitHub must not show a closed pull request as deployed, also when the server is down. + try { + SyncGithubDeploymentJob::deactivatePullRequest($application, $pull_request_id); + } catch (\Throwable $e) { + \Log::warning("Failed to queue GitHub deployment deactivation for application {$application->id}: {$e->getMessage()}"); + } + $server = $application->destination->server; if (! $server->isFunctional()) { diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index b494eda616..2c21c480b1 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -7,6 +7,7 @@ use App\Actions\Database\StartDatabase; use App\Actions\Service\StartService; use App\Enums\ApplicationDeploymentStatus; use App\Http\Controllers\Controller; +use App\Jobs\SyncGithubDeploymentJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -277,6 +278,13 @@ class DeployController extends Controller $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; $cancelled = true; + try { + // The query update above fires no model event. + SyncGithubDeploymentJob::dispatchFor($deployment); + } catch (\Throwable $e) { + \Log::warning("Failed to queue the GitHub deployment sync for cancelled deployment {$deployment->id}: {$e->getMessage()}"); + } + try { $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); CleanupCancelledDeployment::run($deployment, (int) $teamId); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f95a85a87a..8576c388a4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -6532,6 +6532,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); public function failed(Throwable $exception): void { $this->failDeployment(); + // A failed remote command can store the failed status with a query update, which fires no model event. + try { + SyncGithubDeploymentJob::dispatchFor($this->application_deployment_queue->refresh()); + } catch (Throwable $e) { + $this->logStatusTransitionSideEffectFailure('Queueing the GitHub deployment sync failed', $e); + } // failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here. // Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index 71920d6d76..59ceb7612d 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -29,10 +30,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue try { $github_access_token = generateGithubJwt($this->github_app); - $response = Http::GitSource($this->github_app->api_url)->withHeaders([ + $github = Http::GitSource($this->github_app->api_url)->withHeaders([ 'Authorization' => "Bearer $github_access_token", 'Accept' => 'application/vnd.github+json', - ])->get("{$this->github_app->api_url}/app"); + ]); + $response = $github->get("{$this->github_app->api_url}/app"); if (! $response->successful()) { throw new \RuntimeException('Failed to fetch GitHub app permissions: '.$response->body()); @@ -44,6 +46,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue $this->github_app->contents = data_get($permissions, 'contents'); $this->github_app->metadata = data_get($permissions, 'metadata'); $this->github_app->pull_requests = data_get($permissions, 'pull_requests'); + $this->github_app->deployments = $this->approvedInstallationPermission($github, 'deployments'); $this->github_app->administration = data_get($permissions, 'administration'); $this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners'); $this->github_app->actions = data_get($permissions, 'actions'); @@ -57,4 +60,22 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue throw $e; } } + + /** + * GET /app returns the permissions the App requests. A new permission + * counts only after the installation approves it. + */ + private function approvedInstallationPermission(PendingRequest $github, string $permission): ?string + { + if (! $this->github_app->installation_id) { + return null; + } + + $response = $github->get("{$this->github_app->api_url}/app/installations/{$this->github_app->installation_id}"); + if (! $response->successful()) { + throw new \RuntimeException('Failed to fetch GitHub app installation permissions: '.$response->body()); + } + + return data_get($response->json(), "permissions.{$permission}"); + } } diff --git a/app/Jobs/SyncGithubDeploymentJob.php b/app/Jobs/SyncGithubDeploymentJob.php new file mode 100644 index 0000000000..2a3cc9702b --- /dev/null +++ b/app/Jobs/SyncGithubDeploymentJob.php @@ -0,0 +1,243 @@ +onQueue('high'); + } + + /** + * 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, + ])); + + 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, + ]); + + 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}")['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; + } +} diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 60d65804e2..3925f8a793 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -34,6 +34,8 @@ class Change extends Component public ?bool $preview_deployment_permissions = true; + public ?bool $deployment_status_permissions = true; + public ?bool $administration = false; public ?bool $github_runners = false; @@ -198,7 +200,7 @@ class Change extends Component public function checkPermissions() { try { - $this->authorize('view', $this->github_app); + $this->authorize('update', $this->github_app); // Validate required fields before attempting to fetch permissions $missingFields = []; diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index a7cb96ef84..80863d9678 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -4,6 +4,7 @@ namespace App\Mcp\Tools; use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; +use App\Jobs\SyncGithubDeploymentJob; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesTeam; @@ -88,6 +89,13 @@ class CancelDeployment extends Tool // Cancellation is still recorded even if the helper container cleanup fails. } + try { + // The query update above fires no model event. + SyncGithubDeploymentJob::dispatchFor($deployment); + } catch (\Throwable $e) { + \Log::warning("Failed to queue the GitHub deployment sync after cancelling deployment {$deployment->id}: {$e->getMessage()}"); + } + auditLog('mcp.deployment.cancelled', [ 'team_id' => $teamId, 'deployment_uuid' => $uuid, diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index e7c7f69cb8..4b50b77c63 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; use App\Enums\ApplicationDeploymentStatus; +use App\Jobs\SyncGithubDeploymentJob; use App\Services\Access\AccessResolver; use App\Services\Security\SensitiveDataRedactor; use Illuminate\Database\Eloquent\Builder; @@ -100,6 +101,22 @@ class ApplicationDeploymentQueue extends Model ]); }); + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! $deployment->wasChanged('status')) { + return; + } + + // A failed GitHub sync must not stop the status transition or the deployment queue. + try { + SyncGithubDeploymentJob::dispatchFor($deployment); + } catch (\Throwable $e) { + Log::warning('Failed to queue the GitHub deployment sync.', [ + 'deployment_uuid' => $deployment->deployment_uuid, + 'error' => $e->getMessage(), + ]); + } + }); + static::updated(function (ApplicationDeploymentQueue $deployment): void { if (! auth()->check() || ! $deployment->wasChanged('status') diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 6de5989747..8735818465 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -35,6 +35,7 @@ class GithubApp extends BaseModel 'contents', 'metadata', 'pull_requests', + 'deployments', 'administration', 'organization_self_hosted_runners', 'actions', @@ -149,6 +150,24 @@ class GithubApp extends BaseModel return $this->hasMany(GithubRunnerConfig::class); } + /** + * Whether Coolify can create GitHub Deployments and their statuses with this App. + */ + public function canSyncDeployments(): bool + { + return ! $this->is_public && $this->missingDeploymentRequirements() === []; + } + + /** + * Permissions that GitHub deployments need but the App does not have. + * + * @return array + */ + public function missingDeploymentRequirements(): array + { + return $this->deployments === 'write' ? [] : ['Repository permission "Deployments": write']; + } + /** * Permissions and webhook events that GitHub Actions runners need but the App does not have. * diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 5a0a9f7a42..f3a54bce3d 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -3,6 +3,7 @@ namespace App\Services\ServerTransfer; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Models\CloudProviderToken; use App\Models\DnsProviderZone; @@ -427,6 +428,16 @@ class ServerTransferExporter $warnings[] = 'Some applications use a system-wide Gitea App, which is not exported. On import, Coolify will re-link to a matching system-wide Gitea App on the target instance (by UUID) if one exists.'; } + $hasActivePreviewGithubDeployments = ApplicationDeploymentQueue::query() + ->whereIn('application_id', $applications->pluck('id')) + ->where('pull_request_id', '!=', 0) + ->whereNotNull('github_deployment_id') + ->where(fn ($query) => $query->whereNull('github_deployment_state')->orWhere('github_deployment_state', '!=', 'inactive')) + ->exists(); + if ($hasActivePreviewGithubDeployments) { + $warnings[] = 'Deployment history is not transferred, so the target instance cannot mark the GitHub Deployments of current preview deployments as inactive when their pull requests close. Mark them as inactive on GitHub, or redeploy the previews on the target instance.'; + } + if ($dependencies['s3_storages']->isNotEmpty()) { $warnings[] = 'S3 storage credentials were exported. Confirm endpoint access from the target instance and that bucket policies still apply.'; } @@ -1405,6 +1416,7 @@ class ServerTransferExporter 'contents' => $app->contents, 'metadata' => $app->metadata, 'pull_requests' => $app->pull_requests, + 'deployments' => $app->deployments, 'administration' => $app->administration, 'organization_self_hosted_runners' => $app->organization_self_hosted_runners, 'actions' => $app->actions, diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 1cc4aed13e..bb270f1d04 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -1855,6 +1855,7 @@ class ServerTransferImporter 'contents' => data_get($payload, 'contents'), 'metadata' => data_get($payload, 'metadata'), 'pull_requests' => data_get($payload, 'pull_requests'), + 'deployments' => data_get($payload, 'deployments'), 'administration' => data_get($payload, 'administration'), 'organization_self_hosted_runners' => data_get($payload, 'organization_self_hosted_runners'), 'actions' => data_get($payload, 'actions'), diff --git a/database/migrations/2026_11_03_195902_add_github_deployments_columns.php b/database/migrations/2026_11_03_195902_add_github_deployments_columns.php new file mode 100644 index 0000000000..77498b99a9 --- /dev/null +++ b/database/migrations/2026_11_03_195902_add_github_deployments_columns.php @@ -0,0 +1,31 @@ +string('deployments')->nullable()->after('pull_requests'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->unsignedBigInteger('github_deployment_id')->nullable(); + $table->string('github_deployment_state')->nullable(); + }); + } + + public function down(): void + { + Schema::table('github_apps', function (Blueprint $table) { + $table->dropColumn('deployments'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn(['github_deployment_id', 'github_deployment_state']); + }); + } +}; diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index d1c6f8193c..dfc1e90204 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -319,6 +319,13 @@ ]" helper="Write access lets Coolify post deployment status and links on pull requests." /> + + + x-on:click.prevent="createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint, $wire.preview_deployment_permissions, $wire.deployment_status_permissions, {{ Illuminate\Support\Js::from($administration) }}, $wire.github_runners)"> Register with GitHub @@ -371,7 +378,7 @@