From 1739c04d3288cbee396ef42c812623e27ef3d472 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 18:37:10 +0100
Subject: [PATCH] feat(github-runners): add capacity wait timeout and deferred
repo loading
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.
---
app/Jobs/ProvisionGithubRunnerJob.php | 55 +++-
app/Livewire/Server/GithubRunners.php | 68 ++---
app/Models/GithubRunnerConfig.php | 1 +
...timeout_to_github_runner_configs_table.php | 25 ++
.../views/components/server/sidebar.blade.php | 2 +-
.../livewire/server/github-runners.blade.php | 90 ++++---
tests/Feature/GithubRunnerCapacityTest.php | 248 ++++++++++++++++++
...ithubRunnersAccessibleRepositoriesTest.php | 76 ++++++
8 files changed, 480 insertions(+), 85 deletions(-)
create mode 100644 database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php
create mode 100644 tests/Feature/GithubRunnerCapacityTest.php
diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php
index 1fcac5513..f66092eee 100644
--- a/app/Jobs/ProvisionGithubRunnerJob.php
+++ b/app/Jobs/ProvisionGithubRunnerJob.php
@@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
public array $workflowJobPayload,
public string $organizationLogin,
public int $repositoryId = 0,
+ public ?string $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);
diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php
index 4e39b19cc..280d6b8a6 100644
--- a/app/Livewire/Server/GithubRunners.php
+++ b/app/Livewire/Server/GithubRunners.php
@@ -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 {
diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php
index 7a7586ea7..62cf44174 100644
--- a/app/Models/GithubRunnerConfig.php
+++ b/app/Models/GithubRunnerConfig.php
@@ -16,6 +16,7 @@ class GithubRunnerConfig extends BaseModel
'labels' => 'array',
'is_enabled' => 'boolean',
'max_runners' => 'integer',
+ 'capacity_wait_timeout' => 'integer',
];
}
diff --git a/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php b/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php
new file mode 100644
index 000000000..5045e9bb0
--- /dev/null
+++ b/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php
@@ -0,0 +1,25 @@
+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');
+ });
+ }
+};
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php
index 62004c2ee..ece349df4 100644
--- a/resources/views/components/server/sidebar.blade.php
+++ b/resources/views/components/server/sidebar.blade.php
@@ -32,7 +32,7 @@
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}">