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]) }}">