parent::delete()); } protected $fillable = [ 'team_id', 'private_key_id', 'name', 'organization', 'api_url', 'html_url', 'custom_user', 'custom_port', 'app_id', 'installation_id', 'client_id', 'client_secret', 'webhook_secret', 'is_system_wide', 'is_public', 'contents', 'metadata', 'pull_requests', 'deployments', 'administration', 'organization_self_hosted_runners', 'actions', 'webhook_events', 'runner_group_id', ]; protected $appends = ['type']; protected $casts = [ 'is_public' => 'boolean', 'is_system_wide' => 'boolean', 'type' => 'string', 'webhook_events' => 'array', 'runner_group_id' => 'integer', 'client_secret' => 'encrypted', 'webhook_secret' => 'encrypted', ]; protected $hidden = [ 'client_secret', 'webhook_secret', ]; /** * Installation token kept in memory, so one job does not request a new token for each GitHub call. * * @var array{credentials: string, token: string, expires_at: int}|null */ private ?array $installationTokenInMemory = null; public function installationToken(?int $timeout = null): string { $credentials = implode('|', [$this->api_url, $this->app_id, $this->installation_id, $this->private_key_id]); $cached = $this->installationTokenInMemory; if ($cached && $cached['credentials'] === $credentials && $cached['expires_at'] > now()->getTimestamp()) { return $cached['token']; } $token = generateGithubToken($this, 'installation', $timeout); // GitHub installation tokens expire after one hour; renew them well before that. $this->installationTokenInMemory = [ 'credentials' => $credentials, 'token' => $token, 'expires_at' => now()->addMinutes(50)->getTimestamp(), ]; return $token; } protected static function booted(): void { static::deleting(function (GithubApp $github_app) { $applications_count = $github_app->applications()->count(); if ($applications_count > 0) { throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.'); } // Runner rows cascade with the App, so live runner containers would lose their cleanup and keep taking jobs. $hasLiveRunners = $github_app->runnerConfigs()->where('is_enabled', true)->exists() || GithubRunnerExecution::where('github_app_id', $github_app->id) ->whereIn('status', GithubRunnerStatus::occupying()) ->exists(); if ($hasLiveRunners) { throw new \Exception('You cannot delete this GitHub App because servers use it for GitHub Actions runners. Disable the runners on these servers and wait for running jobs to finish first.'); } $privateKey = $github_app->privateKey; if ($privateKey) { // Check if key is used by anything EXCEPT this GitHub app $isUsedElsewhere = $privateKey->servers()->exists() || $privateKey->applications()->exists() || $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists() || $privateKey->gitlabApps()->exists() || $privateKey->giteaApps()->exists(); if (! $isUsedElsewhere) { $privateKey->delete(); } else { } } }); } public static function ownedByCurrentTeam() { return GithubApp::where(function ($query) { $query->where('team_id', currentTeam()->id) ->orWhere('is_system_wide', true); }); } public function team() { return $this->belongsTo(Team::class); } public function applications() { return $this->morphMany(Application::class, 'source'); } public function privateKey() { return $this->belongsTo(PrivateKey::class); } public function runnerConfigs() { return $this->hasMany(GithubRunnerConfig::class); } /** * Whether Coolify can create GitHub Deployments and their statuses with this App. */ public function canSyncDeployments(): bool { return ! $this->is_public && $this->missingDeploymentRequirements() === []; } /** * Permissions that GitHub deployments need but the App does not have. * * @return array */ public function missingDeploymentRequirements(): array { return $this->deployments === 'write' ? [] : ['Repository permission "Deployments": write']; } /** * Permissions and webhook events that GitHub Actions runners need but the App does not have. * * @return array */ public function missingRunnerRequirements(): array { $missing = []; if (blank($this->organization)) { $missing[] = 'An organization (runners are registered at organization level)'; } if ($this->organization_self_hosted_runners !== 'write') { $missing[] = 'Organization permission "Self-hosted runners": write'; } if (! in_array($this->actions, ['read', 'write'], true)) { $missing[] = 'Repository permission "Actions": read'; } if (! in_array('workflow_job', $this->webhook_events ?? [], true)) { $missing[] = 'Webhook event "Workflow job"'; } return $missing; } public function type(): Attribute { return Attribute::make( get: function () { if ($this->getMorphClass() === GithubApp::class) { return 'github'; } }, ); } /** * A private GitHub App is connected once it has been registered and installed. * Public sources do not require installation credentials. */ public function isConnected(): bool { if ($this->is_public) { return true; } return filled($this->app_id) && filled($this->installation_id); } /** * The settings page of this App at GitHub. */ public function settingsUrl(): string { $name = encodeGithubPathSegment($this->name); $organization = normalizeGithubOrganization($this->organization); if (filled($organization)) { return rtrim($this->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}"; } return rtrim($this->html_url, '/')."/settings/apps/{$name}"; } }