feat(github-runners): improve runner lifecycle and GitHub sync

Add runner execution observability and lifecycle hardening for self-hosted
GitHub Actions runners, including:
- scheduled artifact cleanup job for cached runner tarballs/templates
- workflow_job in_progress handling to mark executions as running
- safer cleanup failure handling and non-functional server fallback
- persisted workflow job HTML URLs with execution "Open" links in UI
- configurable runner group name sync (UI + provisioning + GitHub API)
- webhook events persistence and auto-fix for missing required events
- strict enum/status checks and related model cast/query improvements

Also includes migrations and expanded feature/unit coverage for webhook,
provisioning, cleanup, and runner group/event sync behaviors.
This commit is contained in:
Andras Bacsai 2026-03-03 22:21:13 +01:00
parent cec2f388df
commit 5c50ab6162
33 changed files with 1813 additions and 120 deletions

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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.');

View file

@ -0,0 +1,57 @@
<?php
namespace App\Jobs;
use App\Models\GithubRunnerConfig;
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\SerializesModels;
class CleanupGithubRunnerArtifactsJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300;
public $tries = 1;
public function __construct()
{
$this->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",
];
}
}

View file

@ -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) {

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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,

View file

@ -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();

View file

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Server;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
class GithubRunnerExecutions extends Component
{
public Server $server;
public function mount(Server $server): void
{
$this->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');
}
}

View file

@ -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 {

View file

@ -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();

View file

@ -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(

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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',

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,22 @@
<?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_runner_executions', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->json('webhook_events')->nullable()->after('organization_self_hosted_runners');
});
}
public function down(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->dropColumn('webhook_events');
});
}
};

View file

@ -0,0 +1,85 @@
<div class="mt-8" wire:poll.10s>
<div class="flex items-center gap-2 mb-4">
<h3>Recent Executions</h3>
<x-forms.button type="button" wire:click="$refresh" class="!py-1 !px-3 !text-xs">
Refresh
</x-forms.button>
</div>
@if ($this->recentExecutions->isEmpty())
<div class="text-sm text-neutral-500">No runner executions yet. When a workflow job matches this server's labels, it will appear here.</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left border-b border-neutral-700">
<th class="pb-2 pr-4">Runner</th>
<th class="pb-2 pr-4">Workflow</th>
<th class="pb-2 pr-4">Repository</th>
<th class="pb-2 pr-4">Status</th>
<th class="pb-2 pr-4">Duration</th>
<th class="pb-2 pr-4">Started</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($this->recentExecutions as $execution)
<tr class="border-b border-neutral-800">
<td class="py-2 pr-4 font-mono text-xs">{{ $execution->runner_name }}</td>
<td class="py-2 pr-4">{{ $execution->workflow_name ?? '-' }}</td>
<td class="py-2 pr-4">{{ $execution->repository_full_name ?? '-' }}</td>
<td class="py-2 pr-4">
@switch($execution->status->value)
@case('queued')
<span class="px-2 py-0.5 text-xs rounded-full bg-warning/20 text-warning">Queued</span>
@break
@case('provisioning')
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Provisioning</span>
@break
@case('running')
<span class="px-2 py-0.5 text-xs rounded-full bg-success/20 text-success">Running</span>
@break
@case('completed')
<span class="px-2 py-0.5 text-xs rounded-full bg-neutral-500/20 text-neutral-400">Completed</span>
@break
@case('failed')
<span class="px-2 py-0.5 text-xs rounded-full bg-error/20 text-error">Failed</span>
@break
@case('timed_out')
<span class="px-2 py-0.5 text-xs rounded-full bg-orange-500/20 text-orange-400">Timed Out</span>
@break
@case('cleaning')
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Cleaning</span>
@break
@endswitch
</td>
<td class="py-2 pr-4">{{ $execution->duration() ?? '-' }}</td>
<td class="py-2 pr-4">{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }}</td>
<td class="py-2 whitespace-nowrap">
<div class="flex items-center gap-2 flex-nowrap whitespace-nowrap">
@if ($execution->workflowJobUrl())
<a href="{{ $execution->workflowJobUrl() }}" target="_blank" rel="noopener noreferrer"
class="flex hover:no-underline">
<x-forms.button type="button" class="!py-0.5 !px-2 !text-xs">
Open
<x-external-link />
</x-forms.button>
</a>
@endif
@if ($execution->isActive())
<x-forms.button
wire:click="$dispatch('cancel-github-runner-execution', { executionId: {{ $execution->id }} })"
wire:confirm="Cancel this runner? This will kill the process, remove the runner directory, and deregister from GitHub."
canGate="update" :canResource="$server"
class="!py-0.5 !px-2 !text-xs">
Cancel
</x-forms.button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>

View file

@ -117,6 +117,12 @@
</select>
</div>
<div class="flex gap-4">
<x-forms.input canGate="update" :canResource="$server" id="runnerGroupName"
label="Runner Group Name (optional)"
helper="If set, Coolify will enforce this GitHub organization runner group name during sync." />
</div>
<div class="flex gap-4">
<x-forms.input canGate="update" :canResource="$server" id="labels"
label="Labels (comma-separated)" required
@ -176,76 +182,8 @@
</div>
@endif
{{-- Executions --}}
@if ($this->config)
<div class="mt-8" wire:poll.10s>
<h3 class="mb-4">Recent Executions</h3>
@if ($this->recentExecutions->isEmpty())
<div class="text-sm text-neutral-500">No runner executions yet. When a workflow job matches this server's labels, it will appear here.</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left border-b border-neutral-700">
<th class="pb-2 pr-4">Runner</th>
<th class="pb-2 pr-4">Workflow</th>
<th class="pb-2 pr-4">Repository</th>
<th class="pb-2 pr-4">Status</th>
<th class="pb-2 pr-4">Duration</th>
<th class="pb-2 pr-4">Started</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($this->recentExecutions as $execution)
<tr class="border-b border-neutral-800">
<td class="py-2 pr-4 font-mono text-xs">{{ $execution->runner_name }}</td>
<td class="py-2 pr-4">{{ $execution->workflow_name ?? '-' }}</td>
<td class="py-2 pr-4">{{ $execution->repository_full_name ?? '-' }}</td>
<td class="py-2 pr-4">
@switch($execution->status->value)
@case('queued')
<span class="px-2 py-0.5 text-xs rounded-full bg-warning/20 text-warning">Queued</span>
@break
@case('provisioning')
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Provisioning</span>
@break
@case('running')
<span class="px-2 py-0.5 text-xs rounded-full bg-success/20 text-success">Running</span>
@break
@case('completed')
<span class="px-2 py-0.5 text-xs rounded-full bg-neutral-500/20 text-neutral-400">Completed</span>
@break
@case('failed')
<span class="px-2 py-0.5 text-xs rounded-full bg-error/20 text-error">Failed</span>
@break
@case('timed_out')
<span class="px-2 py-0.5 text-xs rounded-full bg-orange-500/20 text-orange-400">Timed Out</span>
@break
@case('cleaning')
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Cleaning</span>
@break
@endswitch
</td>
<td class="py-2 pr-4">{{ $execution->duration() ?? '-' }}</td>
<td class="py-2 pr-4">{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }}</td>
<td class="py-2">
@if ($execution->isActive())
<x-forms.button wire:click="cancelExecution({{ $execution->id }})"
wire:confirm="Cancel this runner? This will kill the process, remove the runner directory, and deregister from GitHub."
canGate="update" :canResource="$server"
class="!py-0.5 !px-2 !text-xs">
Cancel
</x-forms.button>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
<livewire:server.github-runner-executions :server="$server" />
@endif
</div>
</div>

View file

@ -140,6 +140,26 @@
helper="write access needed to use GitHub Actions self-hosted runners."
label="Runners" readonly placeholder="N/A" />
</div>
<h3 class="pt-4">Webhook Events</h3>
@if ($webhookEvents)
<div class="flex flex-wrap gap-2">
@foreach ($webhookEvents as $event)
<span class="px-2 py-1 text-xs font-mono rounded dark:bg-coolgray-200 bg-neutral-200">{{ $event }}</span>
@endforeach
</div>
@php
$missingEvents = $github_app->missingWebhookEvents();
@endphp
@if (!empty($missingEvents))
<div class="text-xs text-warning">
Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }}
</div>
@endif
@else
<div class="text-xs opacity-70">
No webhook event data yet. Click Refetch above to fetch current events.
</div>
@endif
</div>
@endif
</form>
@ -323,6 +343,7 @@
}
if (administration) {
default_permissions.administration = 'write';
default_events.push('workflow_job');
}
const data = {

View file

@ -0,0 +1,183 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('marks cleaning execution as failed when cleanup job fails', 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::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();
});

View file

@ -0,0 +1,101 @@
<?php
use App\Models\GithubApp;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
function setupGithubAppWithKey(array $appAttributes = []): GithubApp
{
$team = Team::factory()->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([]);
});

View file

@ -0,0 +1,90 @@
<?php
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function createGithubAppForEvents(array $attributes = []): GithubApp
{
$team = Team::factory()->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');
});

View file

@ -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);
});

View file

@ -1,12 +1,15 @@
<?php
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns pong for ping events on the webhook endpoint', function () {
$response = $this->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();
});

View file

@ -0,0 +1,228 @@
<?php
use App\Livewire\Server\GithubRunners;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
$validKey = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK\n3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy\nf8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7\nPLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG\nw+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc\n1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO\nlwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU\nuo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ\nCCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL\njuEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf\niJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL\nh7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ\nGjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp\nwTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK\n4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue\nq0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn\n9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG\nmAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj\nimWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc\neOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/\nm61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ\nEtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn\nh83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq\nJR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4\n8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs\nNwAXUicFB5gVAWeyU+C6Xjn1\n-----END PRIVATE KEY-----";
beforeEach(function () {
InstanceSettings::create(['id' => 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)
);
});

View file

@ -0,0 +1,194 @@
<?php
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
function makeGithubAppForRunnerGroupTests(): GithubApp
{
$team = Team::factory()->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-');
});
});

View file

@ -0,0 +1,84 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Livewire\Server\GithubRunnerExecutions;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders recent github runner executions inside the polled child component', function () {
$team = Team::factory()->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');
});

View file

@ -0,0 +1,28 @@
<?php
use App\Jobs\CleanupGithubRunnerArtifactsJob;
it('builds cleanup commands for cached runner tarballs and template directories', function () {
$commands = CleanupGithubRunnerArtifactsJob::buildCleanupCommands('/opt/github-runners');
expect($commands)->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}');
});

View file

@ -0,0 +1,33 @@
<?php
use App\Models\GithubRunnerExecution;
it('prefers the direct workflow job html url when available', function () {
$execution = new GithubRunnerExecution([
'workflow_job_id' => 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();
});