Files
coolify/app/Jobs/SyncGithubDeploymentJob.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

256 lines
11 KiB
PHP

<?php
namespace App\Jobs;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\GithubApp;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Mirrors the status of a Coolify deployment to a GitHub Deployment, so GitHub shows the
* branch or pull request as deployed. Each run sends the current status, so a late or
* repeated run converges to the same result.
*/
class SyncGithubDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
/**
* API errors are tried three times. Waits for the per-deployment lock are not counted, so a
* terminal sync that waits behind a slow sync still runs (see retryUntil()).
*/
public $maxExceptions = 3;
public $backoff = 10;
/**
* Seconds that one request waits for GitHub. A slow GitHub must not hold a queue worker.
*/
public const HTTP_TIMEOUT_SECONDS = 10;
/**
* Longer than the worst case of all requests with their retries, shorter than the lock
* expiry, so the per-deployment lock is held while the job runs.
*/
public $timeout = 100;
private const LOCK_EXPIRES_AFTER = 120;
private const LOCK_RELEASE_DELAY = 5;
public function __construct(public int $deploymentQueueId, public bool $deactivate = false)
{
// Not the deployment queue: a slow GitHub must not delay deployments.
$this->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<int, object>
*/
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;
}
}