diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 018187a0d..0bd856d22 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -5,6 +5,7 @@ namespace App\Console;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
+use App\Jobs\CleanupGithubRunnerArtifactsJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleGithubRunnersJob;
@@ -57,6 +58,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
+ $this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();
} else {
// Instance Jobs
@@ -89,6 +91,7 @@ class Kernel extends ConsoleKernel
// Cleanup stale GitHub Actions runners
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
+ $this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();
}
}
diff --git a/app/Enums/GithubRunnerStatus.php b/app/Enums/GithubRunnerStatus.php
index 82da58e6d..f48fac831 100644
--- a/app/Enums/GithubRunnerStatus.php
+++ b/app/Enums/GithubRunnerStatus.php
@@ -14,6 +14,6 @@ enum GithubRunnerStatus: string
public function isActive(): bool
{
- return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning]);
+ return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning], true);
}
}
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index a3404031e..8b9850e97 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Webhook;
+use App\Enums\GithubRunnerStatus;
use App\Http\Controllers\Controller;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
@@ -9,6 +10,7 @@ use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\GithubApp;
+use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Request;
@@ -229,6 +231,14 @@ class Github extends Controller
if ($x_github_event === 'workflow_job') {
$action = data_get($payload, 'action');
$workflowJob = data_get($payload, 'workflow_job');
+ $workflowJobId = (int) data_get($workflowJob, 'id', 0);
+
+ ray("[webhook] workflow_job.{$action} received", [
+ 'workflow_job_id' => $workflowJobId,
+ 'runner_name' => data_get($workflowJob, 'runner_name'),
+ 'workflow_name' => data_get($workflowJob, 'workflow_name'),
+ 'conclusion' => data_get($workflowJob, 'conclusion'),
+ ]);
if ($action === 'queued' && $workflowJob) {
ProvisionGithubRunnerJob::dispatch(
@@ -236,14 +246,32 @@ class Github extends Controller
workflowJobPayload: collect($workflowJob)->toArray(),
organizationLogin: data_get($payload, 'organization.login', ''),
repositoryId: (int) data_get($payload, 'repository.id', 0),
+ repositoryFullName: data_get($payload, 'repository.full_name'),
);
return response('Runner provisioning queued.');
}
+ if ($action === 'in_progress' && $workflowJobId > 0) {
+ $execution = GithubRunnerExecution::query()
+ ->where('workflow_job_id', $workflowJobId)
+ ->whereIn('status', [GithubRunnerStatus::Queued, GithubRunnerStatus::Provisioning])
+ ->first();
+
+ if ($execution) {
+ $execution->update([
+ 'status' => GithubRunnerStatus::Running,
+ 'started_at' => $execution->started_at ?? now(),
+ 'runner_name' => data_get($workflowJob, 'runner_name') ?: $execution->runner_name,
+ ]);
+ }
+
+ return response('Runner marked running.');
+ }
+
if ($action === 'completed' && $workflowJob) {
CleanupGithubRunnerJob::dispatch(
- workflowJobId: (int) data_get($workflowJob, 'id'),
+ workflowJobId: $workflowJobId,
);
return response('Runner cleanup queued.');
diff --git a/app/Jobs/CleanupGithubRunnerArtifactsJob.php b/app/Jobs/CleanupGithubRunnerArtifactsJob.php
new file mode 100644
index 000000000..a302657c6
--- /dev/null
+++ b/app/Jobs/CleanupGithubRunnerArtifactsJob.php
@@ -0,0 +1,57 @@
+onQueue('high');
+ }
+
+ public function handle(): void
+ {
+ $targets = GithubRunnerConfig::query()
+ ->where('is_enabled', true)
+ ->with('server')
+ ->get()
+ ->filter(fn (GithubRunnerConfig $config) => $config->server?->isFunctional())
+ ->map(fn (GithubRunnerConfig $config): array => [
+ 'server' => $config->server,
+ 'base_dir' => $config->runner_base_dir,
+ ])
+ ->unique(fn (array $target): string => "{$target['server']->id}:{$target['base_dir']}")
+ ->values();
+
+ foreach ($targets as $target) {
+ $baseDir = validateShellSafePath($target['base_dir'], 'runner base directory');
+
+ instant_remote_process(static::buildCleanupCommands($baseDir), $target['server'], throwError: false);
+ }
+ }
+
+ public static function buildCleanupCommands(string $baseDir): array
+ {
+ $quotedBaseDir = escapeshellarg($baseDir);
+
+ return [
+ "if [ -d {$quotedBaseDir}/.cache ]; then for arch in x64 arm64; do ls -1t {$quotedBaseDir}/.cache/actions-runner-linux-\${arch}-*.tar.gz 2>/dev/null | tail -n +3 | xargs -r rm -f; done; fi",
+ "if [ -d {$quotedBaseDir}/.templates ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.templates/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi",
+ "if [ -d {$quotedBaseDir}/.template ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.template/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi",
+ ];
+ }
+}
diff --git a/app/Jobs/CleanupGithubRunnerJob.php b/app/Jobs/CleanupGithubRunnerJob.php
index 1f3db6721..c53804949 100644
--- a/app/Jobs/CleanupGithubRunnerJob.php
+++ b/app/Jobs/CleanupGithubRunnerJob.php
@@ -33,24 +33,44 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
+ ray("[cleanup] Starting cleanup for workflow_job_id {$this->workflowJobId}");
+
$execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId)
->with('config.githubApp')
->first();
if (! $execution) {
+ ray("[cleanup] No execution found for workflow_job_id {$this->workflowJobId} — skipping");
+
return;
}
// Already cleaned up
if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) {
+ ray("[cleanup] Execution {$execution->id} already in {$execution->status->value} — skipping");
+
return;
}
+ ray("[cleanup] Execution {$execution->id} transitioning from {$execution->status->value} → cleaning");
$execution->update(['status' => GithubRunnerStatus::Cleaning]);
try {
$server = $execution->server;
+ if (! $server || ! $server->isFunctional()) {
+ ray("[cleanup] Server not functional for execution {$execution->id}; marking failed to release capacity");
+ $this->deregisterFromGithub($execution);
+
+ $execution->update([
+ 'status' => GithubRunnerStatus::Failed,
+ 'error_message' => 'Cleanup skipped: server is not functional.',
+ 'completed_at' => now(),
+ ]);
+
+ return;
+ }
+
if ($execution->pid) {
instant_remote_process([
"kill {$execution->pid} 2>/dev/null || true",
@@ -69,7 +89,10 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
'status' => GithubRunnerStatus::Completed,
'completed_at' => now(),
]);
+
+ ray("[cleanup] Execution {$execution->id} completed successfully");
} catch (\Throwable $e) {
+ ray("[cleanup] Execution {$execution->id} cleanup failed: {$e->getMessage()}");
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => 'Cleanup failed: '.$e->getMessage(),
@@ -78,6 +101,23 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ public function failed(?\Throwable $exception): void
+ {
+ GithubRunnerExecution::query()
+ ->where('workflow_job_id', $this->workflowJobId)
+ ->whereIn('status', [
+ GithubRunnerStatus::Queued,
+ GithubRunnerStatus::Provisioning,
+ GithubRunnerStatus::Running,
+ GithubRunnerStatus::Cleaning,
+ ])
+ ->update([
+ 'status' => GithubRunnerStatus::Failed,
+ 'error_message' => 'Cleanup failed: '.($exception?->getMessage() ?? 'unknown error'),
+ 'completed_at' => now(),
+ ]);
+ }
+
private function deregisterFromGithub(GithubRunnerExecution $execution): void
{
if (! $execution->runner_id) {
diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php
index 7b1323a27..17a62ee10 100644
--- a/app/Jobs/GithubAppPermissionJob.php
+++ b/app/Jobs/GithubAppPermissionJob.php
@@ -46,8 +46,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
$this->github_app->pull_requests = data_get($permissions, 'pull_requests');
$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->webhook_events = data_get($response, 'events', []);
$this->github_app->save();
+
+ $this->autoFixMissingEvents($github_access_token);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
} catch (\Throwable $e) {
@@ -55,4 +58,29 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
throw $e;
}
}
+
+ private function autoFixMissingEvents(string $github_access_token): void
+ {
+ $missing = $this->github_app->missingWebhookEvents();
+
+ if (empty($missing)) {
+ return;
+ }
+
+ $updatedEvents = array_values(array_unique(
+ array_merge($this->github_app->webhook_events ?? [], $missing)
+ ));
+
+ $response = Http::withHeaders([
+ 'Authorization' => "Bearer $github_access_token",
+ 'Accept' => 'application/vnd.github+json',
+ ])->patch("{$this->github_app->api_url}/app", [
+ 'events' => $updatedEvents,
+ ]);
+
+ if ($response->successful()) {
+ $this->github_app->webhook_events = data_get($response->json(), 'events', $updatedEvents);
+ $this->github_app->save();
+ }
+ }
}
diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php
index f66092eee..1535af70d 100644
--- a/app/Jobs/ProvisionGithubRunnerJob.php
+++ b/app/Jobs/ProvisionGithubRunnerJob.php
@@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
public array $workflowJobPayload,
public string $organizationLogin,
public int $repositoryId = 0,
+ public ?string $repositoryFullName = null,
public ?string $capacityWaitStartedAt = null,
) {
$this->onQueue('high');
@@ -40,7 +41,11 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
- $workflowJobId = data_get($this->workflowJobPayload, 'id');
+ $workflowJobId = (int) data_get($this->workflowJobPayload, 'id');
+
+ if ($workflowJobId <= 0) {
+ return;
+ }
// Idempotency: skip if already provisioning for this job
if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) {
@@ -52,6 +57,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
return;
}
+ if (! $this->shouldContinueProvisioning($githubApp, $workflowJobId)) {
+ return;
+ }
+
$requestedLabels = data_get($this->workflowJobPayload, 'labels', []);
// Step 1: find configs that match labels (ignoring capacity)
@@ -75,8 +84,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
? \Carbon\Carbon::parse($this->capacityWaitStartedAt)
: now();
- if (now()->diffInMinutes($waitStartedAt) >= $timeoutMinutes) {
+ $waitedMinutes = $waitStartedAt->diffInMinutes(now(), absolute: true);
+
+ if ($waitedMinutes >= $timeoutMinutes) {
// Gave up waiting — log and drop so GitHub eventually cancels the job
+ ray("[provision] Gave up waiting for capacity after {$waitedMinutes}m", [
+ 'workflow_job_id' => $workflowJobId,
+ 'labels' => $requestedLabels,
+ 'timeout_minutes' => $timeoutMinutes,
+ ]);
logger()->warning('ProvisionGithubRunnerJob: gave up waiting for capacity', [
'workflow_job_id' => $workflowJobId,
'labels' => $requestedLabels,
@@ -86,12 +102,21 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
return;
}
+ $firstConfig = $matchingConfigs->first();
+ ray("[provision] At capacity for workflow_job_id {$workflowJobId} — retrying in 15s", [
+ 'active' => $firstConfig->activeRunnerCount(),
+ 'max' => $firstConfig->max_runners,
+ 'waited_minutes' => $waitedMinutes,
+ 'timeout_minutes' => $timeoutMinutes,
+ ]);
+
// Dispatch a new job in 15 seconds carrying the wait start timestamp
static::dispatch(
githubAppId: $this->githubAppId,
workflowJobPayload: $this->workflowJobPayload,
organizationLogin: $this->organizationLogin,
repositoryId: $this->repositoryId,
+ repositoryFullName: $this->repositoryFullNameOrNull(),
capacityWaitStartedAt: $this->capacityWaitStartedAt ?? now()->toIso8601String(),
)->delay(15);
@@ -108,9 +133,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
'runner_name' => $runnerName,
'runner_dir' => $runnerDir,
'workflow_job_id' => $workflowJobId,
+ 'workflow_job_html_url' => data_get($this->workflowJobPayload, 'html_url'),
'workflow_name' => data_get($this->workflowJobPayload, 'workflow_name'),
'repository_full_name' => data_get($this->workflowJobPayload, 'repository.full_name',
- data_get($this->workflowJobPayload, 'head_repository.full_name')
+ data_get($this->workflowJobPayload, 'head_repository.full_name', $this->repositoryFullNameOrNull())
),
]);
@@ -168,21 +194,32 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
{
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
+ $groupName = $this->resolveRunnerGroupName($githubApp);
if ($githubApp->runner_group_id) {
- // Ensure existing group allows public repos
- Http::withHeaders([
+ // Keep existing group settings and name in sync with Coolify.
+ $response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
])->patch("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}", [
+ 'name' => $groupName,
'allows_public_repositories' => true,
]);
- return $githubApp->runner_group_id;
- }
+ if ($response->successful()) {
+ return $githubApp->runner_group_id;
+ }
- $groupName = 'Coolify-'.((string) new Cuid2(7));
+ if ($response->status() !== 404) {
+ throw new \RuntimeException(
+ 'Failed to sync runner group: '.data_get($response->json(), 'message', $response->body())
+ );
+ }
+
+ $githubApp->update(['runner_group_id' => null]);
+ $githubApp->refresh();
+ }
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
@@ -201,11 +238,26 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
}
$runnerGroupId = (int) data_get($response->json(), 'id');
- $githubApp->update(['runner_group_id' => $runnerGroupId]);
+ $githubApp->update([
+ 'runner_group_id' => $runnerGroupId,
+ 'runner_group_name' => $groupName,
+ ]);
return $runnerGroupId;
}
+ private function resolveRunnerGroupName(GithubApp $githubApp): string
+ {
+ $groupName = trim((string) $githubApp->runner_group_name);
+
+ if ($groupName === '') {
+ $groupName = 'Coolify-'.((string) new Cuid2(7));
+ $githubApp->update(['runner_group_name' => $groupName]);
+ }
+
+ return preg_replace('/\s+/', ' ', $groupName) ?? $groupName;
+ }
+
private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void
{
if ($this->repositoryId <= 0) {
@@ -286,6 +338,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
"if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi",
"if [ ! -d {$templateDir} ]; then mkdir -p {$templateDir} && tar xzf {$cacheDir}/{$tarball} -C {$templateDir} && chown -R {$user}:{$user} {$templateDir}; fi",
"cp -r {$templateDir}/. {$runnerDir}",
+ "touch {$cacheDir}/{$tarball} {$templateDir}",
"chown -R {$user}:{$user} {$runnerDir}",
], $server);
@@ -333,4 +386,60 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
return '2.321.0';
}
+
+ private function repositoryFullNameOrNull(): ?string
+ {
+ // Backward compatibility: older serialized jobs may not have this promoted property initialized.
+ return isset($this->repositoryFullName) ? $this->repositoryFullName : null;
+ }
+
+ private function shouldContinueProvisioning(GithubApp $githubApp, int $workflowJobId): bool
+ {
+ $repositoryFullName = $this->repositoryFullNameOrNull()
+ ?? data_get($this->workflowJobPayload, 'repository.full_name')
+ ?? data_get($this->workflowJobPayload, 'head_repository.full_name');
+
+ if (! is_string($repositoryFullName) || trim($repositoryFullName) === '') {
+ return true;
+ }
+
+ try {
+ $apiUrl = $githubApp->api_url ?? 'https://api.github.com';
+ $headers = [
+ 'Accept' => 'application/vnd.github+json',
+ 'X-GitHub-Api-Version' => '2022-11-28',
+ ];
+ if (! $githubApp->is_public) {
+ $token = generateGithubInstallationToken($githubApp);
+ $headers['Authorization'] = "Bearer {$token}";
+ }
+
+ $response = Http::withHeaders($headers)
+ ->get("{$apiUrl}/repos/{$repositoryFullName}/actions/jobs/{$workflowJobId}");
+
+ if ($response->status() === 404) {
+ ray("[provision] workflow_job_id {$workflowJobId} no longer exists — skipping provisioning");
+
+ return false;
+ }
+
+ if (! $response->successful()) {
+ return true;
+ }
+
+ $status = data_get($response->json(), 'status');
+ $conclusion = data_get($response->json(), 'conclusion');
+ $isDone = $status === 'completed' || $conclusion === 'cancelled';
+
+ if ($isDone) {
+ ray("[provision] workflow_job_id {$workflowJobId} is {$status} ({$conclusion}) — skipping provisioning");
+
+ return false;
+ }
+ } catch (\Throwable) {
+ return true;
+ }
+
+ return true;
+ }
}
diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php
index a18d45b9a..70d929c32 100644
--- a/app/Jobs/ServerCheckJob.php
+++ b/app/Jobs/ServerCheckJob.php
@@ -37,10 +37,10 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
- Log::warning('ServerCheckJob timed out', [
- 'server_id' => $this->server->id,
- 'server_name' => $this->server->name,
- ]);
+ // Log::warning('ServerCheckJob timed out', [
+ // 'server_id' => $this->server->id,
+ // 'server_name' => $this->server->name,
+ // ]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index d4a499865..4e0ae4e3b 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -108,10 +108,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
- Log::warning('ServerConnectionCheckJob timed out', [
- 'server_id' => $this->server->id,
- 'server_name' => $this->server->name,
- ]);
+ // Log::warning('ServerConnectionCheckJob timed out', [
+ // 'server_id' => $this->server->id,
+ // 'server_name' => $this->server->name,
+ // ]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php
index 51426d880..d489ed8a7 100644
--- a/app/Jobs/ServerStorageCheckJob.php
+++ b/app/Jobs/ServerStorageCheckJob.php
@@ -32,10 +32,10 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
- Log::warning('ServerStorageCheckJob timed out', [
- 'server_id' => $this->server->id,
- 'server_name' => $this->server->name,
- ]);
+ // Log::warning('ServerStorageCheckJob timed out', [
+ // 'server_id' => $this->server->id,
+ // 'server_name' => $this->server->name,
+ // ]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
diff --git a/app/Livewire/Server/GithubRunnerExecutions.php b/app/Livewire/Server/GithubRunnerExecutions.php
new file mode 100644
index 000000000..ac73e7b49
--- /dev/null
+++ b/app/Livewire/Server/GithubRunnerExecutions.php
@@ -0,0 +1,34 @@
+server = $server;
+ }
+
+ #[Computed]
+ public function recentExecutions(): Collection
+ {
+ return GithubRunnerExecution::query()
+ ->where('server_id', $this->server->id)
+ ->orderByDesc('created_at')
+ ->limit(25)
+ ->get();
+ }
+
+ public function render()
+ {
+ return view('livewire.server.github-runner-executions');
+ }
+}
diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php
index 280d6b8a6..3d453cf89 100644
--- a/app/Livewire/Server/GithubRunners.php
+++ b/app/Livewire/Server/GithubRunners.php
@@ -10,8 +10,10 @@ use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Computed;
+use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
+use Visus\Cuid2\Cuid2;
class GithubRunners extends Component
{
@@ -23,6 +25,9 @@ class GithubRunners extends Component
public ?int $selectedGithubAppId = null;
+ #[Validate(['nullable', 'string', 'max:255'])]
+ public ?string $runnerGroupName = null;
+
#[Validate(['required', 'string', 'min:1'])]
public string $labels = 'self-hosted,coolify';
@@ -53,6 +58,8 @@ class GithubRunners extends Component
public bool $skipNextSelectedAppReload = false;
+ public ?string $originalRunnerGroupName = null;
+
public function mount(string $server_uuid): void
{
try {
@@ -86,15 +93,6 @@ class GithubRunners extends Component
return $this->config?->activeRunnerCount() ?? 0;
}
- #[Computed]
- public function recentExecutions()
- {
- return GithubRunnerExecution::where('server_id', $this->server->id)
- ->orderByDesc('created_at')
- ->limit(25)
- ->get();
- }
-
#[Computed]
public function selectedApp(): ?GithubApp
{
@@ -116,6 +114,8 @@ class GithubRunners extends Component
$config = $this->server->githubRunnerConfig;
if ($config) {
$this->selectedGithubAppId = $config->github_app_id;
+ $this->runnerGroupName = $config->githubApp?->runner_group_name;
+ $this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
$this->skipNextSelectedAppReload = true;
$this->labels = implode(',', $config->labels ?? []);
$this->maxRunners = $config->max_runners;
@@ -144,6 +144,8 @@ class GithubRunners extends Component
return;
}
+ $this->runnerGroupName = $this->selectedApp?->runner_group_name;
+ $this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
$this->repositoriesLoaded = true;
$this->loadAccessibleRepositories();
}
@@ -199,6 +201,27 @@ class GithubRunners extends Component
throw new \Exception('Please select a GitHub App.');
}
+ $runnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
+ if ($runnerGroupName === null) {
+ $runnerGroupName = $this->generateDefaultRunnerGroupName();
+ $this->runnerGroupName = $runnerGroupName;
+ }
+ $runnerGroupNameIsDirty = $runnerGroupName !== $this->originalRunnerGroupName;
+
+ if ($runnerGroupNameIsDirty) {
+ GithubApp::query()
+ ->whereKey($this->selectedGithubAppId)
+ ->update(['runner_group_name' => $runnerGroupName]);
+ $this->runnerGroupName = $runnerGroupName;
+
+ $selectedGithubApp = GithubApp::query()->find($this->selectedGithubAppId);
+ if ($selectedGithubApp) {
+ $this->syncRunnerGroupNameToGithub($selectedGithubApp);
+ }
+ }
+
+ $this->originalRunnerGroupName = $runnerGroupName;
+
$labelsArray = array_map('trim', explode(',', $this->labels));
$labelsArray = array_values(array_filter($labelsArray));
@@ -240,6 +263,90 @@ class GithubRunners extends Component
}
}
+ private function normalizeRunnerGroupName(?string $runnerGroupName): ?string
+ {
+ if (! is_string($runnerGroupName)) {
+ return null;
+ }
+
+ $trimmedName = trim($runnerGroupName);
+
+ if ($trimmedName === '') {
+ return null;
+ }
+
+ return preg_replace('/\s+/', ' ', $trimmedName) ?? $trimmedName;
+ }
+
+ private function generateDefaultRunnerGroupName(): string
+ {
+ return 'Coolify-'.((string) new Cuid2(7));
+ }
+
+ private function syncRunnerGroupNameToGithub(GithubApp $githubApp): void
+ {
+ $desiredRunnerGroupName = $this->normalizeRunnerGroupName($githubApp->runner_group_name);
+
+ if ($desiredRunnerGroupName === null) {
+ return;
+ }
+
+ if (! $githubApp->installation_id || ! $githubApp->organization) {
+ return;
+ }
+
+ $token = generateGithubInstallationToken($githubApp);
+ $apiUrl = $githubApp->api_url ?? 'https://api.github.com';
+ $headers = [
+ 'Authorization' => "Bearer {$token}",
+ 'Accept' => 'application/vnd.github+json',
+ 'X-GitHub-Api-Version' => '2022-11-28',
+ ];
+
+ if ($githubApp->runner_group_id) {
+ $patchResponse = Http::withHeaders($headers)->patch(
+ "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}",
+ [
+ 'name' => $desiredRunnerGroupName,
+ 'allows_public_repositories' => true,
+ ]
+ );
+
+ if ($patchResponse->successful()) {
+ return;
+ }
+
+ if ($patchResponse->status() !== 404) {
+ throw new \RuntimeException(
+ 'Failed to sync runner group: '.data_get($patchResponse->json(), 'message', $patchResponse->body())
+ );
+ }
+
+ $githubApp->update(['runner_group_id' => null]);
+ $githubApp->refresh();
+ }
+
+ $createResponse = Http::withHeaders($headers)->post(
+ "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups",
+ [
+ 'name' => $desiredRunnerGroupName,
+ 'visibility' => 'selected',
+ 'allows_public_repositories' => true,
+ ]
+ );
+
+ if (! $createResponse->successful()) {
+ throw new \RuntimeException(
+ 'Failed to create runner group: '.data_get($createResponse->json(), 'message', $createResponse->body())
+ );
+ }
+
+ $githubApp->update([
+ 'runner_group_id' => (int) data_get($createResponse->json(), 'id'),
+ 'runner_group_name' => $desiredRunnerGroupName,
+ ]);
+ }
+
public function toggleEnabled()
{
try {
@@ -279,6 +386,7 @@ class GithubRunners extends Component
}
}
+ #[On('cancel-github-runner-execution')]
public function cancelExecution(int $executionId)
{
try {
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 78e4c005a..c0d923b89 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -69,6 +69,8 @@ class Change extends Component
public ?string $organizationSelfHostedRunners = null;
+ public ?array $webhookEvents = null;
+
public $applications;
public $privateKeys;
@@ -126,6 +128,7 @@ class Change extends Component
$this->github_app->metadata = $this->metadata;
$this->github_app->pull_requests = $this->pullRequests;
$this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners;
+ $this->github_app->webhook_events = $this->webhookEvents;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->github_app->name;
@@ -145,6 +148,7 @@ class Change extends Component
$this->metadata = $this->github_app->metadata;
$this->pullRequests = $this->github_app->pull_requests;
$this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners;
+ $this->webhookEvents = $this->github_app->webhook_events;
}
}
@@ -178,10 +182,17 @@ class Change extends Component
return;
}
+ $previousEvents = $this->github_app->webhook_events ?? [];
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false);
- $this->dispatch('success', 'Github App permissions updated.');
+
+ $addedEvents = array_diff($this->github_app->webhook_events ?? [], $previousEvents);
+ if (! empty($addedEvents)) {
+ $this->dispatch('success', 'Permissions updated. Auto-enabled missing events: '.implode(', ', $addedEvents));
+ } else {
+ $this->dispatch('success', 'Github App permissions updated.');
+ }
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
$errorMessage = $e->getMessage();
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index c9a7c5d78..6cdd15ccb 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -15,6 +15,8 @@ class GithubApp extends BaseModel
'is_system_wide' => 'boolean',
'type' => 'string',
'runner_group_id' => 'integer',
+ 'runner_group_name' => 'string',
+ 'webhook_events' => 'array',
];
protected $hidden = [
@@ -94,6 +96,28 @@ class GithubApp extends BaseModel
return $this->hasMany(GithubRunnerConfig::class);
}
+ public function requiredWebhookEvents(): array
+ {
+ $events = ['push'];
+
+ if ($this->pull_requests === 'write') {
+ $events[] = 'pull_request';
+ }
+
+ if ($this->runnerConfigs()->exists()) {
+ $events[] = 'workflow_job';
+ }
+
+ return $events;
+ }
+
+ public function missingWebhookEvents(): array
+ {
+ $current = $this->webhook_events ?? [];
+
+ return array_values(array_diff($this->requiredWebhookEvents(), $current));
+ }
+
public function type(): Attribute
{
return Attribute::make(
diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php
index 62cf44174..0cbcbfd66 100644
--- a/app/Models/GithubRunnerConfig.php
+++ b/app/Models/GithubRunnerConfig.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\GithubRunnerStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -45,7 +46,12 @@ class GithubRunnerConfig extends BaseModel
public function activeRunnerCount(): int
{
return $this->executions()
- ->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning'])
+ ->whereIn('status', [
+ GithubRunnerStatus::Queued->value,
+ GithubRunnerStatus::Provisioning->value,
+ GithubRunnerStatus::Running->value,
+ GithubRunnerStatus::Cleaning->value,
+ ])
->count();
}
diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php
index 2b47ff2bf..641395f7a 100644
--- a/app/Models/GithubRunnerExecution.php
+++ b/app/Models/GithubRunnerExecution.php
@@ -46,4 +46,19 @@ class GithubRunnerExecution extends BaseModel
return $this->started_at->diffForHumans($end, true);
}
+
+ public function workflowJobUrl(): ?string
+ {
+ $directUrl = trim((string) $this->workflow_job_html_url);
+ if ($directUrl !== '') {
+ return $directUrl;
+ }
+
+ $repositoryFullName = trim((string) $this->repository_full_name);
+ if ($repositoryFullName === '' || ! $this->workflow_job_id) {
+ return null;
+ }
+
+ return "https://github.com/{$repositoryFullName}/actions?query=".urlencode((string) $this->workflow_job_id);
+ }
}
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 0ad0fcf84..e24b566f7 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -56,6 +56,7 @@ class ServerSetting extends Model
protected $guarded = [];
protected $casts = [
+ 'force_disabled' => 'boolean',
'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted',
diff --git a/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php b/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php
new file mode 100644
index 000000000..a19fd24db
--- /dev/null
+++ b/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php
@@ -0,0 +1,28 @@
+string('runner_group_name')->nullable()->after('runner_group_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('github_apps', function (Blueprint $table) {
+ $table->dropColumn('runner_group_name');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php b/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php
new file mode 100644
index 000000000..4c60381ca
--- /dev/null
+++ b/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php
@@ -0,0 +1,22 @@
+string('workflow_job_html_url')->nullable()->after('workflow_job_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('github_runner_executions', function (Blueprint $table) {
+ $table->dropColumn('workflow_job_html_url');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php b/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php
new file mode 100644
index 000000000..d2d8b1454
--- /dev/null
+++ b/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php
@@ -0,0 +1,25 @@
+json('webhook_events')->nullable()->after('organization_self_hosted_runners');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('github_apps', function (Blueprint $table) {
+ $table->dropColumn('webhook_events');
+ });
+ }
+};
diff --git a/resources/views/livewire/server/github-runner-executions.blade.php b/resources/views/livewire/server/github-runner-executions.blade.php
new file mode 100644
index 000000000..4b7ba1673
--- /dev/null
+++ b/resources/views/livewire/server/github-runner-executions.blade.php
@@ -0,0 +1,85 @@
+
+
+
Recent Executions
+
+ Refresh
+
+
+ @if ($this->recentExecutions->isEmpty())
+
No runner executions yet. When a workflow job matches this server's labels, it will appear here.
+ @else
+
+
+
+
+ | Runner |
+ Workflow |
+ Repository |
+ Status |
+ Duration |
+ Started |
+ |
+
+
+
+ @foreach ($this->recentExecutions as $execution)
+
+ | {{ $execution->runner_name }} |
+ {{ $execution->workflow_name ?? '-' }} |
+ {{ $execution->repository_full_name ?? '-' }} |
+
+ @switch($execution->status->value)
+ @case('queued')
+ Queued
+ @break
+ @case('provisioning')
+ Provisioning
+ @break
+ @case('running')
+ Running
+ @break
+ @case('completed')
+ Completed
+ @break
+ @case('failed')
+ Failed
+ @break
+ @case('timed_out')
+ Timed Out
+ @break
+ @case('cleaning')
+ Cleaning
+ @break
+ @endswitch
+ |
+ {{ $execution->duration() ?? '-' }} |
+ {{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }} |
+
+
+ @if ($execution->workflowJobUrl())
+
+
+ Open
+
+
+
+ @endif
+ @if ($execution->isActive())
+
+ Cancel
+
+ @endif
+
+ |
+
+ @endforeach
+
+
+
+ @endif
+
diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php
index cc4c576e5..9ffd70d62 100644
--- a/resources/views/livewire/server/github-runners.blade.php
+++ b/resources/views/livewire/server/github-runners.blade.php
@@ -117,6 +117,12 @@
+
+
+
+
@endif
- {{-- Executions --}}
@if ($this->config)
-
-
Recent Executions
- @if ($this->recentExecutions->isEmpty())
-
No runner executions yet. When a workflow job matches this server's labels, it will appear here.
- @else
-
-
-
-
- | Runner |
- Workflow |
- Repository |
- Status |
- Duration |
- Started |
- |
-
-
-
- @foreach ($this->recentExecutions as $execution)
-
- | {{ $execution->runner_name }} |
- {{ $execution->workflow_name ?? '-' }} |
- {{ $execution->repository_full_name ?? '-' }} |
-
- @switch($execution->status->value)
- @case('queued')
- Queued
- @break
- @case('provisioning')
- Provisioning
- @break
- @case('running')
- Running
- @break
- @case('completed')
- Completed
- @break
- @case('failed')
- Failed
- @break
- @case('timed_out')
- Timed Out
- @break
- @case('cleaning')
- Cleaning
- @break
- @endswitch
- |
- {{ $execution->duration() ?? '-' }} |
- {{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }} |
-
- @if ($execution->isActive())
-
- Cancel
-
- @endif
- |
-
- @endforeach
-
-
-
- @endif
-
+
@endif
diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php
index 0153ca8e0..81f264e31 100644
--- a/resources/views/livewire/source/github/change.blade.php
+++ b/resources/views/livewire/source/github/change.blade.php
@@ -140,6 +140,26 @@
helper="write access needed to use GitHub Actions self-hosted runners."
label="Runners" readonly placeholder="N/A" />
+ Webhook Events
+ @if ($webhookEvents)
+
+ @foreach ($webhookEvents as $event)
+ {{ $event }}
+ @endforeach
+
+ @php
+ $missingEvents = $github_app->missingWebhookEvents();
+ @endphp
+ @if (!empty($missingEvents))
+
+ Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }}
+
+ @endif
+ @else
+
+ No webhook event data yet. Click Refetch above to fetch current events.
+
+ @endif
@endif
@@ -323,6 +343,7 @@
}
if (administration) {
default_permissions.administration = 'write';
+ default_events.push('workflow_job');
}
const data = {
diff --git a/tests/Feature/CleanupGithubRunnerJobFailureTest.php b/tests/Feature/CleanupGithubRunnerJobFailureTest.php
new file mode 100644
index 000000000..d26042666
--- /dev/null
+++ b/tests/Feature/CleanupGithubRunnerJobFailureTest.php
@@ -0,0 +1,183 @@
+create();
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test'),
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
+ $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'test-app',
+ 'app_id' => fake()->unique()->randomNumber(6, true),
+ 'installation_id' => 789,
+ 'client_id' => 'Iv1.abc123',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'test-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+
+ $config = GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 1,
+ 'capacity_wait_timeout' => 60,
+ ]);
+
+ $execution = GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Cleaning,
+ 'runner_name' => 'coolify-cleaning',
+ 'runner_dir' => '/opt/github-runners/coolify-cleaning',
+ 'workflow_job_id' => 123456,
+ 'started_at' => now()->subMinute(),
+ ]);
+
+ $job = new CleanupGithubRunnerJob(workflowJobId: 123456);
+ $job->failed(new RuntimeException('simulated cleanup crash'));
+
+ $execution->refresh();
+
+ expect($execution->status)->toBe(GithubRunnerStatus::Failed)
+ ->and($execution->error_message)->toContain('simulated cleanup crash')
+ ->and($execution->completed_at)->not->toBeNull()
+ ->and($config->fresh()->activeRunnerCount())->toBe(0)
+ ->and($config->fresh()->hasCapacity())->toBeTrue();
+});
+
+it('marks running execution as failed when cleanup job fails before cleaning transition', function () {
+ $team = Team::factory()->create();
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test'),
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
+ $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'test-app',
+ 'app_id' => fake()->unique()->randomNumber(6, true),
+ 'installation_id' => 789,
+ 'client_id' => 'Iv1.abc123',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'test-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+
+ $config = GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 1,
+ 'capacity_wait_timeout' => 60,
+ ]);
+
+ $execution = GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Running,
+ 'runner_name' => 'coolify-running',
+ 'runner_dir' => '/opt/github-runners/coolify-running',
+ 'workflow_job_id' => 123457,
+ 'started_at' => now()->subMinute(),
+ ]);
+
+ $job = new CleanupGithubRunnerJob(workflowJobId: 123457);
+ $job->failed(new RuntimeException('simulated early cleanup crash'));
+
+ $execution->refresh();
+
+ expect($execution->status)->toBe(GithubRunnerStatus::Failed)
+ ->and($execution->error_message)->toContain('simulated early cleanup crash')
+ ->and($execution->completed_at)->not->toBeNull()
+ ->and($config->fresh()->activeRunnerCount())->toBe(0)
+ ->and($config->fresh()->hasCapacity())->toBeTrue();
+});
+
+it('marks execution as failed when cleanup runs on a non-functional server', function () {
+ $team = Team::factory()->create();
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test'),
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
+ $server->settings()->update(['is_reachable' => false, 'is_usable' => false, 'force_disabled' => false]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'test-app',
+ 'app_id' => fake()->unique()->randomNumber(6, true),
+ 'installation_id' => 789,
+ 'client_id' => 'Iv1.abc123',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'test-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+
+ $config = GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 1,
+ 'capacity_wait_timeout' => 60,
+ ]);
+
+ $execution = GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Running,
+ 'runner_name' => 'coolify-running',
+ 'runner_dir' => '/opt/github-runners/coolify-running',
+ 'workflow_job_id' => 999001,
+ 'started_at' => now()->subMinute(),
+ ]);
+
+ $job = new CleanupGithubRunnerJob(workflowJobId: 999001);
+ $job->handle();
+
+ $execution->refresh();
+
+ expect($execution->status)->toBe(GithubRunnerStatus::Failed)
+ ->and($execution->error_message)->toBe('Cleanup skipped: server is not functional.')
+ ->and($execution->completed_at)->not->toBeNull()
+ ->and($config->fresh()->activeRunnerCount())->toBe(0)
+ ->and($config->fresh()->hasCapacity())->toBeTrue();
+});
diff --git a/tests/Feature/GithubAppWebhookEventsSyncTest.php b/tests/Feature/GithubAppWebhookEventsSyncTest.php
new file mode 100644
index 000000000..14bb2bd74
--- /dev/null
+++ b/tests/Feature/GithubAppWebhookEventsSyncTest.php
@@ -0,0 +1,101 @@
+create();
+ $privateKeyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => 'test-key-value',
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return GithubApp::create(array_merge([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-app',
+ 'html_url' => 'https://github.com',
+ 'api_url' => 'https://api.github.com',
+ 'team_id' => $team->id,
+ 'app_id' => 12345,
+ 'private_key_id' => $privateKeyId,
+ ], $appAttributes));
+}
+
+it('stores webhook events from github api response', function () {
+ $app = setupGithubAppWithKey();
+
+ Http::fake([
+ '*/app' => Http::response([
+ 'permissions' => ['contents' => 'read', 'metadata' => 'read'],
+ 'events' => ['push', 'installation', 'pull_request'],
+ ]),
+ ]);
+
+ $response = Http::get('https://api.github.com/app')->json();
+ $app->webhook_events = data_get($response, 'events', []);
+ $app->save();
+ $app->refresh();
+
+ expect($app->webhook_events)->toBe(['push', 'installation', 'pull_request']);
+});
+
+it('auto-fixes missing events by patching github api', function () {
+ $app = setupGithubAppWithKey([
+ 'webhook_events' => ['push'],
+ 'pull_requests' => 'write',
+ ]);
+
+ Http::fake([
+ 'api.github.com/app' => Http::response([
+ 'events' => ['push', 'pull_request'],
+ ]),
+ ]);
+
+ $missing = $app->missingWebhookEvents();
+ expect($missing)->toContain('pull_request');
+
+ $updatedEvents = array_values(array_unique(
+ array_merge($app->webhook_events ?? [], $missing)
+ ));
+
+ $response = Http::withHeaders([
+ 'Authorization' => 'Bearer fake-jwt',
+ 'Accept' => 'application/vnd.github+json',
+ ])->patch('https://api.github.com/app', [
+ 'events' => $updatedEvents,
+ ]);
+
+ expect($response->successful())->toBeTrue();
+
+ $app->webhook_events = data_get($response->json(), 'events', $updatedEvents);
+ $app->save();
+ $app->refresh();
+
+ expect($app->webhook_events)
+ ->toContain('push')
+ ->toContain('pull_request');
+
+ Http::assertSent(function ($request) {
+ return $request->method() === 'PATCH'
+ && str_contains($request->url(), '/app')
+ && in_array('pull_request', $request['events']);
+ });
+});
+
+it('does not patch when no events are missing', function () {
+ $app = setupGithubAppWithKey([
+ 'webhook_events' => ['push', 'installation'],
+ ]);
+
+ expect($app->missingWebhookEvents())->toBe([]);
+});
diff --git a/tests/Feature/GithubAppWebhookEventsTest.php b/tests/Feature/GithubAppWebhookEventsTest.php
new file mode 100644
index 000000000..bb64a8c51
--- /dev/null
+++ b/tests/Feature/GithubAppWebhookEventsTest.php
@@ -0,0 +1,90 @@
+create();
+
+ return GithubApp::create(array_merge([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-app',
+ 'html_url' => 'https://github.com',
+ 'api_url' => 'https://api.github.com',
+ 'team_id' => $team->id,
+ ], $attributes));
+}
+
+it('returns base required events when no special config exists', function () {
+ $app = createGithubAppForEvents();
+
+ expect($app->requiredWebhookEvents())
+ ->toBe(['push']);
+});
+
+it('includes pull_request when pull_requests permission is write', function () {
+ $app = createGithubAppForEvents(['pull_requests' => 'write']);
+
+ expect($app->requiredWebhookEvents())
+ ->toContain('pull_request');
+});
+
+it('does not include pull_request when pull_requests permission is read', function () {
+ $app = createGithubAppForEvents(['pull_requests' => 'read']);
+
+ expect($app->requiredWebhookEvents())
+ ->not->toContain('pull_request');
+});
+
+it('includes workflow_job when runner configs exist', function () {
+ $app = createGithubAppForEvents();
+ $server = Server::factory()->create(['team_id' => $app->team_id]);
+
+ GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $app->id,
+ 'labels' => ['self-hosted'],
+ ]);
+
+ expect($app->requiredWebhookEvents())
+ ->toContain('workflow_job');
+});
+
+it('returns missing events correctly', function () {
+ $app = createGithubAppForEvents([
+ 'webhook_events' => ['push'],
+ ]);
+
+ $missing = $app->missingWebhookEvents();
+
+ expect($missing)->toBe([]);
+});
+
+it('returns no missing events when all required events are present', function () {
+ $app = createGithubAppForEvents([
+ 'webhook_events' => ['push'],
+ ]);
+
+ expect($app->missingWebhookEvents())->toBe([]);
+});
+
+it('returns missing workflow_job when runner config exists but event is not subscribed', function () {
+ $app = createGithubAppForEvents([
+ 'webhook_events' => ['push', 'installation'],
+ ]);
+ $server = Server::factory()->create(['team_id' => $app->team_id]);
+
+ GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $app->id,
+ 'labels' => ['self-hosted'],
+ ]);
+
+ expect($app->missingWebhookEvents())->toContain('workflow_job');
+});
diff --git a/tests/Feature/GithubRunnerCapacityTest.php b/tests/Feature/GithubRunnerCapacityTest.php
index ec4a46f42..74c29bdf1 100644
--- a/tests/Feature/GithubRunnerCapacityTest.php
+++ b/tests/Feature/GithubRunnerCapacityTest.php
@@ -6,9 +6,9 @@ use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
-use App\Models\ServerSetting;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -26,11 +26,10 @@ function makeRunnerSetup(array $configOverrides = []): array
]);
$server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
- // Create functional server settings so isFunctional() returns true
- ServerSetting::updateOrCreate(
- ['server_id' => $server->id],
- ['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false],
- );
+ // Make server functional — Server::created() auto-creates settings with is_reachable=false.
+ // force_disabled must be explicitly set because it's not cast to boolean on ServerSetting.
+ $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
+ $server->refresh();
$githubApp = GithubApp::create([
'name' => 'test-app',
@@ -67,6 +66,7 @@ function makeJob(GithubApp $githubApp, array $overrides = []): ProvisionGithubRu
], $overrides['payload'] ?? []),
organizationLogin: 'test-org',
repositoryId: 0,
+ repositoryFullName: $overrides['repositoryFullName'] ?? null,
capacityWaitStartedAt: $overrides['capacityWaitStartedAt'] ?? null,
);
}
@@ -224,6 +224,24 @@ it('still re-dispatches when wait time is within the custom timeout', function (
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($j) => $j->workflowJobPayload['id'] === 44002);
});
+it('counts cleaning executions as active capacity', function () {
+ ['config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
+
+ GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Cleaning,
+ 'runner_name' => 'coolify-cleaning',
+ 'runner_dir' => '/opt/github-runners/coolify-cleaning',
+ 'workflow_job_id' => 41001,
+ 'pid' => 12345,
+ 'started_at' => now()->subMinutes(1),
+ ]);
+
+ expect($config->fresh()->activeRunnerCount())->toBe(1)
+ ->and($config->fresh()->hasCapacity())->toBeFalse();
+});
+
it('does not re-dispatch when the job has already been provisioned (idempotency)', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup();
@@ -246,3 +264,66 @@ it('does not re-dispatch when the job has already been provisioned (idempotency)
expect(GithubRunnerExecution::where('workflow_job_id', 33001)->count())->toBe(1);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
+
+it('does not re-dispatch when github reports the workflow job as cancelled', function () {
+ Queue::fake();
+ Http::fake([
+ 'https://api.github.com/repos/test-org/test-repo/actions/jobs/21002' => Http::response([
+ 'status' => 'completed',
+ 'conclusion' => 'cancelled',
+ ], 200),
+ ]);
+
+ ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
+ $githubApp->update(['is_public' => true]);
+
+ GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Running,
+ 'runner_name' => 'coolify-existing',
+ 'runner_dir' => '/opt/github-runners/coolify-existing',
+ 'workflow_job_id' => 21001,
+ 'pid' => 12345,
+ 'started_at' => now()->subMinutes(2),
+ ]);
+
+ $job = makeJob($githubApp, [
+ 'payload' => ['id' => 21002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
+ 'repositoryFullName' => 'test-org/test-repo',
+ ]);
+ $job->handle();
+
+ expect(GithubRunnerExecution::where('workflow_job_id', 21002)->exists())->toBeFalse();
+ Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
+});
+
+it('does not re-dispatch when github reports the workflow job as missing', function () {
+ Queue::fake();
+ Http::fake([
+ 'https://api.github.com/repos/test-org/test-repo/actions/jobs/20002' => Http::response([], 404),
+ ]);
+
+ ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
+ $githubApp->update(['is_public' => true]);
+
+ GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Running,
+ 'runner_name' => 'coolify-existing',
+ 'runner_dir' => '/opt/github-runners/coolify-existing',
+ 'workflow_job_id' => 20001,
+ 'pid' => 12345,
+ 'started_at' => now()->subMinutes(2),
+ ]);
+
+ $job = makeJob($githubApp, [
+ 'payload' => ['id' => 20002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
+ 'repositoryFullName' => 'test-org/test-repo',
+ ]);
+ $job->handle();
+
+ expect(GithubRunnerExecution::where('workflow_job_id', 20002)->exists())->toBeFalse();
+ Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
+});
diff --git a/tests/Feature/GithubRunnerWebhookTest.php b/tests/Feature/GithubRunnerWebhookTest.php
index 5533a6063..d46ba1926 100644
--- a/tests/Feature/GithubRunnerWebhookTest.php
+++ b/tests/Feature/GithubRunnerWebhookTest.php
@@ -1,12 +1,15 @@
postJson('/source/github/events', [], [
+ $response = $this->postJson('/webhooks/source/github/events', [], [
'X-GitHub-Event' => 'ping',
]);
@@ -30,7 +33,7 @@ it('returns nothing to do when no github app found for workflow_job event', func
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, $secret);
- $response = $this->postJson('/source/github/events', $payload, [
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '999999',
'X-Hub-Signature-256' => 'sha256='.$signature,
@@ -42,10 +45,13 @@ it('returns nothing to do when no github app found for workflow_job event', func
it('dispatches provisioning job for queued workflow_job event', function () {
$team = \App\Models\Team::factory()->create();
- $privateKey = \App\Models\PrivateKey::create([
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
'name' => 'test-key',
- 'private_key' => 'test',
+ 'private_key' => encrypt('test'),
'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
]);
$githubApp = GithubApp::create([
@@ -55,7 +61,7 @@ it('dispatches provisioning job for queued workflow_job event', function () {
'client_id' => 'Iv1.abc123',
'client_secret' => 'secret',
'webhook_secret' => 'test-webhook-secret',
- 'private_key_id' => $privateKey->id,
+ 'private_key_id' => $privateKeyId,
'team_id' => $team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
@@ -76,13 +82,14 @@ it('dispatches provisioning job for queued workflow_job event', function () {
],
'repository' => [
'id' => 123456789,
+ 'full_name' => 'test-org/repo-one',
],
];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'test-webhook-secret');
- $response = $this->postJson('/source/github/events', $payload, [
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '123456',
'X-Hub-Signature-256' => 'sha256='.$signature,
@@ -93,16 +100,20 @@ it('dispatches provisioning job for queued workflow_job event', function () {
$response->assertSee('Runner provisioning queued.');
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ProvisionGithubRunnerJob::class, function ($job) {
- return $job->repositoryId === 123456789;
+ return $job->repositoryId === 123456789
+ && $job->repositoryFullName === 'test-org/repo-one';
});
});
it('dispatches cleanup job for completed workflow_job event', function () {
$team = \App\Models\Team::factory()->create();
- $privateKey = \App\Models\PrivateKey::create([
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
'name' => 'test-key',
- 'private_key' => 'test',
+ 'private_key' => encrypt('test'),
'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
]);
$githubApp = GithubApp::create([
@@ -112,7 +123,7 @@ it('dispatches cleanup job for completed workflow_job event', function () {
'client_id' => 'Iv1.abc123',
'client_secret' => 'secret',
'webhook_secret' => 'cleanup-secret',
- 'private_key_id' => $privateKey->id,
+ 'private_key_id' => $privateKeyId,
'team_id' => $team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
@@ -135,7 +146,7 @@ it('dispatches cleanup job for completed workflow_job event', function () {
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'cleanup-secret');
- $response = $this->postJson('/source/github/events', $payload, [
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '654321',
'X-Hub-Signature-256' => 'sha256='.$signature,
@@ -147,3 +158,80 @@ it('dispatches cleanup job for completed workflow_job event', function () {
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\CleanupGithubRunnerJob::class);
});
+
+it('marks execution as running for in_progress workflow_job event', function () {
+ $team = \App\Models\Team::factory()->create();
+ $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test'),
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $server = Server::factory()->create([
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ ]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'test-app',
+ 'app_id' => 112233,
+ 'installation_id' => 789,
+ 'client_id' => 'Iv1.abc123',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'inprogress-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+
+ $config = GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 1,
+ 'capacity_wait_timeout' => 60,
+ ]);
+
+ $execution = GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => \App\Enums\GithubRunnerStatus::Provisioning,
+ 'runner_name' => 'coolify-preprovision',
+ 'runner_dir' => '/opt/github-runners/coolify-preprovision',
+ 'workflow_job_id' => 998877,
+ ]);
+
+ $payload = [
+ 'action' => 'in_progress',
+ 'workflow_job' => [
+ 'id' => 998877,
+ 'runner_name' => 'coolify-live-runner',
+ ],
+ 'organization' => [
+ 'login' => 'test-org',
+ ],
+ ];
+
+ $body = json_encode($payload);
+ $signature = hash_hmac('sha256', $body, 'inprogress-secret');
+
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
+ 'X-GitHub-Event' => 'workflow_job',
+ 'X-GitHub-Hook-Installation-Target-Id' => '112233',
+ 'X-Hub-Signature-256' => 'sha256='.$signature,
+ 'Content-Type' => 'application/json',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('Runner marked running.');
+
+ $execution->refresh();
+ expect($execution->status->value)->toBe('running')
+ ->and($execution->runner_name)->toBe('coolify-live-runner')
+ ->and($execution->started_at)->not->toBeNull();
+});
diff --git a/tests/Feature/GithubRunnersRunnerGroupNameTest.php b/tests/Feature/GithubRunnersRunnerGroupNameTest.php
new file mode 100644
index 000000000..3bcbf41a0
--- /dev/null
+++ b/tests/Feature/GithubRunnersRunnerGroupNameTest.php
@@ -0,0 +1,228 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $privateKeyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test-key-content'),
+ 'team_id' => $this->team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->githubApp = GithubApp::create([
+ 'name' => 'Test App',
+ 'app_id' => 123456,
+ 'installation_id' => null,
+ 'client_id' => 'Iv1.abc',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'hook-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $this->team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ 'organization_self_hosted_runners' => 'write',
+ ]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $privateKeyId,
+ ]);
+});
+
+it('stores the normalized runner group name when saving github runner config', function () {
+ Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedGithubAppId', $this->githubApp->id)
+ ->set('runnerGroupName', ' Team Runners Primary ')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->githubApp->refresh();
+
+ expect($this->githubApp->runner_group_name)->toBe('Team Runners Primary');
+});
+
+it('generates a default runner group name when the field is empty', function () {
+ $this->githubApp->update(['runner_group_name' => 'Existing Name']);
+
+ Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedGithubAppId', $this->githubApp->id)
+ ->set('runnerGroupName', ' ')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->githubApp->refresh();
+
+ expect($this->githubApp->runner_group_name)->toStartWith('Coolify-');
+});
+
+it('syncs runner group name to github api when saving from ui', function () use ($validKey) {
+ $validPrivateKey = PrivateKey::create([
+ 'name' => 'valid-runner-key',
+ 'private_key' => $validKey,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->githubApp->update([
+ 'installation_id' => 222,
+ 'private_key_id' => $validPrivateKey->id,
+ 'runner_group_id' => 88,
+ 'runner_group_name' => 'Old Name',
+ ]);
+
+ GithubRunnerConfig::create([
+ 'server_id' => $this->server->id,
+ 'github_app_id' => $this->githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 4,
+ 'capacity_wait_timeout' => 60,
+ 'runner_user' => 'runner',
+ 'runner_base_dir' => '/opt/github-runners',
+ 'is_enabled' => true,
+ ]);
+
+ Http::preventStrayRequests();
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200),
+ ]);
+
+ Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
+ ->assertSet('selectedGithubAppId', $this->githubApp->id)
+ ->set('selectedGithubAppId', $this->githubApp->id)
+ ->set('runnerGroupName', 'New Synced Name')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->githubApp->refresh();
+ expect($this->githubApp->runner_group_name)->toBe('New Synced Name');
+
+ $recordedRequests = collect(Http::recorded())
+ ->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url())
+ ->values()
+ ->all();
+
+ \PHPUnit\Framework\Assert::assertContains(
+ 'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88',
+ $recordedRequests,
+ 'Recorded requests: '.json_encode($recordedRequests)
+ );
+});
+
+it('does not sync runner group name to github api when field is not dirty', function () use ($validKey) {
+ $validPrivateKey = PrivateKey::create([
+ 'name' => 'valid-runner-key-no-dirty',
+ 'private_key' => $validKey,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->githubApp->update([
+ 'installation_id' => 222,
+ 'private_key_id' => $validPrivateKey->id,
+ 'runner_group_id' => 88,
+ 'runner_group_name' => 'Same Name',
+ ]);
+
+ GithubRunnerConfig::create([
+ 'server_id' => $this->server->id,
+ 'github_app_id' => $this->githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 4,
+ 'capacity_wait_timeout' => 60,
+ 'runner_user' => 'runner',
+ 'runner_base_dir' => '/opt/github-runners',
+ 'is_enabled' => true,
+ ]);
+
+ Http::preventStrayRequests();
+ Http::fake();
+
+ Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
+ ->assertSet('selectedGithubAppId', $this->githubApp->id)
+ ->assertSet('runnerGroupName', 'Same Name')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ Http::assertNothingSent();
+});
+
+it('generates and syncs a default runner group name to github when field is empty', function () use ($validKey) {
+ $validPrivateKey = PrivateKey::create([
+ 'name' => 'valid-runner-key-empty-sync',
+ 'private_key' => $validKey,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->githubApp->update([
+ 'installation_id' => 222,
+ 'private_key_id' => $validPrivateKey->id,
+ 'runner_group_id' => 88,
+ 'runner_group_name' => 'Existing Name',
+ ]);
+
+ GithubRunnerConfig::create([
+ 'server_id' => $this->server->id,
+ 'github_app_id' => $this->githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 4,
+ 'capacity_wait_timeout' => 60,
+ 'runner_user' => 'runner',
+ 'runner_base_dir' => '/opt/github-runners',
+ 'is_enabled' => true,
+ ]);
+
+ Http::preventStrayRequests();
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200),
+ ]);
+
+ Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
+ ->assertSet('selectedGithubAppId', $this->githubApp->id)
+ ->set('runnerGroupName', ' ')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->githubApp->refresh();
+ expect($this->githubApp->runner_group_name)->toStartWith('Coolify-');
+
+ $recordedRequests = collect(Http::recorded())
+ ->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url())
+ ->values()
+ ->all();
+
+ \PHPUnit\Framework\Assert::assertContains(
+ 'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88',
+ $recordedRequests,
+ 'Recorded requests: '.json_encode($recordedRequests)
+ );
+});
diff --git a/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php b/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php
new file mode 100644
index 000000000..0611e89cd
--- /dev/null
+++ b/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php
@@ -0,0 +1,194 @@
+create();
+
+ $privateKey = PrivateKey::create([
+ 'name' => 'runner-group-key',
+ 'private_key' => <<<'KEY'
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK
+3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy
+f8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7
+PLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG
+w+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc
+1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO
+lwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU
+uo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ
+CCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL
+juEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf
+iJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL
+h7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ
+GjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp
+wTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK
+4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue
+q0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn
+9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG
+mAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj
+imWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc
+eOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/
+m61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ
+EtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn
+h83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq
+JR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4
+8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs
+NwAXUicFB5gVAWeyU+C6Xjn1
+-----END PRIVATE KEY-----
+KEY,
+ 'team_id' => $team->id,
+ ]);
+
+ return GithubApp::create([
+ 'name' => 'Runner App',
+ 'app_id' => fake()->unique()->numberBetween(100000, 999999),
+ 'installation_id' => 222,
+ 'client_id' => 'Iv1.runner',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'hook-secret',
+ 'private_key_id' => $privateKey->id,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+}
+
+function callEnsureRunnerGroup(ProvisionGithubRunnerJob $job, GithubApp $githubApp): int
+{
+ $method = new ReflectionMethod(ProvisionGithubRunnerJob::class, 'ensureRunnerGroup');
+ $method->setAccessible(true);
+
+ return $method->invoke($job, $githubApp);
+}
+
+beforeEach(function () {
+ Http::preventStrayRequests();
+});
+
+it('creates a runner group with the custom name when no runner group exists', function () {
+ $githubApp = makeGithubAppForRunnerGroupTests();
+ $githubApp->update(['runner_group_name' => 'Team Runner Group']);
+
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 9001], 201),
+ ]);
+
+ $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 1], 'test-org');
+ $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
+
+ expect($runnerGroupId)->toBe(9001);
+
+ $githubApp->refresh();
+ expect($githubApp->runner_group_id)->toBe(9001)
+ ->and($githubApp->runner_group_name)->toBe('Team Runner Group');
+
+ Http::assertSent(function (Request $request) {
+ return $request->method() === 'POST'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
+ && $request['name'] === 'Team Runner Group';
+ });
+});
+
+it('syncs the custom name to github when runner group id already exists', function () {
+ $githubApp = makeGithubAppForRunnerGroupTests();
+ $githubApp->update([
+ 'runner_group_id' => 42,
+ 'runner_group_name' => 'Synced Name',
+ ]);
+
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response([], 200),
+ ]);
+
+ $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 2], 'test-org');
+ $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
+
+ expect($runnerGroupId)->toBe(42);
+
+ Http::assertSent(function (Request $request) {
+ return $request->method() === 'PATCH'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42'
+ && $request['name'] === 'Synced Name';
+ });
+
+ Http::assertNotSent(function (Request $request) {
+ return $request->method() === 'POST'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups';
+ });
+});
+
+it('recreates the runner group when the stored runner group id no longer exists on github', function () {
+ $githubApp = makeGithubAppForRunnerGroupTests();
+ $githubApp->update([
+ 'runner_group_id' => 42,
+ 'runner_group_name' => 'Recover Name',
+ ]);
+
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response(['message' => 'Not Found'], 404),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 96], 201),
+ ]);
+
+ $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 3], 'test-org');
+ $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
+
+ expect($runnerGroupId)->toBe(96);
+
+ $githubApp->refresh();
+ expect($githubApp->runner_group_id)->toBe(96)
+ ->and($githubApp->runner_group_name)->toBe('Recover Name');
+
+ Http::assertSent(function (Request $request) {
+ return $request->method() === 'PATCH'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42';
+ });
+
+ Http::assertSent(function (Request $request) {
+ return $request->method() === 'POST'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
+ && $request['name'] === 'Recover Name';
+ });
+});
+
+it('generates and stores a fallback name when no custom runner group name is set', function () {
+ $githubApp = makeGithubAppForRunnerGroupTests();
+
+ Http::fake([
+ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
+ 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
+ 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 77], 201),
+ ]);
+
+ $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 4], 'test-org');
+ $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
+
+ expect($runnerGroupId)->toBe(77);
+
+ $githubApp->refresh();
+ expect($githubApp->runner_group_name)->toStartWith('Coolify-')
+ ->and($githubApp->runner_group_id)->toBe(77);
+
+ Http::assertSent(function (Request $request) {
+ return $request->method() === 'POST'
+ && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
+ && is_string($request['name'])
+ && str_starts_with($request['name'], 'Coolify-');
+ });
+});
diff --git a/tests/Feature/Server/GithubRunnerExecutionsTest.php b/tests/Feature/Server/GithubRunnerExecutionsTest.php
new file mode 100644
index 000000000..aa5975a5f
--- /dev/null
+++ b/tests/Feature/Server/GithubRunnerExecutionsTest.php
@@ -0,0 +1,84 @@
+create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ $privateKeyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => fake()->uuid(),
+ 'name' => 'test-key',
+ 'private_key' => encrypt('test'),
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => $privateKeyId,
+ ]);
+
+ $githubApp = GithubApp::create([
+ 'name' => 'Test App',
+ 'app_id' => 123456,
+ 'installation_id' => 789,
+ 'client_id' => 'Iv1.abc',
+ 'client_secret' => 'secret',
+ 'webhook_secret' => 'hook-secret',
+ 'private_key_id' => $privateKeyId,
+ 'team_id' => $team->id,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'organization' => 'test-org',
+ ]);
+
+ $config = GithubRunnerConfig::create([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => ['self-hosted', 'coolify'],
+ 'max_runners' => 4,
+ 'capacity_wait_timeout' => 60,
+ 'runner_user' => 'runner',
+ 'runner_base_dir' => '/opt/github-runners',
+ 'is_enabled' => true,
+ ]);
+
+ GithubRunnerExecution::create([
+ 'server_id' => $server->id,
+ 'github_runner_config_id' => $config->id,
+ 'status' => GithubRunnerStatus::Running,
+ 'runner_name' => 'coolify-test-runner',
+ 'runner_dir' => '/opt/github-runners/coolify-test-runner',
+ 'workflow_job_id' => 987654,
+ 'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654',
+ 'repository_full_name' => 'test-org/test-repo',
+ 'started_at' => now()->subMinute(),
+ ]);
+
+ $component = Livewire::test(GithubRunnerExecutions::class, ['server' => $server])
+ ->assertSee('Recent Executions')
+ ->assertSee('Refresh')
+ ->assertSee('coolify-test-runner')
+ ->assertSee('Running')
+ ->assertSee('Open');
+
+ expect($component->html())->toContain('wire:poll.10s');
+ expect($component->html())->toContain('https://github.com/test-org/test-repo/actions/runs/111/job/987654');
+});
diff --git a/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php b/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php
new file mode 100644
index 000000000..802efba76
--- /dev/null
+++ b/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php
@@ -0,0 +1,28 @@
+toHaveCount(3);
+ expect($commands[0])->toContain('.cache/actions-runner-linux-${arch}-*.tar.gz');
+ expect($commands[1])->toContain('.templates/runner-${arch}-*');
+ expect($commands[2])->toContain('.template/runner-${arch}-*');
+ expect($commands[0])->toContain('tail -n +3');
+ expect($commands[1])->toContain('tail -n +3');
+ expect($commands[2])->toContain('tail -n +3');
+});
+
+it('schedules github runner artifact cleanup daily at 2am', function () {
+ $kernelFile = file_get_contents(__DIR__.'/../../app/Console/Kernel.php');
+
+ expect($kernelFile)->toContain('use App\\Jobs\\CleanupGithubRunnerArtifactsJob;');
+ expect($kernelFile)->toContain("->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();");
+});
+
+it('updates runner cache and template timestamps during provisioning', function () {
+ $provisionFile = file_get_contents(__DIR__.'/../../app/Jobs/ProvisionGithubRunnerJob.php');
+
+ expect($provisionFile)->toContain('touch {$cacheDir}/{$tarball} {$templateDir}');
+});
diff --git a/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php b/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php
new file mode 100644
index 000000000..acbad074a
--- /dev/null
+++ b/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php
@@ -0,0 +1,33 @@
+ 987654,
+ 'repository_full_name' => 'test-org/test-repo',
+ 'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654',
+ ]);
+
+ expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions/runs/111/job/987654');
+});
+
+it('builds a fallback github actions search url when direct url is missing', function () {
+ $execution = new GithubRunnerExecution([
+ 'workflow_job_id' => 987654,
+ 'repository_full_name' => 'test-org/test-repo',
+ 'workflow_job_html_url' => null,
+ ]);
+
+ expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions?query=987654');
+});
+
+it('returns null when there is not enough data to build a workflow url', function () {
+ $execution = new GithubRunnerExecution([
+ 'workflow_job_id' => null,
+ 'repository_full_name' => null,
+ 'workflow_job_html_url' => null,
+ ]);
+
+ expect($execution->workflowJobUrl())->toBeNull();
+});