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.
This commit is contained in:
Andras Bacsai committed 2026-10-01 08:14:30 +02:00
1 parent d1cf3e6ef1
commit bcc07dd47e
8 files changed
+194 -14

No files matched your search

+5
View File
@@ -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();
+2
View File
@@ -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',
+20 -3
View File
@@ -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<int, string> $labels
* @return array{runner_id: int, encoded_jit_config: string}
@@ -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 "<execution uuid> <container name> <state>" lines.
*/
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Runners refuse pull request jobs unless this is enabled.
*/
public function up(): void
{
Schema::table('github_runner_configs', function (Blueprint $table) {
$table->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');
});
}
};
@@ -50,7 +50,7 @@
@else
<form wire:submit="submit" class="contents">
<x-unsaved-bar action="submit"
targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated" />
targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated,allowPullRequests" />
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
helper="Each workflow job runs in a new container that is deleted after the job. Use runs-on: [self-hosted, <your label>] in the workflow.">
<x-slot:actions>
@@ -64,10 +64,17 @@
</x-slot:actions>
<div x-cloak x-show="$wire.dockerMode === 'dind'">
<x-callout type="warning" title="Use only for trusted, private repositories">
<x-callout type="warning" title="Use only for trusted repositories">
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.
</x-callout>
</div>
<div x-cloak x-show="$wire.allowPullRequests" class="mt-4">
<x-callout type="warning" title="Pull request jobs run on this server">
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.
</x-callout>
</div>
@@ -98,6 +105,12 @@
['value' => true, 'label' => 'Dedicated to runners'],
]"
helper="Dedicated servers are not used for application builds while runners are enabled." />
<x-forms.listbox id="allowPullRequests" label="Pull request jobs" canGate="update"
:canResource="$server" :options="[
['value' => false, 'label' => 'Refuse pull request jobs'],
['value' => true, 'label' => 'Run pull request jobs'],
]"
helper="Runners fail jobs that a pull request started (pull_request, pull_request_target, and review events) before any step runs. Push, schedule, and manual jobs always run. Applies to runners that start after you save." />
</div>
</x-application.settings-section>
+41 -3
View File
@@ -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) {
+49 -3
View File
@@ -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],
]);