From bcc07dd47e206389f21e2daab01b2e7ef9e46ff5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:14:30 +0200 Subject: [PATCH] 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. --- app/Livewire/Server/GithubRunners.php | 5 ++ app/Models/GithubRunnerConfig.php | 2 + app/Services/GithubRunner/GithubRunnerApi.php | 23 ++++++-- .../GithubRunner/GithubRunnerContainer.php | 36 ++++++++++++- ...equests_to_github_runner_configs_table.php | 25 +++++++++ .../livewire/server/github-runners.blade.php | 21 ++++++-- tests/Feature/GithubRunnerSettingsTest.php | 44 ++++++++++++++-- tests/Unit/GithubRunnerContainerTest.php | 52 +++++++++++++++++-- 8 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 database/migrations/2026_10_01_051935_add_allow_pull_requests_to_github_runner_configs_table.php diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 4b95ed67be..d0e537640b 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -53,6 +53,8 @@ class GithubRunners extends Component public bool $isDedicated = false; + public bool $allowPullRequests = false; + /** Null until checked over SSH. */ public ?bool $isSysboxInstalled = null; @@ -87,6 +89,7 @@ class GithubRunners extends Component 'idleTimeout' => ['required', 'integer', 'min:1', 'max:1440'], 'jobTimeout' => ['required', 'integer', 'min:1', 'max:7200'], 'isDedicated' => ['required', 'boolean'], + 'allowPullRequests' => ['required', 'boolean'], ]; } @@ -139,6 +142,7 @@ class GithubRunners extends Component $this->idleTimeout = $config->idle_timeout; $this->jobTimeout = $config->job_timeout; $this->isDedicated = $config->is_dedicated; + $this->allowPullRequests = $config->allow_pull_requests; } /** @@ -199,6 +203,7 @@ class GithubRunners extends Component 'idle_timeout' => $this->idleTimeout, 'job_timeout' => $this->jobTimeout, 'is_dedicated' => $this->isDedicated, + 'allow_pull_requests' => $this->allowPullRequests, ]); unset($this->config); $this->syncData(); diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php index 7f848107c8..0aa2af8134 100644 --- a/app/Models/GithubRunnerConfig.php +++ b/app/Models/GithubRunnerConfig.php @@ -20,6 +20,7 @@ class GithubRunnerConfig extends BaseModel 'labels', 'is_enabled', 'is_dedicated', + 'allow_pull_requests', 'max_runners', 'docker_mode', 'runner_image', @@ -36,6 +37,7 @@ class GithubRunnerConfig extends BaseModel 'labels' => 'array', 'is_enabled' => 'boolean', 'is_dedicated' => 'boolean', + 'allow_pull_requests' => 'boolean', 'max_runners' => 'integer', 'docker_mode' => GithubRunnerDockerMode::class, 'capacity_wait_timeout' => 'integer', diff --git a/app/Services/GithubRunner/GithubRunnerApi.php b/app/Services/GithubRunner/GithubRunnerApi.php index 8f25b18380..8e0ae46e76 100644 --- a/app/Services/GithubRunner/GithubRunnerApi.php +++ b/app/Services/GithubRunner/GithubRunnerApi.php @@ -16,7 +16,9 @@ class GithubRunnerApi public function __construct(private GithubApp $githubApp) {} /** - * Finds or creates the runner group of the App. The group is limited to private repositories. + * 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. * @@ -29,6 +31,10 @@ class GithubRunnerApi 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) { @@ -38,8 +44,8 @@ class GithubRunnerApi $response = $this->client()->post("/orgs/{$org}/actions/runner-groups", [ 'name' => 'Coolify '.$this->githubApp->uuid, - 'visibility' => 'private', - 'allows_public_repositories' => false, + 'visibility' => 'all', + 'allows_public_repositories' => true, ]); if (! $response->successful() || ! $response->json('id')) { throw new RuntimeException('Could not create a runner group: '.$this->errorMessage($response)); @@ -51,6 +57,17 @@ class GithubRunnerApi 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 $labels * @return array{runner_id: int, encoded_jit_config: string} diff --git a/app/Services/GithubRunner/GithubRunnerContainer.php b/app/Services/GithubRunner/GithubRunnerContainer.php index 591aadf067..82fa0433c8 100644 --- a/app/Services/GithubRunner/GithubRunnerContainer.php +++ b/app/Services/GithubRunner/GithubRunnerContainer.php @@ -10,6 +10,7 @@ use App\Models\GithubRunnerExecution; /** * Builds the remote shell commands for one ephemeral runner. Each runner gets its own network, * volumes, and (in dind and sysbox modes) a private Docker daemon. The host Docker socket is never mounted. + * Unless the config allows pull request jobs, a job-started hook fails them before any step runs. * Every command is a single line that is safe for parseCommandsByLineForSudo(). */ class GithubRunnerContainer @@ -27,6 +28,8 @@ class GithubRunnerContainer private const PIDS_LIMIT = 4096; + private const HOOK_PATH = '/home/runner/coolify-job-started.sh'; + public function __construct(private GithubRunnerExecution $execution, private GithubRunnerConfig $config) {} public static function nameFor(string $executionUuid): string @@ -110,6 +113,12 @@ class GithubRunnerContainer ]; $runnerFlags = "--name {$name} --network {$name} {$this->labelFlags()} {$this->limitFlags()} --env-file {$envFile}"; + if (! $this->config->allow_pull_requests) { + $hookFile = self::hookFileFor($this->execution->uuid); + $commands[] = 'echo '.base64_encode(self::pullRequestHookScript())." | base64 -d | tee {$hookFile} > /dev/null"; + $commands[] = "chmod 644 {$hookFile}"; + $runnerFlags .= " -v {$hookFile}:".self::HOOK_PATH.':ro -e ACTIONS_RUNNER_HOOK_JOB_STARTED='.self::HOOK_PATH; + } if ($this->usesDind()) { $commands[] = "docker exec {$this->dindName()} chown ".self::RUNNER_UID.':'.self::RUNNER_DOCKER_GID.' /home/runner/_work'; $runnerFlags .= ' -e DOCKER_HOST=unix:///var/run/docker.sock -e RUNNER_WAIT_FOR_DOCKER_IN_SECONDS=120 '.$this->sharedVolumeFlags(); @@ -133,10 +142,35 @@ class GithubRunnerContainer "docker rm -f -v {$name} {$name}-dind > /dev/null 2>&1 || true", "docker volume rm {$name}-sock {$name}-work {$name}-externals > /dev/null 2>&1 || true", "docker network rm {$name} > /dev/null 2>&1 || true", - 'rm -f '.self::SECRETS_DIRECTORY."/{$executionUuid}.env", + 'rm -f '.self::SECRETS_DIRECTORY."/{$executionUuid}.env ".self::hookFileFor($executionUuid), ]; } + /** + * Job-started hook that fails pull request jobs before any step runs. GitHub gives a runner + * any queued job with matching labels, so the check must run in the runner, not when Coolify + * starts it. An unknown event fails closed. + */ + public static function pullRequestHookScript(): string + { + return <<<'BASH' + #!/bin/bash + case "${GITHUB_EVENT_NAME:-}" in + pull_request|pull_request_target|pull_request_review|pull_request_review_comment|"") + echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the build server to run them." + exit 1 + ;; + esac + exit 0 + + BASH; + } + + private static function hookFileFor(string $executionUuid): string + { + return self::SECRETS_DIRECTORY."/{$executionUuid}-job-started.sh"; + } + /** * Lists runner containers as " " lines. */ diff --git a/database/migrations/2026_10_01_051935_add_allow_pull_requests_to_github_runner_configs_table.php b/database/migrations/2026_10_01_051935_add_allow_pull_requests_to_github_runner_configs_table.php new file mode 100644 index 0000000000..a029ef78b1 --- /dev/null +++ b/database/migrations/2026_10_01_051935_add_allow_pull_requests_to_github_runner_configs_table.php @@ -0,0 +1,25 @@ +boolean('allow_pull_requests')->default(false)->after('is_dedicated'); + }); + } + + public function down(): void + { + Schema::table('github_runner_configs', function (Blueprint $table) { + $table->dropColumn('allow_pull_requests'); + }); + } +}; diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php index c98cc92477..7a35ef0e07 100644 --- a/resources/views/livewire/server/github-runners.blade.php +++ b/resources/views/livewire/server/github-runners.blade.php @@ -50,7 +50,7 @@ @else
+ targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated,allowPullRequests" /> @@ -64,10 +64,17 @@
- + In the privileged Docker mode, a malicious workflow can take control of this server. - Use Isolated Docker (Sysbox) or No Docker to keep jobs away from the server. Coolify - limits the runner group to private repositories. + Use Isolated Docker (Sysbox) or No Docker to keep jobs away from the server. Every + repository of the organization can use these runners, public ones too. + +
+
+ + In a public repository, anyone can open a pull request and change its workflow. Turn + on approval for fork pull request workflows in the organization's Actions settings on + GitHub, and do not use the privileged Docker mode.
@@ -98,6 +105,12 @@ ['value' => true, 'label' => 'Dedicated to runners'], ]" helper="Dedicated servers are not used for application builds while runners are enabled." /> +
diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index 37f9b43361..df4619b991 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -107,7 +107,7 @@ function fakeRunnerGroupApi(GithubApp $githubApp, int $createStatus = 201): void } describe('runner settings page', function () { - it('saves the configuration and creates a runner group for private repositories only', function () { + it('saves the configuration and creates a runner group that public repositories can use', function () { fakeRunnerGroupApi($this->githubApp); Livewire::actingAs($this->owner) @@ -128,8 +128,46 @@ describe('runner settings page', function () { ->and($this->githubApp->fresh()->runner_group_id)->toBe(55); Http::assertSent(fn ($request) => $request->method() === 'POST' && str_ends_with($request->url(), '/orgs/acme/actions/runner-groups') - && $request['allows_public_repositories'] === false - && $request['visibility'] === 'private'); + && $request['allows_public_repositories'] === true + && $request['visibility'] === 'all'); + expect($config->allow_pull_requests)->toBeFalse(); + }); + + it('opens an existing private runner group to public repositories', function () { + $this->githubApp->update(['runner_group_id' => 77]); + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + "https://api.github.com/app/installations/{$this->githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runner-groups/77' => fn ($request) => $request->method() === 'PATCH' + ? Http::response(['id' => 77, 'default' => false, 'visibility' => 'all', 'allows_public_repositories' => true]) + : Http::response(['id' => 77, 'default' => false, 'visibility' => 'private', 'allows_public_repositories' => false]), + ]); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->set('githubAppId', $this->githubApp->id) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + Http::assertSent(fn ($request) => $request->method() === 'PATCH' + && str_ends_with($request->url(), '/orgs/acme/actions/runner-groups/77') + && $request['allows_public_repositories'] === true + && $request['visibility'] === 'all'); + }); + + it('saves whether runners take pull request jobs', function () { + fakeRunnerGroupApi($this->githubApp); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->set('githubAppId', $this->githubApp->id) + ->set('allowPullRequests', true) + ->call('submit') + ->assertHasNoErrors() + ->assertSet('allowPullRequests', true); + + expect(GithubRunnerConfig::sole()->allow_pull_requests)->toBeTrue(); }); it('does not fall back to the Default runner group when group creation fails', function (int $status) { diff --git a/tests/Unit/GithubRunnerContainerTest.php b/tests/Unit/GithubRunnerContainerTest.php index 5e08b062a5..48017decdf 100644 --- a/tests/Unit/GithubRunnerContainerTest.php +++ b/tests/Unit/GithubRunnerContainerTest.php @@ -4,6 +4,7 @@ use App\Models\GithubRunnerConfig; use App\Models\GithubRunnerExecution; use App\Models\Server; use App\Services\GithubRunner\GithubRunnerContainer; +use Symfony\Component\Process\Process; use Tests\TestCase; uses(TestCase::class); @@ -44,9 +45,10 @@ it('passes the JIT config through a root-only env file and never mounts the host ->toContain(base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG=SECRET\n")) ->not->toContain('docker.sock:/var/run/docker.sock') ->and($commands[2])->toBe('chmod 600 /data/coolify/github-runners/abc123.env') - ->and($commands[4]) + ->and(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 '))) ->toStartWith('docker run -d --name coolify-runner-abc123 --network coolify-runner-abc123') - ->toContain('--env-file /data/coolify/github-runners/abc123.env -e DOCKER_HOST=unix:///var/run/docker.sock') + ->toContain('--env-file /data/coolify/github-runners/abc123.env ') + ->toContain('-e DOCKER_HOST=unix:///var/run/docker.sock') ->toEndWith("'".config('constants.github_runner.image')."' /home/runner/run.sh") ->and(end($commands))->toBe('rm -f /data/coolify/github-runners/abc123.env'); }); @@ -93,7 +95,8 @@ it('quotes a custom runner image', function () { $this->config->forceFill(['runner_image' => 'ghcr.io/acme/runner:1.0']); $commands = (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET'); - expect($commands[4])->toEndWith("'ghcr.io/acme/runner:1.0' /home/runner/run.sh"); + expect(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 '))) + ->toEndWith("'ghcr.io/acme/runner:1.0' /home/runner/run.sh"); }); it('keeps every command valid for servers with a non-root user', function () { @@ -112,6 +115,8 @@ it('keeps every command valid for servers with a non-root user', function () { ->toContain('sudo docker pull '."'".config('constants.github_runner.image')."'".' || sudo docker image inspect '."'".config('constants.github_runner.image')."'".' > /dev/null') ->toContain('echo '.base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG=SECRET\n").' | sudo base64 -d | sudo tee /data/coolify/github-runners/abc123.env > /dev/null') ->toContain('sudo chmod 600 /data/coolify/github-runners/abc123.env') + ->toContain('echo '.base64_encode(GithubRunnerContainer::pullRequestHookScript()).' | sudo base64 -d | sudo tee /data/coolify/github-runners/abc123-job-started.sh > /dev/null') + ->toContain('sudo chmod 644 /data/coolify/github-runners/abc123-job-started.sh') ->toContain('sudo docker rm -f -v coolify-runner-abc123 coolify-runner-abc123-dind > /dev/null 2>&1 || sudo true') ->toContain("sudo docker exec coolify-runner-abc123-dind docker version --format '{{.Server.Version}}'") ->toContain('sudo docker ps -a --filter label=coolify.githubRunner=true --format \'{{.Label "coolify.githubRunnerExecution"}} {{.Names}} {{.State}}\''); @@ -119,3 +124,44 @@ it('keeps every command valid for servers with a non-root user', function () { $linesWithoutSudo = collect($parsed)->reject(fn (string $line) => str_starts_with($line, 'sudo ') || str_starts_with($line, 'echo ')); expect($linesWithoutSudo)->toBeEmpty(); }); + +it('installs a job-started hook that refuses pull request jobs by default', function () { + $commands = (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET'); + $hookFile = '/data/coolify/github-runners/abc123-job-started.sh'; + + expect($commands)->toContain('echo '.base64_encode(GithubRunnerContainer::pullRequestHookScript()).' | base64 -d | tee '.$hookFile.' > /dev/null') + ->toContain("chmod 644 {$hookFile}") + ->and(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 '))) + ->toContain("-v {$hookFile}:/home/runner/coolify-job-started.sh:ro -e ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/coolify-job-started.sh") + ->and(GithubRunnerContainer::cleanupCommands('abc123'))->toContain("rm -f /data/coolify/github-runners/abc123.env {$hookFile}"); +}); + +it('does not install the pull request hook when pull request jobs are allowed', function () { + $this->config->forceFill(['allow_pull_requests' => true]); + $all = implode("\n", (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET')); + + expect($all)->not->toContain('job-started') + ->not->toContain('ACTIONS_RUNNER_HOOK_JOB_STARTED'); +}); + +it('fails pull request jobs in the job-started hook and lets other jobs run', function (?string $eventName, int $expectedExitCode) { + $hook = tempnam(sys_get_temp_dir(), 'hook'); + file_put_contents($hook, GithubRunnerContainer::pullRequestHookScript()); + + $environment = $eventName === null ? [] : ['GITHUB_EVENT_NAME' => $eventName]; + $process = new Process(['bash', $hook], env: ['PATH' => getenv('PATH'), ...$environment]); + $process->run(); + unlink($hook); + + expect($process->getExitCode())->toBe($expectedExitCode); +})->with([ + 'pull_request' => ['pull_request', 1], + 'pull_request_target' => ['pull_request_target', 1], + 'pull_request_review' => ['pull_request_review', 1], + 'pull_request_review_comment' => ['pull_request_review_comment', 1], + 'unknown event fails closed' => [null, 1], + 'push' => ['push', 0], + 'workflow_dispatch' => ['workflow_dispatch', 0], + 'schedule' => ['schedule', 0], + 'merge_group' => ['merge_group', 0], +]);