feat(github): add self-hosted Actions runner orchestration

Implement end-to-end GitHub Actions runner support with provisioning,
tracking, and cleanup flows.

- handle `workflow_job` webhooks to provision and tear down runners
- add runner config/execution models, enum states, and relationships
- create jobs for provisioning, cleanup, and stale-runner reaping
- schedule periodic stale runner cleanup in the console kernel
- add Livewire server UI to manage runner configuration and executions
- store GitHub App runner permissions and runner group metadata
- add migrations for runner permissions, configs, executions, and group id
- update GitHub permissions URL generation for organization app settings
- include feature/unit tests for runner behavior and permission paths
This commit is contained in:
Andras Bacsai 2026-03-03 14:27:19 +01:00
parent fb186841f4
commit 576f38da1c
29 changed files with 2077 additions and 0 deletions

View file

@ -7,6 +7,7 @@ use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleGithubRunnersJob;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
@ -55,6 +56,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
} else {
// Instance Jobs
@ -84,6 +86,9 @@ class Kernel extends ConsoleKernel
// Cleanup orphaned PR preview containers daily
$this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer();
// Cleanup stale GitHub Actions runners
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum GithubRunnerStatus: string
{
case Queued = 'queued';
case Provisioning = 'provisioning';
case Running = 'running';
case Completed = 'completed';
case Failed = 'failed';
case TimedOut = 'timed_out';
case Cleaning = 'cleaning';
public function isActive(): bool
{
return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning]);
}
}

View file

@ -3,8 +3,10 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
@ -224,6 +226,31 @@ class Github extends Controller
return response('cool');
}
if ($x_github_event === 'workflow_job') {
$action = data_get($payload, 'action');
$workflowJob = data_get($payload, 'workflow_job');
if ($action === 'queued' && $workflowJob) {
ProvisionGithubRunnerJob::dispatch(
githubAppId: $github_app->id,
workflowJobPayload: collect($workflowJob)->toArray(),
organizationLogin: data_get($payload, 'organization.login', ''),
repositoryId: (int) data_get($payload, 'repository.id', 0),
);
return response('Runner provisioning queued.');
}
if ($action === 'completed' && $workflowJob) {
CleanupGithubRunnerJob::dispatch(
workflowJobId: (int) data_get($workflowJob, 'id'),
);
return response('Runner cleanup queued.');
}
return response('workflow_job event received.');
}
if ($x_github_event === 'push') {
$id = data_get($payload, 'repository.id');
$branch = data_get($payload, 'ref');

View file

@ -0,0 +1,117 @@
<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubRunnerExecution;
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;
use Illuminate\Support\Facades\Http;
class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 120;
public $tries = 3;
public function backoff(): array
{
return [5, 10, 30];
}
public function __construct(
public int $workflowJobId,
) {
$this->onQueue('high');
}
public function handle(): void
{
$execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId)
->with('config.githubApp')
->first();
if (! $execution) {
return;
}
// Already cleaned up
if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) {
return;
}
$execution->update(['status' => GithubRunnerStatus::Cleaning]);
try {
$server = $execution->server;
if ($execution->pid) {
instant_remote_process([
"kill {$execution->pid} 2>/dev/null || true",
], $server, throwError: false);
}
if ($execution->runner_dir) {
instant_remote_process([
"rm -rf {$execution->runner_dir}",
], $server, throwError: false);
}
$this->deregisterFromGithub($execution);
$execution->update([
'status' => GithubRunnerStatus::Completed,
'completed_at' => now(),
]);
} catch (\Throwable $e) {
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => 'Cleanup failed: '.$e->getMessage(),
'completed_at' => now(),
]);
}
}
private function deregisterFromGithub(GithubRunnerExecution $execution): void
{
if (! $execution->runner_id) {
ray('Runner deregister skipped: no runner_id for execution '.$execution->id);
return;
}
$githubApp = $execution->config?->githubApp;
if (! $githubApp || $githubApp->is_public) {
ray('Runner deregister skipped: no githubApp or is_public for execution '.$execution->id);
return;
}
$org = $githubApp->organization;
if (! $org) {
ray('Runner deregister skipped: no organization for execution '.$execution->id);
return;
}
try {
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
ray("Deregistering runner {$execution->runner_id} from {$org} via DELETE /orgs/{$org}/actions/runners/{$execution->runner_id}");
$response = Http::GitHub($apiUrl, $token)
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
ray('Runner deregister response: '.$response->status().' '.$response->body());
} catch (\Throwable $e) {
ray('Runner deregister failed: '.$e->getMessage());
}
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubRunnerExecution;
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;
use Illuminate\Support\Facades\Http;
class CleanupStaleGithubRunnersJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300;
public $tries = 1;
public function __construct()
{
$this->onQueue('long-running');
}
public function handle(): void
{
$this->cleanupDeadRunners();
$this->cleanupStaleRunners();
}
/**
* Check Running executions with a PID and mark them Failed if the process no longer exists.
* Uses a 5-minute grace period to avoid false-positives during startup.
*/
private function cleanupDeadRunners(): void
{
$gracePeriod = now()->subMinutes(5);
$runningExecutions = GithubRunnerExecution::query()
->where('status', GithubRunnerStatus::Running)
->whereNotNull('pid')
->where('started_at', '<', $gracePeriod)
->with(['server', 'config.githubApp'])
->get();
foreach ($runningExecutions as $execution) {
try {
$server = $execution->server;
if (! $server->isFunctional()) {
continue;
}
// kill -0 checks if the process exists without sending a signal.
// Exit code 0 = alive, non-zero = dead.
$result = instant_remote_process([
"kill -0 {$execution->pid} 2>/dev/null && echo alive || echo dead",
], $server, throwError: false);
if (trim((string) $result) !== 'alive') {
if ($execution->runner_dir) {
instant_remote_process([
"rm -rf {$execution->runner_dir}",
], $server, throwError: false);
}
$this->deregisterFromGithub($execution);
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => 'Runner process died unexpectedly.',
'completed_at' => now(),
]);
}
} catch (\Throwable $e) {
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => 'Health check failed: '.$e->getMessage(),
'completed_at' => now(),
]);
}
}
}
/**
* Mark any active executions older than 2 hours as timed out.
*/
private function cleanupStaleRunners(): void
{
$staleThreshold = now()->subHours(2);
$staleExecutions = GithubRunnerExecution::query()
->whereIn('status', [
GithubRunnerStatus::Queued,
GithubRunnerStatus::Provisioning,
GithubRunnerStatus::Running,
GithubRunnerStatus::Cleaning,
])
->where('created_at', '<', $staleThreshold)
->with(['server', 'config.githubApp'])
->get();
foreach ($staleExecutions as $execution) {
try {
$server = $execution->server;
if ($execution->pid && $server->isFunctional()) {
instant_remote_process([
"kill {$execution->pid} 2>/dev/null || true",
], $server, throwError: false);
}
if ($execution->runner_dir && $server->isFunctional()) {
instant_remote_process([
"rm -rf {$execution->runner_dir}",
], $server, throwError: false);
}
$this->deregisterFromGithub($execution);
$execution->update([
'status' => GithubRunnerStatus::TimedOut,
'error_message' => 'Runner exceeded maximum execution time (2 hours).',
'completed_at' => now(),
]);
} catch (\Throwable $e) {
$execution->update([
'status' => GithubRunnerStatus::TimedOut,
'error_message' => 'Stale cleanup failed: '.$e->getMessage(),
'completed_at' => now(),
]);
}
}
}
private function deregisterFromGithub(GithubRunnerExecution $execution): void
{
if (! $execution->runner_id) {
return;
}
$githubApp = $execution->config?->githubApp;
if (! $githubApp || $githubApp->is_public) {
return;
}
$org = $githubApp->organization;
if (! $org) {
return;
}
try {
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
Http::GitHub($apiUrl, $token)
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
} catch (\Throwable) {
// Best-effort: don't block cleanup if the API call fails
}
}
}

View file

@ -45,6 +45,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
$this->github_app->metadata = data_get($permissions, 'metadata');
$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->save();
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');

View file

@ -0,0 +1,299 @@
<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
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;
use Illuminate\Support\Facades\Http;
use Visus\Cuid2\Cuid2;
class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300;
public $tries = 3;
public function backoff(): array
{
return [5, 15, 30];
}
public function __construct(
public int $githubAppId,
public array $workflowJobPayload,
public string $organizationLogin,
public int $repositoryId = 0,
) {
$this->onQueue('high');
}
public function handle(): void
{
$workflowJobId = data_get($this->workflowJobPayload, 'id');
// Idempotency: skip if already provisioning for this job
if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) {
return;
}
$githubApp = GithubApp::find($this->githubAppId);
if (! $githubApp) {
return;
}
$requestedLabels = data_get($this->workflowJobPayload, 'labels', []);
// Find a matching server with capacity
$config = $this->findMatchingConfig($githubApp, $requestedLabels);
if (! $config) {
ray('No matching GitHub runner config found for labels: '.implode(', ', $requestedLabels));
return;
}
$runnerName = 'coolify-'.((string) new Cuid2(7));
$runnerDir = "{$config->runner_base_dir}/{$runnerName}";
$execution = GithubRunnerExecution::create([
'server_id' => $config->server_id,
'github_runner_config_id' => $config->id,
'status' => GithubRunnerStatus::Queued,
'runner_name' => $runnerName,
'runner_dir' => $runnerDir,
'workflow_job_id' => $workflowJobId,
'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')
),
]);
try {
$execution->update(['status' => GithubRunnerStatus::Provisioning]);
// Ensure a Coolify-managed runner group exists and the repo has access
$runnerGroupId = $this->ensureRunnerGroup($githubApp);
$this->ensureRepositoryInRunnerGroup($githubApp, $runnerGroupId);
// Generate JIT config via GitHub API
['encoded_jit_config' => $jitConfig, 'runner_id' => $runnerId] = $this->generateJitConfig($githubApp, $config, $runnerName, $requestedLabels, $runnerGroupId);
// Provision runner on server via SSH
$pid = $this->provisionRunner($config, $runnerName, $runnerDir, $jitConfig);
$execution->update([
'status' => GithubRunnerStatus::Running,
'pid' => $pid,
'runner_id' => $runnerId,
'started_at' => now(),
]);
} catch (\Throwable $e) {
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => $e->getMessage(),
]);
// Attempt cleanup of the runner directory on the server
try {
$server = $config->server;
instant_remote_process(["rm -rf {$runnerDir}"], $server, throwError: false);
} catch (\Throwable) {
// Best-effort cleanup
}
throw $e;
}
}
private function findMatchingConfig(GithubApp $githubApp, array $requestedLabels): ?GithubRunnerConfig
{
return GithubRunnerConfig::query()
->where('github_app_id', $githubApp->id)
->whereHas('githubApp', fn ($q) => $q->where('organization', $this->organizationLogin))
->where('is_enabled', true)
->with('server')
->get()
->filter(fn ($config) => $config->matchesLabels($requestedLabels))
->filter(fn ($config) => $config->server->isFunctional())
->filter(fn ($config) => $config->hasCapacity())
->sortBy(fn ($config) => $config->activeRunnerCount())
->first();
}
private function ensureRunnerGroup(GithubApp $githubApp): int
{
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
if ($githubApp->runner_group_id) {
// Ensure existing group allows public repos
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}", [
'allows_public_repositories' => true,
]);
return $githubApp->runner_group_id;
}
$groupName = 'Coolify-'.((string) new Cuid2(7));
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
])->post("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups", [
'name' => $groupName,
'visibility' => 'selected',
'allows_public_repositories' => true,
]);
if (! $response->successful()) {
throw new \RuntimeException(
'Failed to create runner group: '.data_get($response->json(), 'message', $response->body())
);
}
$runnerGroupId = (int) data_get($response->json(), 'id');
$githubApp->update(['runner_group_id' => $runnerGroupId]);
return $runnerGroupId;
}
private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void
{
if ($this->repositoryId <= 0) {
ray("Skipping runner group repo assignment — repositoryId is {$this->repositoryId}");
return;
}
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
$url = "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$runnerGroupId}/repositories/{$this->repositoryId}";
ray("Adding repository {$this->repositoryId} to runner group {$runnerGroupId}: PUT {$url}");
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
])->withBody('', 'application/json')->put($url);
if (! $response->successful()) {
ray('Failed to add repository to runner group: '.$response->status().' '.data_get($response->json(), 'message', $response->body()));
}
}
private function generateJitConfig(GithubApp $githubApp, GithubRunnerConfig $config, string $runnerName, array $requestedLabels, int $runnerGroupId): array
{
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
])->post("{$apiUrl}/orgs/{$config->organization}/actions/runners/generate-jitconfig", [
'name' => $runnerName,
'runner_group_id' => $runnerGroupId,
'labels' => $requestedLabels,
'work_folder' => '_work',
]);
if (! $response->successful()) {
throw new \RuntimeException(
'Failed to generate JIT runner config: '.data_get($response->json(), 'message', $response->body())
);
}
return [
'encoded_jit_config' => data_get($response->json(), 'encoded_jit_config'),
'runner_id' => data_get($response->json(), 'runner.id'),
];
}
private function provisionRunner(GithubRunnerConfig $config, string $runnerName, string $runnerDir, string $jitConfig): int
{
$server = $config->server;
$user = $config->runner_user;
$baseDir = $config->runner_base_dir;
$cacheDir = "{$baseDir}/.cache";
// Detect architecture from server
$uname = trim(instant_remote_process(['uname -m'], $server));
$arch = $uname === 'aarch64' ? 'arm64' : 'x64';
$version = $config->runner_version ?? $this->getLatestRunnerVersion($config, $arch);
// Ensure runner user and directories exist
instant_remote_process([
"id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}",
"usermod -aG docker {$user}",
"mkdir -p {$cacheDir}",
"mkdir -p {$runnerDir}",
], $server);
// Download runner binary if not cached
$tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz";
instant_remote_process([
"if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi",
"tar xzf {$cacheDir}/{$tarball} -C {$runnerDir}",
"chown -R {$user}:{$user} {$runnerDir}",
], $server);
// Start the JIT runner in background
$output = instant_remote_process([
"cd {$runnerDir} && sudo -u {$user} nohup ./run.sh --jitconfig {$jitConfig} > {$runnerDir}/runner.log 2>&1 & echo \$!",
], $server);
$pid = (int) trim($output);
if ($pid <= 0) {
throw new \RuntimeException('Failed to start runner process — no PID returned.');
}
return $pid;
}
private function getLatestRunnerVersion(GithubRunnerConfig $config, string $arch): string
{
$githubApp = GithubApp::find($this->githubAppId);
$apiUrl = $githubApp?->api_url ?? 'https://api.github.com';
try {
$token = generateGithubInstallationToken($githubApp);
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
])->get("{$apiUrl}/orgs/{$config->organization}/actions/runners/downloads");
if ($response->successful()) {
$download = collect($response->json())
->first(fn ($d) => data_get($d, 'os') === 'linux' && data_get($d, 'architecture') === $arch);
if ($download) {
// Extract version from filename like "actions-runner-linux-x64-2.321.0.tar.gz"
preg_match('/(\d+\.\d+\.\d+)/', data_get($download, 'filename', ''), $matches);
if (! empty($matches[1])) {
return $matches[1];
}
}
}
} catch (\Throwable) {
// Fall through to default
}
return '2.321.0';
}
}

View file

@ -0,0 +1,355 @@
<?php
namespace App\Livewire\Server;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Component;
class GithubRunners extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
public ?int $selectedGithubAppId = null;
#[Validate(['required', 'string', 'min:1'])]
public string $labels = 'self-hosted,coolify';
#[Validate(['required', 'integer', 'min:1', 'max:32'])]
public int $maxRunners = 4;
#[Validate(['required', 'string', 'min:1'])]
public string $runnerUser = 'runner';
#[Validate(['required', 'string', 'min:1'])]
public string $runnerBaseDir = '/opt/github-runners';
public ?string $runnerVersion = null;
#[Validate('boolean')]
public bool $isEnabled = true;
public array $accessibleRepositories = [];
public ?string $repositoryError = null;
public function mount(string $server_uuid): void
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->loadConfig();
} catch (\Throwable) {
$this->redirectRoute('server.index');
}
}
#[Computed]
public function githubApps()
{
return GithubApp::ownedByCurrentTeam()
->whereNotNull('app_id')
->whereNotNull('organization')
->where('organization', '!=', '')
->get();
}
#[Computed]
public function config(): ?GithubRunnerConfig
{
return $this->server->githubRunnerConfig;
}
#[Computed]
public function activeRunnerCount(): int
{
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
{
if (! $this->selectedGithubAppId) {
return null;
}
return GithubApp::find($this->selectedGithubAppId);
}
#[Computed]
public function selectedAppHasRunnerPermission(): ?bool
{
return $this->selectedApp?->organization_self_hosted_runners === 'write';
}
public function loadConfig(): void
{
$config = $this->server->githubRunnerConfig;
if ($config) {
$this->selectedGithubAppId = $config->github_app_id;
$this->labels = implode(',', $config->labels ?? []);
$this->maxRunners = $config->max_runners;
$this->runnerUser = $config->runner_user;
$this->runnerBaseDir = $config->runner_base_dir;
$this->runnerVersion = $config->runner_version;
$this->isEnabled = $config->is_enabled;
$this->loadAccessibleRepositories();
}
}
public function updatedSelectedGithubAppId(): void
{
$this->loadAccessibleRepositories();
}
public function loadAccessibleRepositories(): void
{
$this->accessibleRepositories = [];
$this->repositoryError = null;
$app = $this->selectedApp;
if (! $app || ! $app->installation_id) {
return;
}
try {
$token = generateGithubInstallationToken($app);
$allRepos = [];
$page = 1;
do {
$result = loadRepositoryByPage($app, $token, $page);
$repos = data_get($result, 'repositories', []);
$totalCount = data_get($result, 'total_count', 0);
foreach ($repos as $repo) {
$allRepos[] = data_get($repo, 'full_name');
}
$page++;
} while (count($allRepos) < $totalCount && count($allRepos) < 500 && count($repos) > 0);
sort($allRepos);
$this->accessibleRepositories = $allRepos;
} catch (\Throwable $e) {
$this->repositoryError = 'Could not load repositories: '.$e->getMessage();
}
}
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->validate();
if (! $this->selectedGithubAppId) {
throw new \Exception('Please select a GitHub App.');
}
$labelsArray = array_map('trim', explode(',', $this->labels));
$labelsArray = array_values(array_filter($labelsArray));
if (empty($labelsArray)) {
throw new \Exception('At least one label is required.');
}
$config = $this->server->githubRunnerConfig;
if ($config) {
$config->update([
'github_app_id' => $this->selectedGithubAppId,
'labels' => $labelsArray,
'max_runners' => $this->maxRunners,
'runner_user' => $this->runnerUser,
'runner_base_dir' => $this->runnerBaseDir,
'runner_version' => $this->runnerVersion ?: null,
'is_enabled' => $this->isEnabled,
]);
} else {
GithubRunnerConfig::create([
'server_id' => $this->server->id,
'github_app_id' => $this->selectedGithubAppId,
'labels' => $labelsArray,
'max_runners' => $this->maxRunners,
'runner_user' => $this->runnerUser,
'runner_base_dir' => $this->runnerBaseDir,
'runner_version' => $this->runnerVersion ?: null,
'is_enabled' => $this->isEnabled,
]);
}
$this->server->refresh();
$this->dispatch('success', 'GitHub Runner configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function toggleEnabled()
{
try {
$this->authorize('update', $this->server);
$config = $this->server->githubRunnerConfig;
if (! $config) {
return;
}
$config->update(['is_enabled' => ! $config->is_enabled]);
$this->isEnabled = $config->fresh()->is_enabled;
$this->dispatch('success', $this->isEnabled ? 'Runners enabled.' : 'Runners disabled.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteConfig()
{
try {
$this->authorize('update', $this->server);
$config = $this->server->githubRunnerConfig;
if (! $config) {
return;
}
if ($config->activeRunnerCount() > 0) {
throw new \Exception('Cannot delete configuration while runners are active.');
}
$config->delete();
$this->server->refresh();
$this->loadConfig();
$this->dispatch('success', 'GitHub Runner configuration deleted.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function preinstallBinary()
{
try {
$this->authorize('update', $this->server);
$config = $this->server->githubRunnerConfig;
if (! $config) {
throw new \Exception('Save configuration first.');
}
$baseDir = $config->runner_base_dir;
$cacheDir = "{$baseDir}/.cache";
$user = $config->runner_user;
$version = $config->runner_version ?? '2.321.0';
// Detect architecture from server
$uname = trim(instant_remote_process(['uname -m'], $this->server));
$arch = $uname === 'aarch64' ? 'arm64' : 'x64';
$tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz";
instant_remote_process([
"id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}",
"usermod -aG docker {$user}",
"mkdir -p {$cacheDir}",
"if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi",
], $this->server);
$this->dispatch('success', 'Runner binary pre-installed on server.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function cancelExecution(int $executionId)
{
try {
$this->authorize('update', $this->server);
$execution = GithubRunnerExecution::where('id', $executionId)
->where('server_id', $this->server->id)
->with('config.githubApp')
->firstOrFail();
if (! $execution->isActive()) {
$this->dispatch('error', 'This execution is already finished.');
return;
}
$server = $execution->server;
if ($execution->pid && $server->isFunctional()) {
instant_remote_process([
"kill {$execution->pid} 2>/dev/null || true",
], $server, throwError: false);
}
if ($execution->runner_dir && $server->isFunctional()) {
instant_remote_process([
"rm -rf {$execution->runner_dir}",
], $server, throwError: false);
}
$this->deregisterRunnerFromGithub($execution);
$execution->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => 'Cancelled by user.',
'completed_at' => now(),
]);
$this->dispatch('success', "Runner {$execution->runner_name} cancelled.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function deregisterRunnerFromGithub(GithubRunnerExecution $execution): void
{
if (! $execution->runner_id) {
return;
}
$githubApp = $execution->config?->githubApp;
if (! $githubApp || $githubApp->is_public) {
return;
}
$org = $githubApp->organization;
if (! $org) {
return;
}
try {
$token = generateGithubInstallationToken($githubApp);
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
Http::GitHub($apiUrl, $token)
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
} catch (\Throwable) {
// Best-effort
}
}
public function render()
{
return view('livewire.server.github-runners');
}
}

View file

@ -67,6 +67,8 @@ class Change extends Component
public ?string $pullRequests = null;
public ?string $organizationSelfHostedRunners = null;
public $applications;
public $privateKeys;
@ -87,6 +89,7 @@ class Change extends Component
'contents' => 'nullable|string',
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'organizationSelfHostedRunners' => 'nullable|string',
'privateKeyId' => 'nullable|int',
];
@ -122,6 +125,7 @@ class Change extends Component
$this->github_app->contents = $this->contents;
$this->github_app->metadata = $this->metadata;
$this->github_app->pull_requests = $this->pullRequests;
$this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->github_app->name;
@ -140,6 +144,7 @@ class Change extends Component
$this->contents = $this->github_app->contents;
$this->metadata = $this->github_app->metadata;
$this->pullRequests = $this->github_app->pull_requests;
$this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners;
}
}
@ -175,6 +180,7 @@ class Change extends Component
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.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats

View file

@ -14,6 +14,7 @@ class GithubApp extends BaseModel
'is_public' => 'boolean',
'is_system_wide' => 'boolean',
'type' => 'string',
'runner_group_id' => 'integer',
];
protected $hidden = [
@ -88,6 +89,11 @@ class GithubApp extends BaseModel
return $this->belongsTo(PrivateKey::class);
}
public function runnerConfigs()
{
return $this->hasMany(GithubRunnerConfig::class);
}
public function type(): Attribute
{
return Attribute::make(

View file

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GithubRunnerConfig extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'labels' => 'array',
'is_enabled' => 'boolean',
'max_runners' => 'integer',
];
}
public function organization(): Attribute
{
return Attribute::make(
get: fn () => $this->githubApp?->organization,
);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function githubApp(): BelongsTo
{
return $this->belongsTo(GithubApp::class);
}
public function executions(): HasMany
{
return $this->hasMany(GithubRunnerExecution::class);
}
public function activeRunnerCount(): int
{
return $this->executions()
->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning'])
->count();
}
public function hasCapacity(): bool
{
return $this->activeRunnerCount() < $this->max_runners;
}
public function matchesLabels(array $requestedLabels): bool
{
$configLabels = collect($this->labels)->map(fn ($l) => strtolower($l));
return collect($requestedLabels)
->every(fn ($label) => $configLabels->contains(strtolower($label)));
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Enums\GithubRunnerStatus;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GithubRunnerExecution extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'status' => GithubRunnerStatus::class,
'workflow_job_id' => 'integer',
'runner_id' => 'integer',
'pid' => 'integer',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function config(): BelongsTo
{
return $this->belongsTo(GithubRunnerConfig::class, 'github_runner_config_id');
}
public function isActive(): bool
{
return $this->status->isActive();
}
public function duration(): ?string
{
if (! $this->started_at) {
return null;
}
$end = $this->completed_at ?? now();
return $this->started_at->diffForHumans($end, true);
}
}

View file

@ -977,6 +977,16 @@ $schema://$host {
return $standalone_docker->concat($swarm_docker);
}
public function githubRunnerConfig()
{
return $this->hasOne(GithubRunnerConfig::class);
}
public function githubRunnerExecutions()
{
return $this->hasMany(GithubRunnerExecution::class);
}
public function standaloneDockers()
{
return $this->hasMany(StandaloneDocker::class);

View file

@ -130,6 +130,10 @@ function getPermissionsPath(GithubApp $source)
$github = GithubApp::where('uuid', $source->uuid)->first();
$name = str(Str::kebab($github->name));
if (str($github->organization)->isNotEmpty()) {
return "$github->html_url/organizations/$github->organization/settings/apps/$name/permissions";
}
return "$github->html_url/settings/apps/$name/permissions";
}

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_apps', function (Blueprint $table) {
$table->string('organization_self_hosted_runners')->nullable()->after('administration');
});
}
public function down(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->dropColumn('organization_self_hosted_runners');
});
}
};

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('github_runner_configs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('server_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('github_app_id')->constrained()->cascadeOnDelete();
$table->json('labels')->default('["self-hosted","coolify"]');
$table->boolean('is_enabled')->default(true);
$table->integer('max_runners')->default(4);
$table->string('runner_user')->default('runner');
$table->string('runner_version')->nullable();
$table->string('runner_arch')->default('x64');
$table->string('runner_base_dir')->default('/opt/github-runners');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('github_runner_configs');
}
};

View file

@ -0,0 +1,38 @@
<?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::create('github_runner_executions', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('server_id')->constrained()->cascadeOnDelete();
$table->foreignId('github_runner_config_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('queued');
$table->string('runner_name')->nullable();
$table->string('runner_dir')->nullable();
$table->unsignedBigInteger('workflow_job_id');
$table->string('workflow_name')->nullable();
$table->string('repository_full_name')->nullable();
$table->unsignedBigInteger('runner_id')->nullable();
$table->integer('pid')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['server_id', 'status']);
$table->index('workflow_job_id');
});
}
public function down(): void
{
Schema::dropIfExists('github_runner_executions');
}
};

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->unsignedBigInteger('runner_group_id')->nullable()->after('installation_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->dropColumn('runner_group_id');
});
}
};

View file

@ -31,6 +31,9 @@
<a class="sub-menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Docker Cleanup</span>
</a>
<a class="sub-menu-item {{ $activeMenu === 'github-runners' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.github-runners', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">GitHub Runners</span>
</a>
<a class="sub-menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Destinations</span>
</a>

View file

@ -0,0 +1,246 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar :server="$server" activeMenu="github-runners" />
<div class="w-full">
<div class="flex items-center gap-2">
<h2>GitHub Actions Runners</h2>
@if ($this->config)
<x-forms.button wire:click="toggleEnabled" canGate="update" :canResource="$server">
{{ $this->config->is_enabled ? 'Disable' : 'Enable' }}
</x-forms.button>
@endif
</div>
<div class="mt-1 mb-6">Use this server as a GitHub Actions self-hosted runner host. Runners are ephemeral (JIT) spun up per workflow job and cleaned up automatically.</div>
{{-- Permission Warning --}}
@if ($selectedGithubAppId && $this->selectedAppHasRunnerPermission === false)
<div class="mb-4">
<x-callout type="warning" title="Missing Permission">
<p>The selected GitHub App does not have the <code>organization_self_hosted_runners: write</code> permission.</p>
<p class="mt-1">
1. Add it in your <a href="{{ getPermissionsPath($this->selectedApp) }}" target="_blank" class="underline">GitHub App settings</a>,
then 2. re-sync permissions in <a href="{{ route('source.github.show', ['github_app_uuid' => $this->selectedApp->uuid]) }}" class="underline">Coolify's Source settings</a>.
</p>
</x-callout>
</div>
@endif
{{-- Accessible Repositories --}}
@if ($selectedGithubAppId && $this->selectedApp && !$this->selectedApp->is_public)
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<label class="text-sm font-medium">Accessible Repositories</label>
<div class="flex items-center gap-3">
<button type="button" wire:click="loadAccessibleRepositories"
class="text-xs text-neutral-400 hover:text-white transition-colors">
Refresh
</button>
<a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank"
class="text-xs text-warning hover:underline">
Manage Repository Access
</a>
</div>
</div>
@if ($repositoryError)
<x-callout type="error" title="Could Not Load Repositories">
{{ $repositoryError }}
</x-callout>
@elseif (count($accessibleRepositories) === 0)
<x-callout type="warning" title="No Repositories Loaded">
<p>No repositories are accessible yet, or the GitHub App is set to "All repositories" (all org repos are covered automatically).</p>
<p class="mt-1">If you expect specific repositories to appear, <a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank" class="underline">manage repository access</a> in your GitHub App settings.</p>
</x-callout>
@else
<div x-data="{
open: false,
search: '',
repos: @js($accessibleRepositories),
get filtered() {
if (!this.search) return this.repos;
const q = this.search.toLowerCase();
return this.repos.filter(r => r.toLowerCase().includes(q));
}
}" @click.outside="open = false" class="relative mt-1">
<div @click="open = !open"
class="flex items-center gap-2 w-full input cursor-pointer">
<input type="text" x-model="search" @click.stop @focus="open = true"
@keydown.escape="open = false"
placeholder="{{ count($accessibleRepositories) }} {{ Str::plural('repository', count($accessibleRepositories)) }} accessible — type to search..."
class="flex-1 text-sm border-0 outline-none bg-transparent px-2 py-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-white" />
<svg class="w-4 h-4 shrink-0 text-neutral-400 transition-transform" :class="{ 'rotate-180': open }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div x-show="open" x-transition
class="absolute z-50 w-full mt-1 bg-coolgray-100 border border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
<template x-if="filtered.length === 0">
<div class="px-3 py-2 text-sm text-neutral-400">No matching repositories</div>
</template>
<template x-for="repo in filtered" :key="repo">
<div class="px-3 py-2 text-sm font-mono text-neutral-300" x-text="repo"></div>
</template>
</div>
</div>
@endif
<span wire:loading wire:target="loadAccessibleRepositories" class="text-xs text-neutral-400 mt-1 inline-block">Loading...</span>
</div>
@endif
{{-- Configuration Form --}}
<form wire:submit="submit">
<div class="flex flex-col gap-4">
<div>
<label for="selectedGithubAppId" class="block text-sm font-medium">GitHub App</label>
<select wire:model.live="selectedGithubAppId" id="selectedGithubAppId"
class="w-full mt-1 input">
<option value="">Select a GitHub App...</option>
@foreach ($this->githubApps as $app)
<option value="{{ $app->id }}">
{{ $app->name }}
@if ($app->organization)
({{ $app->organization }})
@endif
@if ($app->organization_self_hosted_runners === 'write')
Runner Permission
@endif
</option>
@endforeach
</select>
</div>
<div class="flex gap-4">
<x-forms.input canGate="update" :canResource="$server" id="labels"
label="Labels (comma-separated)" required
helper="Labels for routing workflow jobs to this server. Workflows use runs-on to match these labels." />
</div>
<div class="flex gap-4">
<x-forms.input canGate="update" :canResource="$server" id="maxRunners" type="number"
label="Max Concurrent Runners" required
helper="Maximum number of runners that can run simultaneously on this server." />
<x-forms.input canGate="update" :canResource="$server" id="runnerUser"
label="Runner User" required
helper="Linux user to run the runner process as. Will be created if it doesn't exist." />
</div>
<div class="flex gap-4">
<x-forms.input canGate="update" :canResource="$server" id="runnerBaseDir"
label="Base Directory" required
helper="Directory on the server where runner binaries and working directories will be stored." />
<x-forms.input canGate="update" :canResource="$server" id="runnerVersion"
label="Runner Version (optional)"
helper="Pin to a specific runner version (e.g. 2.321.0). Leave empty for latest." />
</div>
<div class="flex items-center gap-2 mt-2">
<x-forms.button type="submit" canGate="update" :canResource="$server">Save</x-forms.button>
@if ($this->config)
<x-forms.button wire:click="preinstallBinary" canGate="update" :canResource="$server">
Pre-install Binary
</x-forms.button>
<x-modal-confirmation title="Delete Runner Configuration?" buttonTitle="Delete Configuration"
submitAction="deleteConfig"
:actions="['This will remove the runner configuration from this server.', 'Active runners will not be affected until they complete.']"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Delete Configuration" />
@endif
</div>
</div>
</form>
{{-- Status --}}
@if ($this->config)
<div class="mt-8">
<h3 class="mb-2">Status</h3>
<div class="flex items-center gap-4 text-sm">
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ $this->config->is_enabled ? 'bg-success' : 'bg-error' }}"></span>
{{ $this->config->is_enabled ? 'Enabled' : 'Disabled' }}
</span>
<span>Active Runners: {{ $this->activeRunnerCount }} / {{ $this->config->max_runners }}</span>
<span>Organization: {{ $this->config->githubApp?->organization }}</span>
<span>Labels: {{ implode(', ', $this->config->labels ?? []) }}</span>
</div>
</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>
@endif
</div>
</div>
</div>

View file

@ -136,6 +136,9 @@
<x-forms.input id="pullRequests"
helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" />
<x-forms.input id="organizationSelfHostedRunners"
helper="write access needed to use GitHub Actions self-hosted runners."
label="Runners" readonly placeholder="N/A" />
</div>
</div>
@endif

View file

@ -48,6 +48,7 @@ use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
use App\Livewire\Server\Delete as DeleteServer;
use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup;
use App\Livewire\Server\GithubRunners;
use App\Livewire\Server\Index as ServerIndex;
use App\Livewire\Server\LogDrains;
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
@ -274,6 +275,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
Route::get('/github-runners', GithubRunners::class)->name('server.github-runners');
Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource');
Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource');
Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource');

51
scripts/webhook-tunnel.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# webhook-tunnel.sh
#
# Opens a Cloudflare quick tunnel to expose your local Coolify instance
# so GitHub can deliver webhook events during local development.
#
# Usage:
# bash scripts/webhook-tunnel.sh [local_port]
#
# Default local port: 8000
set -euo pipefail
LOCAL_PORT="${1:-8000}"
WEBHOOK_PATH="/source/github/events"
if ! command -v cloudflared &>/dev/null; then
echo ""
echo " ERROR: cloudflared is not installed."
echo " Install it with: brew install cloudflared"
echo ""
exit 1
fi
echo ""
echo " GitHub Webhook Tunnel (via Cloudflare)"
echo " ======================================="
echo " Forwarding: public HTTPS -> localhost:${LOCAL_PORT}"
echo ""
echo " Waiting for tunnel URL..."
echo ""
cloudflared tunnel --url "http://localhost:${LOCAL_PORT}" 2>&1 | while IFS= read -r line; do
if [[ "$line" =~ https://[a-zA-Z0-9_-]+\.trycloudflare\.com ]]; then
TUNNEL_URL="${BASH_REMATCH[0]}"
echo ""
echo " ✔ Tunnel is live!"
echo ""
echo " ┌─────────────────────────────────────────────────────────────────┐"
echo " │ Webhook endpoint: │"
echo "${TUNNEL_URL}${WEBHOOK_PATH}"
echo " │ │"
echo " │ Configure your GitHub App: │"
echo " │ GitHub -> App Settings -> Webhook URL -> paste the URL above │"
echo " │ │"
echo " │ Press Ctrl+C to stop the tunnel. │"
echo " └─────────────────────────────────────────────────────────────────┘"
echo ""
fi
done

View file

@ -0,0 +1,129 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupStaleGithubRunnersJob;
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;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function makeExecution(array $attributes = []): GithubRunnerExecution
{
$team = Team::factory()->create();
$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(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
$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'],
]);
return GithubRunnerExecution::create(array_merge([
'server_id' => $server->id,
'github_runner_config_id' => $config->id,
'status' => GithubRunnerStatus::Running,
'runner_name' => 'coolify-test',
'runner_dir' => '/opt/github-runners/coolify-test',
'workflow_job_id' => fake()->unique()->randomNumber(8, true),
'pid' => 12345,
'started_at' => now()->subMinutes(10),
], $attributes));
}
it('skips dead-runner check for executions within the 5-minute grace period', function () {
$execution = makeExecution(['started_at' => now()->subMinutes(2)]);
(new CleanupStaleGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
it('skips dead-runner check when server is not functional', function () {
// Factory servers have no settings → isFunctional() returns false
$execution = makeExecution(['started_at' => now()->subMinutes(10)]);
(new CleanupStaleGithubRunnersJob)->handle();
// Should remain Running because the health check is skipped for non-functional servers
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
it('marks stale active executions as timed out after 2 hours', function () {
$execution = makeExecution([
'status' => GithubRunnerStatus::Running,
'started_at' => now()->subHours(3),
'created_at' => now()->subHours(3),
]);
// Force the created_at to be old enough
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
(new CleanupStaleGithubRunnersJob)->handle();
$fresh = $execution->fresh();
expect($fresh->status)->toBe(GithubRunnerStatus::TimedOut);
expect($fresh->completed_at)->not->toBeNull();
expect($fresh->error_message)->toContain('2 hours');
});
it('does not touch active executions younger than 2 hours in stale cleanup', function () {
$execution = makeExecution([
'status' => GithubRunnerStatus::Provisioning,
'started_at' => now()->subHour(),
]);
(new CleanupStaleGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Provisioning);
});
it('marks stale queued executions as timed out', function () {
$execution = makeExecution([
'status' => GithubRunnerStatus::Queued,
'started_at' => null,
'pid' => null,
]);
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
(new CleanupStaleGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::TimedOut);
});
it('does not re-process already completed executions', function () {
$execution = makeExecution([
'status' => GithubRunnerStatus::Completed,
'started_at' => now()->subHours(3),
'completed_at' => now()->subHours(2),
]);
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
(new CleanupStaleGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Completed);
});

View file

@ -0,0 +1,35 @@
<?php
use App\Models\GithubApp;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeGithubApp(array $attributes = []): GithubApp
{
$team = Team::factory()->create();
return GithubApp::create(array_merge([
'uuid' => fake()->uuid(),
'name' => 'My Cool App',
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'organization' => null,
'team_id' => $team->id,
], $attributes));
}
it('returns user-level permissions path when no organization is set', function () {
$app = makeGithubApp(['name' => 'My Cool App', 'organization' => null]);
expect(getPermissionsPath($app))
->toBe('https://github.com/settings/apps/my-cool-app/permissions');
});
it('returns organization-level permissions path when organization is set', function () {
$app = makeGithubApp(['name' => 'My Cool App', 'organization' => 'coollabsio']);
expect(getPermissionsPath($app))
->toBe('https://github.com/organizations/coollabsio/settings/apps/my-cool-app/permissions');
});

View file

@ -0,0 +1,149 @@
<?php
use App\Models\GithubApp;
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', [], [
'X-GitHub-Event' => 'ping',
]);
$response->assertOk();
$response->assertSee('pong');
});
it('returns nothing to do when no github app found for workflow_job event', function () {
$payload = [
'action' => 'queued',
'workflow_job' => [
'id' => 12345,
'labels' => ['self-hosted'],
],
'organization' => [
'login' => 'test-org',
],
];
$secret = 'test-secret';
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, $secret);
$response = $this->postJson('/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '999999',
'X-Hub-Signature-256' => 'sha256='.$signature,
]);
$response->assertOk();
$response->assertSee('Nothing to do. No GitHub App found.');
});
it('dispatches provisioning job for queued workflow_job event', function () {
$team = \App\Models\Team::factory()->create();
$privateKey = \App\Models\PrivateKey::create([
'name' => 'test-key',
'private_key' => 'test',
'team_id' => $team->id,
]);
$githubApp = GithubApp::create([
'name' => 'test-app',
'app_id' => 123456,
'installation_id' => 789,
'client_id' => 'Iv1.abc123',
'client_secret' => 'secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'organization' => 'test-org',
]);
\Illuminate\Support\Facades\Queue::fake();
$payload = [
'action' => 'queued',
'workflow_job' => [
'id' => 12345,
'labels' => ['self-hosted', 'coolify'],
'workflow_name' => 'CI',
],
'organization' => [
'login' => 'test-org',
],
'repository' => [
'id' => 123456789,
],
];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'test-webhook-secret');
$response = $this->postJson('/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '123456',
'X-Hub-Signature-256' => 'sha256='.$signature,
'Content-Type' => 'application/json',
]);
$response->assertOk();
$response->assertSee('Runner provisioning queued.');
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ProvisionGithubRunnerJob::class, function ($job) {
return $job->repositoryId === 123456789;
});
});
it('dispatches cleanup job for completed workflow_job event', function () {
$team = \App\Models\Team::factory()->create();
$privateKey = \App\Models\PrivateKey::create([
'name' => 'test-key',
'private_key' => 'test',
'team_id' => $team->id,
]);
$githubApp = GithubApp::create([
'name' => 'test-app',
'app_id' => 654321,
'installation_id' => 789,
'client_id' => 'Iv1.abc123',
'client_secret' => 'secret',
'webhook_secret' => 'cleanup-secret',
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'organization' => 'test-org',
]);
\Illuminate\Support\Facades\Queue::fake();
$payload = [
'action' => 'completed',
'workflow_job' => [
'id' => 67890,
'conclusion' => 'success',
],
'organization' => [
'login' => 'test-org',
],
];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'cleanup-secret');
$response = $this->postJson('/source/github/events', $payload, [
'X-GitHub-Event' => 'workflow_job',
'X-GitHub-Hook-Installation-Target-Id' => '654321',
'X-Hub-Signature-256' => 'sha256='.$signature,
'Content-Type' => 'application/json',
]);
$response->assertOk();
$response->assertSee('Runner cleanup queued.');
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\CleanupGithubRunnerJob::class);
});

View file

@ -0,0 +1,156 @@
<?php
use App\Livewire\Server\GithubRunners;
use App\Models\GithubApp;
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\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// RSA-2048 key in PKCS8 PEM format — required by lcobucci/jwt Rsa\Sha256 signer
$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 () use ($validKey) {
$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]);
$privateKey = PrivateKey::create([
'name' => 'test-key',
'private_key' => $validKey,
'team_id' => $this->team->id,
]);
$this->githubApp = GithubApp::create([
'name' => 'Test App',
'app_id' => 111,
'installation_id' => 222,
'client_id' => 'Iv1.abc',
'client_secret' => 'secret',
'webhook_secret' => 'hook-secret',
'private_key_id' => $privateKey->id,
'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' => $privateKey->id,
]);
});
describe('GithubRunners accessible repositories', function () {
test('loadAccessibleRepositories populates accessibleRepositories from GitHub API', function () {
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',
'expires_at' => now()->addHour()->toIso8601String(),
], 200),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => 2,
'repositories' => [
['full_name' => 'test-org/repo-b', 'name' => 'repo-b'],
['full_name' => 'test-org/repo-a', 'name' => 'repo-a'],
],
], 200),
]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('selectedGithubAppId', $this->githubApp->id)
->call('loadAccessibleRepositories')
->assertSet('repositoryError', null)
->assertSet('accessibleRepositories', ['test-org/repo-a', 'test-org/repo-b']);
});
test('loadAccessibleRepositories sorts repositories alphabetically', function () {
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',
'expires_at' => now()->addHour()->toIso8601String(),
], 200),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => 3,
'repositories' => [
['full_name' => 'test-org/zebra', 'name' => 'zebra'],
['full_name' => 'test-org/alpha', 'name' => 'alpha'],
['full_name' => 'test-org/middle', 'name' => 'middle'],
],
], 200),
]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('selectedGithubAppId', $this->githubApp->id)
->call('loadAccessibleRepositories')
->assertSet('accessibleRepositories', ['test-org/alpha', 'test-org/middle', 'test-org/zebra']);
});
test('loadAccessibleRepositories returns empty list when no repos accessible', function () {
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',
'expires_at' => now()->addHour()->toIso8601String(),
], 200),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => 0,
'repositories' => [],
], 200),
]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('selectedGithubAppId', $this->githubApp->id)
->call('loadAccessibleRepositories')
->assertSet('repositoryError', null)
->assertSet('accessibleRepositories', []);
});
test('loadAccessibleRepositories does nothing when no app is selected', function () {
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('loadAccessibleRepositories')
->assertSet('accessibleRepositories', [])
->assertSet('repositoryError', null);
});
test('loadAccessibleRepositories does nothing when app has no installation_id', function () {
$this->githubApp->update(['installation_id' => null]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('selectedGithubAppId', $this->githubApp->id)
->call('loadAccessibleRepositories')
->assertSet('accessibleRepositories', [])
->assertSet('repositoryError', null);
});
test('changing selectedGithubAppId triggers repository reload', function () {
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',
'expires_at' => now()->addHour()->toIso8601String(),
], 200),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => 1,
'repositories' => [
['full_name' => 'test-org/my-repo', 'name' => 'my-repo'],
],
], 200),
]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('selectedGithubAppId', $this->githubApp->id)
->assertSet('accessibleRepositories', ['test-org/my-repo']);
});
});

View file

@ -0,0 +1,35 @@
<?php
use App\Models\GithubRunnerConfig;
it('matches when all requested labels are present', function () {
$config = new GithubRunnerConfig;
$config->labels = ['self-hosted', 'linux', 'x64', 'coolify'];
expect($config->matchesLabels(['self-hosted', 'linux']))->toBeTrue();
expect($config->matchesLabels(['self-hosted', 'coolify']))->toBeTrue();
expect($config->matchesLabels(['self-hosted']))->toBeTrue();
});
it('does not match when a requested label is missing', function () {
$config = new GithubRunnerConfig;
$config->labels = ['self-hosted', 'linux', 'x64'];
expect($config->matchesLabels(['self-hosted', 'gpu']))->toBeFalse();
expect($config->matchesLabels(['self-hosted', 'arm64']))->toBeFalse();
});
it('matches labels case-insensitively', function () {
$config = new GithubRunnerConfig;
$config->labels = ['self-hosted', 'Linux', 'X64'];
expect($config->matchesLabels(['Self-Hosted', 'linux']))->toBeTrue();
expect($config->matchesLabels(['SELF-HOSTED', 'x64']))->toBeTrue();
});
it('matches when requesting empty labels', function () {
$config = new GithubRunnerConfig;
$config->labels = ['self-hosted'];
expect($config->matchesLabels([]))->toBeTrue();
});

View file

@ -0,0 +1,23 @@
<?php
use App\Enums\GithubRunnerStatus;
it('identifies active statuses correctly', function () {
expect(GithubRunnerStatus::Queued->isActive())->toBeTrue();
expect(GithubRunnerStatus::Provisioning->isActive())->toBeTrue();
expect(GithubRunnerStatus::Running->isActive())->toBeTrue();
expect(GithubRunnerStatus::Cleaning->isActive())->toBeTrue();
});
it('identifies inactive statuses correctly', function () {
expect(GithubRunnerStatus::Completed->isActive())->toBeFalse();
expect(GithubRunnerStatus::Failed->isActive())->toBeFalse();
expect(GithubRunnerStatus::TimedOut->isActive())->toBeFalse();
});
it('creates from string values', function () {
expect(GithubRunnerStatus::from('queued'))->toBe(GithubRunnerStatus::Queued);
expect(GithubRunnerStatus::from('running'))->toBe(GithubRunnerStatus::Running);
expect(GithubRunnerStatus::from('completed'))->toBe(GithubRunnerStatus::Completed);
expect(GithubRunnerStatus::from('timed_out'))->toBe(GithubRunnerStatus::TimedOut);
});