mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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.
126 lines
4.9 KiB
PHP
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()}");
|
|
}
|
|
}
|