feat(github-runners): add capacity wait timeout and deferred repo loading
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions

Add configurable `capacity_wait_timeout` to runner configs and use it in
`ProvisionGithubRunnerJob` to re-dispatch jobs while at capacity, preserving
initial wait start time and stopping after timeout.

Improve runner provisioning by caching an extracted runner template and copying
it into new runner directories.

Update Livewire GitHub Runners UI to lazy-load accessible repositories via
`wire:init`, track loading state, add timeout input, and remove preinstall
binary action.

Add/extend feature tests for capacity retry timeout behavior and deferred
repository loading.
This commit is contained in:
Andras Bacsai 2026-03-03 18:37:10 +01:00
parent 576f38da1c
commit 1739c04d32
8 changed files with 480 additions and 85 deletions

View file

@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
public array $workflowJobPayload,
public string $organizationLogin,
public int $repositoryId = 0,
public ?string $capacityWaitStartedAt = null,
) {
$this->onQueue('high');
}
@ -53,10 +54,46 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
$requestedLabels = data_get($this->workflowJobPayload, 'labels', []);
// Find a matching server with capacity
$config = $this->findMatchingConfig($githubApp, $requestedLabels);
// Step 1: find configs that match labels (ignoring capacity)
$matchingConfigs = $this->findMatchingConfigsIgnoringCapacity($githubApp, $requestedLabels);
if ($matchingConfigs->isEmpty()) {
// No configured server handles these labels at all — nothing to do
return;
}
// Step 2: filter to configs that have capacity
$config = $matchingConfigs
->filter(fn ($c) => $c->hasCapacity())
->sortBy(fn ($c) => $c->activeRunnerCount())
->first();
if (! $config) {
ray('No matching GitHub runner config found for labels: '.implode(', ', $requestedLabels));
// All matching configs are at capacity — wait and retry
$timeoutMinutes = $matchingConfigs->first()->capacity_wait_timeout;
$waitStartedAt = $this->capacityWaitStartedAt
? \Carbon\Carbon::parse($this->capacityWaitStartedAt)
: now();
if (now()->diffInMinutes($waitStartedAt) >= $timeoutMinutes) {
// Gave up waiting — log and drop so GitHub eventually cancels the job
logger()->warning('ProvisionGithubRunnerJob: gave up waiting for capacity', [
'workflow_job_id' => $workflowJobId,
'labels' => $requestedLabels,
'timeout_minutes' => $timeoutMinutes,
]);
return;
}
// 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,
capacityWaitStartedAt: $this->capacityWaitStartedAt ?? now()->toIso8601String(),
)->delay(15);
return;
}
@ -114,7 +151,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function findMatchingConfig(GithubApp $githubApp, array $requestedLabels): ?GithubRunnerConfig
private function findMatchingConfigsIgnoringCapacity(GithubApp $githubApp, array $requestedLabels): \Illuminate\Support\Collection
{
return GithubRunnerConfig::query()
->where('github_app_id', $githubApp->id)
@ -124,9 +161,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
->get()
->filter(fn ($config) => $config->matchesLabels($requestedLabels))
->filter(fn ($config) => $config->server->isFunctional())
->filter(fn ($config) => $config->hasCapacity())
->sortBy(fn ($config) => $config->activeRunnerCount())
->first();
->values();
}
private function ensureRunnerGroup(GithubApp $githubApp): int
@ -244,11 +279,13 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
"mkdir -p {$runnerDir}",
], $server);
// Download runner binary if not cached
// Download runner binary if not cached, then populate runner dir from pre-extracted template
$tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz";
$templateDir = "{$baseDir}/.templates/runner-{$arch}-{$version}";
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}",
"if [ ! -d {$templateDir} ]; then mkdir -p {$templateDir} && tar xzf {$cacheDir}/{$tarball} -C {$templateDir} && chown -R {$user}:{$user} {$templateDir}; fi",
"cp -r {$templateDir}/. {$runnerDir}",
"chown -R {$user}:{$user} {$runnerDir}",
], $server);

View file

@ -29,6 +29,9 @@ class GithubRunners extends Component
#[Validate(['required', 'integer', 'min:1', 'max:32'])]
public int $maxRunners = 4;
#[Validate(['required', 'integer', 'min:1', 'max:1440'])]
public int $capacityWaitTimeout = 60;
#[Validate(['required', 'string', 'min:1'])]
public string $runnerUser = 'runner';
@ -44,6 +47,12 @@ class GithubRunners extends Component
public ?string $repositoryError = null;
public bool $repositoriesLoaded = false;
public bool $repositoriesLoading = false;
public bool $skipNextSelectedAppReload = false;
public function mount(string $server_uuid): void
{
try {
@ -107,29 +116,50 @@ class GithubRunners extends Component
$config = $this->server->githubRunnerConfig;
if ($config) {
$this->selectedGithubAppId = $config->github_app_id;
$this->skipNextSelectedAppReload = true;
$this->labels = implode(',', $config->labels ?? []);
$this->maxRunners = $config->max_runners;
$this->capacityWaitTimeout = $config->capacity_wait_timeout;
$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 initializeRepositories(): void
{
if ($this->repositoriesLoaded) {
return;
}
$this->loadAccessibleRepositories();
}
public function updatedSelectedGithubAppId(): void
{
if ($this->skipNextSelectedAppReload) {
$this->skipNextSelectedAppReload = false;
return;
}
$this->repositoriesLoaded = true;
$this->loadAccessibleRepositories();
}
public function loadAccessibleRepositories(): void
{
$this->repositoriesLoading = true;
$this->repositoriesLoaded = true;
$this->accessibleRepositories = [];
$this->repositoryError = null;
$app = $this->selectedApp;
if (! $app || ! $app->installation_id) {
$this->repositoriesLoading = false;
return;
}
@ -154,6 +184,8 @@ class GithubRunners extends Component
$this->accessibleRepositories = $allRepos;
} catch (\Throwable $e) {
$this->repositoryError = 'Could not load repositories: '.$e->getMessage();
} finally {
$this->repositoriesLoading = false;
}
}
@ -181,6 +213,7 @@ class GithubRunners extends Component
'github_app_id' => $this->selectedGithubAppId,
'labels' => $labelsArray,
'max_runners' => $this->maxRunners,
'capacity_wait_timeout' => $this->capacityWaitTimeout,
'runner_user' => $this->runnerUser,
'runner_base_dir' => $this->runnerBaseDir,
'runner_version' => $this->runnerVersion ?: null,
@ -192,6 +225,7 @@ class GithubRunners extends Component
'github_app_id' => $this->selectedGithubAppId,
'labels' => $labelsArray,
'max_runners' => $this->maxRunners,
'capacity_wait_timeout' => $this->capacityWaitTimeout,
'runner_user' => $this->runnerUser,
'runner_base_dir' => $this->runnerBaseDir,
'runner_version' => $this->runnerVersion ?: null,
@ -245,38 +279,6 @@ class GithubRunners extends Component
}
}
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 {

View file

@ -16,6 +16,7 @@ class GithubRunnerConfig extends BaseModel
'labels' => 'array',
'is_enabled' => 'boolean',
'max_runners' => 'integer',
'capacity_wait_timeout' => 'integer',
];
}

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_runner_configs', function (Blueprint $table) {
$table->unsignedInteger('capacity_wait_timeout')->default(60)->after('max_runners');
});
}
public function down(): void
{
Schema::table('github_runner_configs', function (Blueprint $table) {
$table->dropColumn('capacity_wait_timeout');
});
}
};

View file

@ -32,7 +32,7 @@
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>
href="{{ route('server.github-runners', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">GitHub Runners (experimental)</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>

View file

@ -1,4 +1,4 @@
<div>
<div wire:init="initializeRepositories">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify
</x-slot>
@ -41,54 +41,57 @@
</button>
<a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank"
class="text-xs text-warning hover:underline">
Manage Repository Access
Manage Accessible Repositories
</a>
</div>
</div>
<div x-data="{
open: false,
search: '',
get repos() {
return $wire.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="{{ $repositoriesLoading || ! $repositoriesLoaded ? 'Loading repositories...' : 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 mr-2.5 duration-200 ease-out text-neutral-500"
:class="{ 'rotate-180': open }" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</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>
@if ($repositoryError)
<x-callout type="error" title="Could Not Load Repositories">
{{ $repositoryError }}
</x-callout>
@elseif (count($accessibleRepositories) === 0)
@elseif ($repositoriesLoaded && 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>
<p class="mt-1">If you expect specific repositories to appear, <a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank" class="underline">manage accessible repositories</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
@ -120,16 +123,22 @@
helper="Labels for routing workflow jobs to this server. Workflows use runs-on to match these labels." />
</div>
<div class="flex gap-4">
<div class="flex gap-2">
<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="capacityWaitTimeout" type="number"
label="Capacity Wait Timeout (minutes)" required
helper="How long queued jobs wait for a runner slot to become available before giving up. Default: 60 minutes (1 hour)." />
</div>
<div class="flex gap-4">
<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">
<div class="flex gap-2">
<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." />
@ -141,9 +150,6 @@
<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.']"

View file

@ -0,0 +1,248 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\ProvisionGithubRunnerJob;
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\Queue;
uses(RefreshDatabase::class);
function makeRunnerSetup(array $configOverrides = []): array
{
$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]);
// Create functional server settings so isFunctional() returns true
ServerSetting::updateOrCreate(
['server_id' => $server->id],
['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(array_merge([
'server_id' => $server->id,
'github_app_id' => $githubApp->id,
'labels' => ['self-hosted', 'coolify'],
'max_runners' => 2,
'capacity_wait_timeout' => 60,
], $configOverrides));
return compact('team', 'server', 'githubApp', 'config');
}
function makeJob(GithubApp $githubApp, array $overrides = []): ProvisionGithubRunnerJob
{
return new ProvisionGithubRunnerJob(
githubAppId: $githubApp->id,
workflowJobPayload: array_merge([
'id' => fake()->unique()->randomNumber(8, true),
'labels' => ['self-hosted', 'coolify'],
'workflow_name' => 'CI',
], $overrides['payload'] ?? []),
organizationLogin: 'test-org',
repositoryId: 0,
capacityWaitStartedAt: $overrides['capacityWaitStartedAt'] ?? null,
);
}
it('does not create an execution when no config matches the requested labels', function () {
Queue::fake();
['githubApp' => $githubApp] = makeRunnerSetup(['labels' => ['self-hosted', 'coolify']]);
$job = makeJob($githubApp, ['payload' => ['id' => 99001, 'labels' => ['self-hosted', 'gpu'], 'workflow_name' => 'CI']]);
$job->handle();
expect(GithubRunnerExecution::where('workflow_job_id', 99001)->exists())->toBeFalse();
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('re-dispatches with a delay when all matching configs are at capacity', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
// Fill the single runner slot
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' => 88001,
'pid' => 12345,
'started_at' => now()->subMinutes(5),
]);
$job = makeJob($githubApp, ['payload' => ['id' => 88002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI']]);
$job->handle();
expect(GithubRunnerExecution::where('workflow_job_id', 88002)->exists())->toBeFalse();
Queue::assertPushed(ProvisionGithubRunnerJob::class, function ($pushedJob) {
return $pushedJob->capacityWaitStartedAt !== null
&& $pushedJob->workflowJobPayload['id'] === 88002;
});
});
it('preserves the original capacityWaitStartedAt when re-dispatching', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
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' => 77001,
'pid' => 12345,
'started_at' => now()->subMinutes(5),
]);
$originalStart = now()->subMinutes(30)->toIso8601String();
$job = makeJob($githubApp, [
'payload' => ['id' => 77002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
'capacityWaitStartedAt' => $originalStart,
]);
$job->handle();
Queue::assertPushed(ProvisionGithubRunnerJob::class, function ($pushedJob) use ($originalStart) {
return $pushedJob->capacityWaitStartedAt === $originalStart;
});
});
it('gives up silently when the capacity wait timeout is exceeded', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([
'max_runners' => 1,
'capacity_wait_timeout' => 60,
]);
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' => 66001,
'pid' => 12345,
'started_at' => now()->subHours(2),
]);
$job = makeJob($githubApp, [
'payload' => ['id' => 66002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
'capacityWaitStartedAt' => now()->subMinutes(61)->toIso8601String(),
]);
$job->handle();
expect(GithubRunnerExecution::where('workflow_job_id', 66002)->exists())->toBeFalse();
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('uses the configured timeout value for the capacity wait', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([
'max_runners' => 1,
'capacity_wait_timeout' => 10, // 10-minute custom timeout
]);
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' => 55001,
'pid' => 12345,
'started_at' => now()->subMinutes(15),
]);
// Started 11 minutes ago — exceeds 10-minute custom timeout
$job = makeJob($githubApp, [
'payload' => ['id' => 55002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
'capacityWaitStartedAt' => now()->subMinutes(11)->toIso8601String(),
]);
$job->handle();
expect(GithubRunnerExecution::where('workflow_job_id', 55002)->exists())->toBeFalse();
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('still re-dispatches when wait time is within the custom timeout', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([
'max_runners' => 1,
'capacity_wait_timeout' => 10,
]);
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' => 44001,
'pid' => 12345,
'started_at' => now()->subMinutes(5),
]);
// Started 5 minutes ago — within 10-minute timeout
$job = makeJob($githubApp, [
'payload' => ['id' => 44002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
'capacityWaitStartedAt' => now()->subMinutes(5)->toIso8601String(),
]);
$job->handle();
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($j) => $j->workflowJobPayload['id'] === 44002);
});
it('does not re-dispatch when the job has already been provisioned (idempotency)', function () {
Queue::fake();
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup();
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' => 33001,
'pid' => 99,
'started_at' => now(),
]);
$job = makeJob($githubApp, ['payload' => ['id' => 33001, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI']]);
$job->handle();
// Should exit early — no new jobs dispatched, existing execution count unchanged
expect(GithubRunnerExecution::where('workflow_job_id', 33001)->count())->toBe(1);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});

View file

@ -2,6 +2,8 @@
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;
@ -16,6 +18,8 @@ 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 () use ($validKey) {
InstanceSettings::create(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@ -51,6 +55,78 @@ beforeEach(function () use ($validKey) {
});
describe('GithubRunners accessible repositories', function () {
test('mount does not load repositories before frontend initialization', function () {
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::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/deferred-repo', 'name' => 'deferred-repo'],
],
], 200),
]);
$component = Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSet('selectedGithubAppId', $this->githubApp->id)
->assertSet('repositoriesLoaded', false)
->assertSet('accessibleRepositories', []);
Http::assertNothingSent();
$component->call('initializeRepositories')
->assertSet('repositoriesLoaded', true)
->assertSet('accessibleRepositories', ['test-org/deferred-repo']);
});
test('initializeRepositories loads repositories only once for preselected app', function () {
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::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/only-once', 'name' => 'only-once'],
],
], 200),
]);
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSet('selectedGithubAppId', $this->githubApp->id)
->call('initializeRepositories')
->assertSet('accessibleRepositories', ['test-org/only-once']);
Http::assertSentCount(3);
});
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()]),