feat(github): sync deployment status to GitHub Deployments (#12451)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 11:37:10 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0becfba911
commit bf4b6ad921
18 changed files with 760 additions and 9 deletions
@@ -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()) {
@@ -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);
+6
View File
@@ -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
+23 -2
View File
@@ -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}");
}
}
+243
View File
@@ -0,0 +1,243 @@
<?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;
private const LOCK_EXPIRES_AFTER = 120;
private const LOCK_RELEASE_DELAY = 5;
public function __construct(public int $deploymentQueueId, public bool $deactivate = false)
{
$this->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<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,
]));
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;
}
}
+3 -1
View File
@@ -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 = [];
+8
View File
@@ -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,
+17
View File
@@ -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')
+19
View File
@@ -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<int, string>
*/
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.
*
@@ -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,
@@ -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'),
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -319,6 +319,13 @@
]"
helper="Write access lets Coolify post deployment status and links on pull requests." />
<x-forms.listbox id="deployment_status_permissions"
label="GitHub deployments" :options="[
['value' => false, 'label' => 'Do not create GitHub deployments'],
['value' => true, 'label' => 'Create GitHub deployments'],
]"
helper="Deployments (write) access lets Coolify show each deployment and its status on GitHub, so pull requests show the preview as deployed." />
<x-forms.listbox id="github_runners" label="GitHub Actions runners"
:disabled="blank($github_app->organization)"
:options="[
@@ -331,7 +338,7 @@
<button type="button"
class="button mt-auto w-full justify-center button-highlighted"
x-on:click.prevent="createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint, $wire.preview_deployment_permissions, {{ Illuminate\Support\Js::from($administration) }}, $wire.github_runners)">
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
</button>
</div>
@@ -371,7 +378,7 @@
<script>
function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_webhook_endpoint,
preview_deployment_permissions, administration, github_runners) {
preview_deployment_permissions, deployment_status_permissions, administration, github_runners) {
const {
organization,
html_url
@@ -408,6 +415,9 @@
default_permissions.pull_requests = 'write';
default_events.push('pull_request');
}
if (deployment_status_permissions) {
default_permissions.deployments = 'write';
}
if (administration) {
default_permissions.administration = 'write';
}
@@ -3,10 +3,12 @@
description="GitHub permissions currently granted to this App.">
<x-slot:actions>
@can('view', $github_app)
<x-forms.button type="button" wire:click.prevent="checkPermissions">
<x-reicon name="refresh" class="size-3.5" />
Refetch
</x-forms.button>
@can('update', $github_app)
<x-forms.button type="button" wire:click.prevent="checkPermissions">
<x-reicon name="refresh" class="size-3.5" />
Refetch
</x-forms.button>
@endcan
<a href="{{ getPermissionsPath($github_app) }}" class="button">
Update on GitHub
<x-external-link />
@@ -25,6 +27,35 @@
</div>
</x-application.settings-section>
@php($missingDeploymentRequirements = $github_app->missingDeploymentRequirements())
<x-application.settings-section title="GitHub deployments"
description="Coolify creates a GitHub deployment for each deployment and updates its status, so branches and pull requests show what is deployed.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="view" :canResource="$github_app"
label="Deployments" readonly placeholder="N/A" :value="$github_app->deployments"
helper="Repository permission. Write access is needed to create deployments and their statuses." />
</div>
@if ($missingDeploymentRequirements !== [])
<x-callout type="info" title="Not ready for GitHub deployments" class="mt-4">
Update the App on GitHub, then click Refetch. Missing:
<ul class="mt-1 list-disc pl-4">
@foreach ($missingDeploymentRequirements as $requirement)
<li>{{ $requirement }}</li>
@endforeach
</ul>
@can('view', $github_app)
<div class="mt-2">
<a href="{{ getPermissionsPath($github_app) }}" class="button" target="_blank"
rel="noopener noreferrer">
Update on GitHub
<x-external-link />
</a>
</div>
@endcan
</x-callout>
@endif
</x-application.settings-section>
@php($missingRunnerRequirements = $github_app->missingRunnerRequirements())
<x-application.settings-section title="GitHub Actions runners"
description="Your servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a server.">
@@ -690,6 +690,7 @@ describe('GitHub Source Change Component', function () {
'pull_requests' => 'write',
],
]),
'https://api.github.com/app/installations/67890' => Http::response(['permissions' => []]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
@@ -713,6 +714,40 @@ describe('GitHub Source Change Component', function () {
->and($githubApp->pull_requests)->toBe('write');
});
test('checkPermissions does not let a member refetch permissions', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Http::preventStrayRequests();
Http::fake();
Livewire::actingAs($member)
->withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->call('checkPermissions')
->assertNotDispatched('success');
Http::assertNothingSent();
expect($githubApp->fresh()->contents)->toBeNull()
->and($githubApp->fresh()->deployments)->toBeNull();
});
test('sync name uses normalized resolvable ghe dot com api url', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
@@ -630,6 +630,7 @@ describe('GitHub App runner permissions', function () {
'permissions' => ['contents' => 'read', 'metadata' => 'read', 'organization_self_hosted_runners' => 'write', 'actions' => 'read'],
'events' => ['push', 'workflow_job'],
]),
"https://api.github.com/app/installations/{$this->githubApp->installation_id}" => Http::response(['permissions' => []]),
]);
GithubAppPermissionJob::dispatchSync($this->githubApp);
@@ -0,0 +1,296 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\SyncGithubDeploymentJob;
use App\Livewire\Source\Github\Change;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['fqdn' => 'https://coolify.example.com']));
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
openssl_pkey_export(openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]), $pemKey);
$this->githubApp = GithubApp::create([
'name' => 'Private GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => PrivateKey::create(['name' => 'Key', 'private_key' => $pemKey, 'team_id' => $this->team->id])->id,
'is_public' => false,
'deployments' => 'write',
'team_id' => $this->team->id,
]);
$this->application = Application::factory()->create([
'name' => 'web',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'source_type' => GithubApp::class,
'source_id' => $this->githubApp->id,
'git_repository' => 'acme/shop',
'git_branch' => 'main',
'fqdn' => 'https://shop.example.com,https://www.shop.example.com',
]);
// The model hook would run the job on the sync queue; the tests run it explicitly.
Queue::fake([SyncGithubDeploymentJob::class]);
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app/installations/*/access_tokens' => Http::response(['token' => 'token'], 201),
'https://api.github.com/repos/acme/shop/pulls/7' => Http::response([
'head' => ['ref' => 'feature/cart', 'sha' => 'aaa111', 'repo' => ['full_name' => 'acme/shop']],
'base' => ['repo' => ['full_name' => 'acme/shop']],
]),
'https://api.github.com/repos/acme/shop/pulls/8' => Http::response([
'head' => ['ref' => 'main', 'sha' => 'bbb222', 'repo' => ['full_name' => 'someone/shop']],
'base' => ['repo' => ['full_name' => 'acme/shop']],
]),
'https://api.github.com/repos/acme/shop/deployments' => fn () => $this->createDeploymentResponse ?? Http::response(['id' => 555], 201),
'https://api.github.com/repos/acme/shop/deployments/*/statuses' => Http::response(['id' => 1], 201),
]);
});
function githubDeployment(Application $application, array $attributes = []): ApplicationDeploymentQueue
{
return ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => (string) Str::uuid(),
'pull_request_id' => 0,
'commit' => 'HEAD',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'server_id' => $application->destination->server->id,
...$attributes,
]);
}
function syncGithubDeployment(ApplicationDeploymentQueue $deployment, bool $deactivate = false): void
{
(new SyncGithubDeploymentJob($deployment->id, $deactivate))->handle();
}
/**
* @return array<int, array<string, mixed>>
*/
function githubRequests(string $suffix): array
{
return Http::recorded(fn (Request $request): bool => $request->method() === 'POST' && str_ends_with($request->url(), $suffix))
->map(fn (array $pair): array => $pair[0]->data())
->values()
->all();
}
it('queues a sync when the status of a deployment changes', function () {
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::QUEUED->value]);
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id);
});
it('does not queue a sync when the GitHub App has no deployments permission', function () {
$this->githubApp->update(['deployments' => null]);
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::QUEUED->value]);
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
Queue::assertNotPushed(SyncGithubDeploymentJob::class);
});
it('does not queue a sync for the deployment to an additional server', function () {
$deployment = githubDeployment($this->application, [
'status' => ApplicationDeploymentStatus::QUEUED->value,
'parent_deployment_uuid' => 'main-deployment',
]);
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
Queue::assertNotPushed(SyncGithubDeploymentJob::class);
});
it('creates one GitHub deployment for the branch and sends each status', function () {
$deployment = githubDeployment($this->application);
syncGithubDeployment($deployment);
syncGithubDeployment($deployment);
$deployment->update(['status' => ApplicationDeploymentStatus::FINISHED->value]);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments'))->toBe([[
'ref' => 'main',
'environment' => 'web',
'description' => 'Coolify deployment',
'auto_merge' => false,
'required_contexts' => [],
'transient_environment' => false,
'production_environment' => true,
]]);
$statuses = githubRequests('/deployments/555/statuses');
$logUrl = "https://coolify.example.com/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$deployment->deployment_uuid}";
expect($statuses)->toBe([
['state' => 'in_progress', 'description' => 'Deploying with Coolify.', 'log_url' => $logUrl],
['state' => 'success', 'description' => 'Deployed with Coolify.', 'log_url' => $logUrl, 'environment_url' => 'https://shop.example.com'],
])->and($deployment->fresh()->only(['github_deployment_id', 'github_deployment_state']))
->toBe(['github_deployment_id' => 555, 'github_deployment_state' => 'success']);
});
it('uses the head branch of a pull request from the same repository', function () {
ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 7, 'pull_request_html_url' => 'https://github.com/acme/shop/pull/7', 'fqdn' => 'https://pr-7.shop.example.com']);
$deployment = githubDeployment($this->application, [
'pull_request_id' => 7,
'commit' => 'aaa111',
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments')[0])->toMatchArray([
'ref' => 'feature/cart',
'environment' => 'web (preview)',
'transient_environment' => true,
'production_environment' => false,
])->and(githubRequests('/statuses')[0]['environment_url'])->toBe('https://pr-7.shop.example.com');
});
it('uses the deployed commit when the head branch of a pull request moved', function () {
$deployment = githubDeployment($this->application, ['pull_request_id' => 7, 'commit' => 'old999']);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments')[0]['ref'])->toBe('old999');
});
it('uses the deployed commit for a pull request from a fork', function () {
$deployment = githubDeployment($this->application, ['pull_request_id' => 8, 'commit' => 'bbb222']);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments')[0]['ref'])->toBe('bbb222');
});
it('uses the deployed commit of a branch deployment', function () {
$deployment = githubDeployment($this->application, ['commit' => 'ccc333']);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments')[0]['ref'])->toBe('ccc333');
});
it('does not create a GitHub deployment for a deployment that was cancelled before the sync', function () {
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments'))->toBe([])
->and($deployment->fresh()->github_deployment_id)->toBeNull();
});
it('marks the GitHub deployments of a closed pull request inactive and keeps them inactive', function () {
$deployment = githubDeployment($this->application, ['pull_request_id' => 7]);
syncGithubDeployment($deployment);
$other = githubDeployment($this->application, ['pull_request_id' => 9]);
SyncGithubDeploymentJob::deactivatePullRequest($this->application, 7);
Queue::assertPushed(SyncGithubDeploymentJob::class, 1);
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id && $job->deactivate);
Queue::assertNotPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $other->id);
syncGithubDeployment($deployment, deactivate: true);
$deployment->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
syncGithubDeployment($deployment);
expect(collect(githubRequests('/deployments/555/statuses'))->pluck('state')->all())->toBe(['in_progress', 'inactive']);
});
it('does not create a GitHub deployment when the pull request closed before the first sync', function () {
$deployment = githubDeployment($this->application, ['pull_request_id' => 7]);
SyncGithubDeploymentJob::deactivatePullRequest($this->application, 7);
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id && $job->deactivate);
syncGithubDeployment($deployment, deactivate: true);
$deployment->update(['status' => ApplicationDeploymentStatus::FINISHED->value]);
syncGithubDeployment($deployment);
expect(githubRequests('/deployments'))->toBe([])
->and(githubRequests('/statuses'))->toBe([])
->and($deployment->fresh()->github_deployment_state)->toBe('inactive');
});
it('fails the sync job, not the deployment, when GitHub rejects the request', function () {
$this->createDeploymentResponse = Http::response(['message' => 'Resource not accessible by integration'], 403);
$deployment = githubDeployment($this->application);
expect(fn () => syncGithubDeployment($deployment))->toThrow(Exception::class, 'Resource not accessible by integration')
->and($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value)
->and($deployment->fresh()->github_deployment_id)->toBeNull();
});
it('shows an existing App how to add the deployments permission, and hides it after Refetch', function () {
$this->withoutVite();
$this->githubApp->update(['deployments' => null]);
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
$page = fn () => Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])
->test(Change::class)
->set('activeTab', 'permissions');
$page()->assertSee('Not ready for GitHub deployments')
->assertSee('Repository permission &quot;Deployments&quot;: write', false)
->assertSee(getPermissionsPath($this->githubApp));
Http::fake([
'https://api.github.com/app' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write'], 'events' => ['push']]),
'https://api.github.com/app/installations/67890' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write']]),
]);
GithubAppPermissionJob::dispatchSync($this->githubApp);
expect($this->githubApp->fresh()->canSyncDeployments())->toBeTrue();
$page()->assertSee('GitHub deployments')->assertDontSee('Not ready for GitHub deployments');
});
it('stays not ready until the installation approves the deployments permission', function () {
$this->githubApp->update(['deployments' => null]);
Http::fake([
'https://api.github.com/app' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write']]),
'https://api.github.com/app/installations/67890' => Http::response(['permissions' => ['contents' => 'read']]),
]);
GithubAppPermissionJob::dispatchSync($this->githubApp);
expect($this->githubApp->fresh()->deployments)->toBeNull()
->and($this->githubApp->fresh()->canSyncDeployments())->toBeFalse();
});
@@ -1471,6 +1471,7 @@ test('transfer keeps preview, backup, GitHub App runner fields and canvas positi
'organization' => 'acme',
'organization_self_hosted_runners' => 'write',
'actions' => 'read',
'deployments' => 'write',
'webhook_events' => ['workflow_job', 'push'],
'runner_group_id' => 12,
]);
@@ -1531,6 +1532,7 @@ test('transfer keeps preview, backup, GitHub App runner fields and canvas positi
->and($importedGithubApp)->toBeInstanceOf(GithubApp::class)
->and($importedGithubApp->organization_self_hosted_runners)->toBe('write')
->and($importedGithubApp->actions)->toBe('read')
->and($importedGithubApp->deployments)->toBe('write')
->and($importedGithubApp->webhook_events)->toBe(['workflow_job', 'push'])
->and($importedGithubApp->runner_group_id)->toBe(12)
->and($application->uuid)->not->toBe($this->application->uuid)