Files
coolify/app/Services/GithubRunner/GithubRunnerApi.php
T
Andras Bacsai bcc07dd47e feat(github-runners): allow public repositories and refuse pull request jobs by default
The runner group was limited to private repositories, so jobs from
public repositories never got a runner: Coolify still started a runner
for each matching job, which then waited idle until its timeout.

The runner group now allows every repository of the organization,
public ones too; existing private groups are updated. GitHub gives a
runner any queued job with matching labels, so pull request jobs are
refused inside the runner: a job-started hook fails jobs from
pull_request, pull_request_target and review events before any step
runs, and fails closed when the event is unknown. A new runner setting,
off by default, allows pull request jobs; the settings page then warns
to require approval for fork pull request workflows on GitHub.
2026-10-01 08:14:30 +02:00

126 lines
4.9 KiB
PHP

<?php
namespace App\Services\GithubRunner;
use App\Models\GithubApp;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Organization-level GitHub Actions runner API calls made with the App installation token.
*/
class GithubRunnerApi
{
public function __construct(private GithubApp $githubApp) {}
/**
* Finds or creates the runner group of the App. Every repository of the organization, public ones too,
* can use it; runners refuse pull request jobs unless the runner config allows them.
* Groups created before public repositories were allowed are updated.
* All GitHub plans can create runner groups, so a failed creation is an error. Coolify never falls back
* to the Default group, because that group can give the runners access to more repositories.
*
* @return array{id: int, is_default: bool}
*/
public function ensureRunnerGroup(): array
{
$org = $this->organization();
if ($this->githubApp->runner_group_id) {
$response = $this->client()->get("/orgs/{$org}/actions/runner-groups/{$this->githubApp->runner_group_id}");
if ($response->successful()) {
if (! $response->json('default') && ($response->json('visibility') !== 'all' || $response->json('allows_public_repositories') !== true)) {
$this->allowAllRepositories((int) $this->githubApp->runner_group_id);
}
return ['id' => (int) $this->githubApp->runner_group_id, 'is_default' => (bool) $response->json('default')];
}
if ($response->status() !== 404) {
throw new RuntimeException('Could not read the runner group: '.$this->errorMessage($response));
}
}
$response = $this->client()->post("/orgs/{$org}/actions/runner-groups", [
'name' => 'Coolify '.$this->githubApp->uuid,
'visibility' => 'all',
'allows_public_repositories' => true,
]);
if (! $response->successful() || ! $response->json('id')) {
throw new RuntimeException('Could not create a runner group: '.$this->errorMessage($response));
}
$group = ['id' => (int) $response->json('id'), 'is_default' => false];
$this->githubApp->update(['runner_group_id' => $group['id']]);
return $group;
}
private function allowAllRepositories(int $runnerGroupId): void
{
$response = $this->client()->patch("/orgs/{$this->organization()}/actions/runner-groups/{$runnerGroupId}", [
'visibility' => 'all',
'allows_public_repositories' => true,
]);
if (! $response->successful()) {
throw new RuntimeException('Could not update the runner group: '.$this->errorMessage($response));
}
}
/**
* @param array<int, string> $labels
* @return array{runner_id: int, encoded_jit_config: string}
*/
public function generateJitConfig(string $runnerName, array $labels, int $runnerGroupId): array
{
$response = $this->client()->post("/orgs/{$this->organization()}/actions/runners/generate-jitconfig", [
'name' => $runnerName,
'runner_group_id' => $runnerGroupId,
'labels' => $labels,
'work_folder' => '_work',
]);
if (! $response->successful() || blank($response->json('encoded_jit_config'))) {
throw new RuntimeException('Could not register the runner on GitHub: '.$this->errorMessage($response));
}
return [
'runner_id' => (int) $response->json('runner.id'),
'encoded_jit_config' => (string) $response->json('encoded_jit_config'),
];
}
/**
* Removes a runner from GitHub. GitHub removes JIT runners itself after their job, so 404 is not an error.
*/
public function deleteRunner(int $runnerId): void
{
$response = $this->client()->delete("/orgs/{$this->organization()}/actions/runners/{$runnerId}");
if (! $response->successful() && $response->status() !== 404) {
throw new RuntimeException('Could not remove the runner from GitHub: '.$this->errorMessage($response));
}
}
private function client(): PendingRequest
{
return Http::GitHub($this->githubApp->api_url, generateGithubInstallationToken($this->githubApp))->timeout(30);
}
private function organization(): string
{
$organization = trim((string) $this->githubApp->organization);
if ($organization === '') {
throw new RuntimeException('GitHub Actions runners need a GitHub App that belongs to an organization.');
}
return rawurlencode($organization);
}
private function errorMessage(Response $response): string
{
return (string) ($response->json('message') ?? "HTTP {$response->status()}");
}
}