mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
912 lines
44 KiB
PHP
912 lines
44 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Webhook;
|
|
|
|
use App\Enums\GithubRunnerStatus;
|
|
use App\Exceptions\InvalidWebhookPayloadException;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
|
use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
|
|
use App\Jobs\CleanupGithubRunnerJob;
|
|
use App\Jobs\GithubAppPermissionJob;
|
|
use App\Jobs\ProcessGithubPullRequestWebhook;
|
|
use App\Jobs\ProvisionGithubRunnerJob;
|
|
use App\Models\Application;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GithubRunnerConfig;
|
|
use App\Models\GithubRunnerExecution;
|
|
use App\Models\PrivateKey;
|
|
use App\Services\GithubRunner\GithubRunnerApi;
|
|
use App\Services\GithubRunner\GithubRunnerContainer;
|
|
use App\Services\ServerTransfer\TransferFinishChecklist;
|
|
use Exception;
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class Github extends Controller
|
|
{
|
|
use DetectsSkipDeployCommits;
|
|
use MatchesManualWebhookApplications;
|
|
use ReadsWebhookPushPayload;
|
|
|
|
public function manual(Request $request)
|
|
{
|
|
try {
|
|
$return_payloads = collect([]);
|
|
$x_github_delivery = request()->header('X-GitHub-Delivery');
|
|
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
|
|
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
|
|
$content_type = $request->header('Content-Type');
|
|
$payload = $request->collect();
|
|
if ($x_github_event === 'ping') {
|
|
// Just pong
|
|
return response('pong');
|
|
}
|
|
|
|
if ($content_type !== 'application/json') {
|
|
$form_payload = data_get($payload, 'payload');
|
|
$payload = is_string($form_payload) ? json_decode($form_payload, true) : null;
|
|
}
|
|
if ($x_github_event === 'push') {
|
|
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
|
|
$full_name = data_get($payload, 'repository.full_name');
|
|
$commit = $this->webhookCommitSha($payload, 'after');
|
|
$changed_files = $this->webhookPushChangedFiles($payload);
|
|
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
|
}
|
|
if ($x_github_event === 'pull_request') {
|
|
[
|
|
'action' => $action,
|
|
'pull_request_id' => $pull_request_id,
|
|
'pull_request_html_url' => $pull_request_html_url,
|
|
'pull_request_title' => $pull_request_title,
|
|
'branch' => $branch,
|
|
'base_branch' => $base_branch,
|
|
'before_sha' => $before_sha,
|
|
'after_sha' => $after_sha,
|
|
'commit_sha' => $commit_sha,
|
|
'is_fork_pull_request' => $is_fork_pull_request,
|
|
] = $this->readPullRequestPayload($payload);
|
|
$full_name = data_get($payload, 'repository.full_name');
|
|
}
|
|
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
|
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
|
}
|
|
if (! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
|
|
return response('Nothing to do. No branch found in the request.');
|
|
}
|
|
// A deleted branch has no commit to deploy. No secret is checked here.
|
|
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
|
|
return response('Nothing to do. Branch deleted.');
|
|
}
|
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
|
if ($full_name === null) {
|
|
return response('Nothing to do. Invalid repository.');
|
|
}
|
|
$matched_branch = match (true) {
|
|
$x_github_event === 'push' => $branch,
|
|
$action === 'closed' => null,
|
|
default => $base_branch,
|
|
};
|
|
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch);
|
|
// A redelivery of the same signed payload is one guess.
|
|
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256);
|
|
$applications = Application::query();
|
|
if ($x_github_event === 'push' || $action !== 'closed') {
|
|
$applications->where('git_branch', $matched_branch);
|
|
}
|
|
$applications = $this->manualWebhookApplications($applications, $full_name);
|
|
if ($applications->isEmpty()) {
|
|
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
|
|
}
|
|
$applicationsByServer = $applications->groupBy(function ($app) {
|
|
return $app->destination->server_id;
|
|
});
|
|
|
|
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
|
foreach ($serverApplications as $application) {
|
|
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
|
if (empty($webhook_secret)) {
|
|
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'repository' => $full_name ?? null,
|
|
'mode' => 'manual',
|
|
]);
|
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
|
|
|
continue;
|
|
}
|
|
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
|
if (! hash_equals($x_hub_signature_256, $hmac)) {
|
|
auditLogWebhookFailure('github', 'invalid_signature', [
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'repository' => $full_name ?? null,
|
|
'mode' => 'manual',
|
|
]);
|
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
|
|
|
continue;
|
|
}
|
|
TransferFinishChecklist::recordWebhook('application', $application->uuid);
|
|
$isFunctional = $application->destination->server->isFunctional();
|
|
if (! $isFunctional) {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'failed',
|
|
'message' => 'Server is not functional.',
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
if ($x_github_event === 'push') {
|
|
if ($application->isDeployable()) {
|
|
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
|
|
if ($skip_deploy_commits ?? false) {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'skipped',
|
|
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$deployment_uuid = new_public_id();
|
|
$result = queue_application_deployment(
|
|
application: $application,
|
|
deployment_uuid: $deployment_uuid,
|
|
force_rebuild: false,
|
|
commit: $commit ?? 'HEAD',
|
|
is_webhook: true,
|
|
);
|
|
if ($result['status'] === 'queue_full') {
|
|
return response($result['message'], 429)->header('Retry-After', 60);
|
|
} elseif ($result['status'] === 'skipped') {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'skipped',
|
|
'message' => $result['message'],
|
|
]);
|
|
} else {
|
|
auditLog('webhook.deployment.queued', [
|
|
'provider' => 'github',
|
|
'mode' => 'manual',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $result['deployment_uuid'],
|
|
'commit' => $commit,
|
|
'repository' => $full_name ?? null,
|
|
]);
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'success',
|
|
'message' => 'Deployment queued.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $result['deployment_uuid'],
|
|
]);
|
|
}
|
|
} else {
|
|
$paths = str($application->watch_paths)->explode("\n");
|
|
$return_payloads->push([
|
|
'status' => 'failed',
|
|
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'details' => [
|
|
'changed_files' => $changed_files,
|
|
'watch_paths' => $paths,
|
|
],
|
|
]);
|
|
}
|
|
} else {
|
|
$return_payloads->push([
|
|
'status' => 'failed',
|
|
'message' => 'Deployments disabled.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
]);
|
|
}
|
|
}
|
|
if ($x_github_event === 'pull_request') {
|
|
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
|
|
if (! $application->isPRDeployable() && $action !== 'closed') {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'failed',
|
|
'message' => 'Preview deployments disabled.',
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
ProcessGithubPullRequestWebhook::dispatch(
|
|
applicationId: $application->id,
|
|
githubAppId: null,
|
|
action: $action,
|
|
pullRequestId: $pull_request_id,
|
|
pullRequestHtmlUrl: $pull_request_html_url,
|
|
pullRequestTitle: $pull_request_title,
|
|
beforeSha: $before_sha,
|
|
afterSha: $after_sha,
|
|
commitSha: $commit_sha,
|
|
fullName: $full_name,
|
|
isForkPullRequest: $is_fork_pull_request,
|
|
);
|
|
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'queued',
|
|
'message' => 'PR webhook received, processing queued.',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
|
|
} catch (InvalidWebhookPayloadException $e) {
|
|
return response($e->getMessage());
|
|
} catch (Exception $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cuts a webhook string to the varchar(255) column of the runner tables.
|
|
*/
|
|
private function limitRunnerColumn(mixed $value): ?string
|
|
{
|
|
return is_scalar($value) ? mb_substr((string) $value, 0, 255) : null;
|
|
}
|
|
|
|
/**
|
|
* Handles GitHub Actions runner demand. Every lookup is scoped to the App whose signature was verified.
|
|
* GitHub can give a job to any idle runner with matching labels, so progress is matched by runner name.
|
|
*/
|
|
private function handleWorkflowJob(GithubApp $githubApp, Collection $payload)
|
|
{
|
|
$action = data_get($payload, 'action');
|
|
$jobId = (int) data_get($payload, 'workflow_job.id', 0);
|
|
$runnerName = (string) data_get($payload, 'workflow_job.runner_name', '');
|
|
if ($jobId <= 0) {
|
|
return response('Nothing to do. No workflow job found.');
|
|
}
|
|
// A public App can be installed by other accounts; only its own installation may use the runners.
|
|
$installationId = data_get($payload, 'installation.id');
|
|
if (blank($githubApp->installation_id) || ! is_numeric($installationId) || (int) $installationId !== (int) $githubApp->installation_id) {
|
|
return response('Nothing to do. The job belongs to another installation of the GitHub App.');
|
|
}
|
|
|
|
// The columns are varchar(255). A cut URL would be a broken link, so a longer one is not stored.
|
|
$htmlUrl = data_get($payload, 'workflow_job.html_url');
|
|
$jobDetails = array_filter([
|
|
'workflow_job_id' => $jobId,
|
|
'workflow_job_html_url' => is_string($htmlUrl) && mb_strlen($htmlUrl) <= 255 ? $htmlUrl : null,
|
|
'workflow_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.workflow_name')),
|
|
'job_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.name')),
|
|
'repository_full_name' => $this->limitRunnerColumn(data_get($payload, 'repository.full_name')),
|
|
], fn ($value) => filled($value));
|
|
|
|
if ($action === 'queued') {
|
|
$labels = array_values(array_filter((array) data_get($payload, 'workflow_job.labels', []), 'is_string'));
|
|
$matchingConfigs = $githubApp->runnerConfigs()
|
|
->where('is_enabled', true)
|
|
->get()
|
|
->filter(fn (GithubRunnerConfig $config) => $config->matchesLabels($labels));
|
|
if ($matchingConfigs->isEmpty()) {
|
|
return response('Nothing to do. No runner configuration matches the job labels.');
|
|
}
|
|
$isPullRequest = $matchingConfigs->contains(fn (GithubRunnerConfig $config) => ! $config->allow_pull_requests)
|
|
&& $this->isPullRequestWorkflowJob($githubApp, $payload);
|
|
if ($isPullRequest && ! $matchingConfigs->contains('allow_pull_requests', true)) {
|
|
return response('Nothing to do. The runner configuration does not allow pull request jobs.');
|
|
}
|
|
|
|
$execution = GithubRunnerExecution::createOrFirst(
|
|
['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId],
|
|
[...$jobDetails, 'labels' => $labels, 'is_pull_request' => $isPullRequest, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()],
|
|
);
|
|
if ($execution->wasRecentlyCreated) {
|
|
ProvisionGithubRunnerJob::dispatch($execution->id);
|
|
}
|
|
|
|
return response('Runner queued.');
|
|
}
|
|
|
|
$execution = filled($runnerName)
|
|
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
|
|
: null;
|
|
|
|
// The completed hook can arrive before the in_progress hook.
|
|
if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
|
$replacement = $execution->assignJob($jobId, $jobDetails);
|
|
if ($replacement) {
|
|
ProvisionGithubRunnerJob::dispatch($replacement->id);
|
|
}
|
|
if ($action === 'in_progress') {
|
|
return response('Runner marked running.');
|
|
}
|
|
}
|
|
|
|
if ($action === 'completed') {
|
|
if ($execution) {
|
|
$execution->update([...$jobDetails, 'conclusion' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.conclusion'))]);
|
|
if ($execution->isActive() || $execution->status === GithubRunnerStatus::Failed) {
|
|
$execution->finish(GithubRunnerStatus::Completed);
|
|
CleanupGithubRunnerJob::dispatch($execution->id);
|
|
}
|
|
|
|
return response('Runner cleanup queued.');
|
|
}
|
|
|
|
// The job ended without a Coolify runner, so its queued runner is no longer needed.
|
|
GithubRunnerExecution::query()
|
|
->where('github_app_id', $githubApp->id)
|
|
->where('trigger_workflow_job_id', $jobId)
|
|
->where('status', GithubRunnerStatus::Queued)
|
|
->first()
|
|
?->finish(GithubRunnerStatus::Cancelled, 'The job ended before a runner started.');
|
|
|
|
return response('Queued runner cancelled.');
|
|
}
|
|
|
|
return response('Nothing to do.');
|
|
}
|
|
|
|
/**
|
|
* Whether a queued job belongs to a workflow run started by a pull request, so no runner is started
|
|
* for a job that the job-started hook would refuse. The `workflow_job` payload has no event, so the
|
|
* run is read from GitHub. When that fails, the job counts as no pull request job and the hook still
|
|
* refuses it in the runner.
|
|
*/
|
|
private function isPullRequestWorkflowJob(GithubApp $githubApp, Collection $payload): bool
|
|
{
|
|
$runId = (int) data_get($payload, 'workflow_job.run_id', 0);
|
|
$repository = (string) data_get($payload, 'repository.full_name', '');
|
|
if ($runId <= 0 || ! str_contains($repository, '/')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$event = (new GithubRunnerApi($githubApp))->workflowRunEvent($repository, $runId);
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($event, GithubRunnerContainer::PULL_REQUEST_EVENTS, true);
|
|
}
|
|
|
|
public function normal(Request $request)
|
|
{
|
|
try {
|
|
$return_payloads = collect([]);
|
|
$id = null;
|
|
$x_github_delivery = $request->header('X-GitHub-Delivery');
|
|
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
|
|
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
|
|
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
|
|
$payload = $request->collect();
|
|
if ($x_github_event === 'ping') {
|
|
// Just pong
|
|
return response('pong');
|
|
}
|
|
// One GitHub App can be installed on several accounts, each added as its own source.
|
|
$installation_id = data_get($payload, 'installation.id');
|
|
$github_app = is_numeric($installation_id)
|
|
? GithubApp::where('app_id', $x_github_hook_installation_target_id)->where('installation_id', (int) $installation_id)->first()
|
|
: null;
|
|
$github_app ??= GithubApp::where('app_id', $x_github_hook_installation_target_id)->first();
|
|
if (is_null($github_app)) {
|
|
return response('Nothing to do. No GitHub App found.');
|
|
}
|
|
$webhook_secret = data_get($github_app, 'webhook_secret');
|
|
if (empty($webhook_secret)) {
|
|
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
|
'mode' => 'app',
|
|
'github_app_id' => $github_app->id,
|
|
'github_app_name' => $github_app->name,
|
|
'installation_target_id' => $x_github_hook_installation_target_id,
|
|
]);
|
|
|
|
return response('Invalid signature.');
|
|
}
|
|
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
|
if (! hash_equals($x_hub_signature_256, $hmac)) {
|
|
auditLogWebhookFailure('github', 'invalid_signature', [
|
|
'mode' => 'app',
|
|
'github_app_id' => $github_app->id,
|
|
'github_app_name' => $github_app->name,
|
|
'installation_target_id' => $x_github_hook_installation_target_id,
|
|
]);
|
|
|
|
return response('Invalid signature.');
|
|
}
|
|
TransferFinishChecklist::recordWebhook('github_app', $github_app->uuid);
|
|
if ($x_github_event === 'installation' || $x_github_event === 'installation_repositories') {
|
|
// Installation handled by setup redirect url. Repositories queried on-demand.
|
|
$action = data_get($payload, 'action');
|
|
if ($action === 'new_permissions_accepted') {
|
|
GithubAppPermissionJob::dispatch($github_app);
|
|
}
|
|
|
|
return response('cool');
|
|
}
|
|
if ($x_github_event === 'workflow_job') {
|
|
return $this->handleWorkflowJob($github_app, $payload);
|
|
}
|
|
if ($x_github_event === 'push') {
|
|
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
|
|
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
|
|
$commit = $this->webhookCommitSha($payload, 'after');
|
|
$changed_files = $this->webhookPushChangedFiles($payload);
|
|
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
|
}
|
|
if ($x_github_event === 'pull_request') {
|
|
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
|
|
[
|
|
'action' => $action,
|
|
'pull_request_id' => $pull_request_id,
|
|
'pull_request_html_url' => $pull_request_html_url,
|
|
'pull_request_title' => $pull_request_title,
|
|
'branch' => $branch,
|
|
'base_branch' => $base_branch,
|
|
'before_sha' => $before_sha,
|
|
'after_sha' => $after_sha,
|
|
'commit_sha' => $commit_sha,
|
|
'is_fork_pull_request' => $is_fork_pull_request,
|
|
] = $this->readPullRequestPayload($payload);
|
|
$full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
|
|
if ($full_name === null) {
|
|
return response('Nothing to do. Invalid repository.');
|
|
}
|
|
}
|
|
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
|
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
|
}
|
|
if (! $id || ! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
|
|
return response('Nothing to do. No id or branch found.');
|
|
}
|
|
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
|
|
return response('Nothing to do. Branch deleted.');
|
|
}
|
|
$applications = Application::where('repository_project_id', $id)
|
|
->where('source_id', $github_app->id)
|
|
->where('source_type', GithubApp::class)
|
|
->whereRelation('source', 'is_public', false);
|
|
if ($x_github_event === 'push') {
|
|
$applications = $applications->where('git_branch', $branch)->get();
|
|
if ($applications->isEmpty()) {
|
|
return response("Nothing to do. No applications found with branch '$branch'.");
|
|
}
|
|
if ($applications->contains(fn (Application $application): bool => filled($application->watch_paths))) {
|
|
$changed_files = $this->githubPushComparedFiles($github_app, $payload) ?? $changed_files;
|
|
}
|
|
}
|
|
if ($x_github_event === 'pull_request') {
|
|
if ($action !== 'closed') {
|
|
$applications->where('git_branch', $base_branch);
|
|
}
|
|
$applications = $applications->get();
|
|
if ($applications->isEmpty()) {
|
|
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
|
}
|
|
}
|
|
$applicationsByServer = $applications->groupBy(function ($app) {
|
|
return $app->destination->server_id;
|
|
});
|
|
|
|
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
|
foreach ($serverApplications as $application) {
|
|
$isFunctional = $application->destination->server->isFunctional();
|
|
if (! $isFunctional) {
|
|
$return_payloads->push([
|
|
'status' => 'failed',
|
|
'message' => 'Server is not functional.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
if ($x_github_event === 'push') {
|
|
if ($application->isDeployable()) {
|
|
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
|
|
if ($skip_deploy_commits ?? false) {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'skipped',
|
|
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$deployment_uuid = new_public_id();
|
|
$result = queue_application_deployment(
|
|
application: $application,
|
|
deployment_uuid: $deployment_uuid,
|
|
commit: $commit ?? 'HEAD',
|
|
force_rebuild: false,
|
|
is_webhook: true,
|
|
);
|
|
if ($result['status'] === 'queue_full') {
|
|
return response($result['message'], 429)->header('Retry-After', 60);
|
|
}
|
|
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
|
|
auditLog('webhook.deployment.queued', [
|
|
'provider' => 'github',
|
|
'mode' => 'app',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $result['deployment_uuid'],
|
|
'commit' => $commit,
|
|
'github_app_id' => $github_app->id,
|
|
]);
|
|
}
|
|
$return_payloads->push([
|
|
'status' => $result['status'],
|
|
'message' => $result['message'],
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $result['deployment_uuid'] ?? null,
|
|
]);
|
|
} else {
|
|
$paths = str($application->watch_paths)->explode("\n");
|
|
$return_payloads->push([
|
|
'status' => 'failed',
|
|
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'details' => [
|
|
'changed_files' => $changed_files,
|
|
'watch_paths' => $paths,
|
|
],
|
|
]);
|
|
}
|
|
} else {
|
|
$return_payloads->push([
|
|
'status' => 'failed',
|
|
'message' => 'Deployments disabled.',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
]);
|
|
}
|
|
}
|
|
if ($x_github_event === 'pull_request') {
|
|
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
|
|
if (! $application->isPRDeployable() && $action !== 'closed') {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'failed',
|
|
'message' => 'Preview deployments disabled.',
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
ProcessGithubPullRequestWebhook::dispatch(
|
|
applicationId: $application->id,
|
|
githubAppId: $github_app->id,
|
|
action: $action,
|
|
pullRequestId: $pull_request_id,
|
|
pullRequestHtmlUrl: $pull_request_html_url,
|
|
pullRequestTitle: $pull_request_title,
|
|
beforeSha: $before_sha,
|
|
afterSha: $after_sha,
|
|
commitSha: $commit_sha,
|
|
fullName: $full_name,
|
|
isForkPullRequest: $is_fork_pull_request,
|
|
);
|
|
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'queued',
|
|
'message' => 'PR webhook received, processing queued.',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return response($return_payloads);
|
|
} catch (InvalidWebhookPayloadException $e) {
|
|
return response($e->getMessage());
|
|
} catch (Exception $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Files that the push changes between its before and after commits, from
|
|
* the GitHub compare API.
|
|
*
|
|
* The file lists of the pushed commits are not the net change: a merge
|
|
* commit names the files that it changes relative to its first parent,
|
|
* also when the push does not change them. Returns null for a new branch,
|
|
* a missing repository name or an API failure; the caller then uses the
|
|
* file lists of the pushed commits.
|
|
*
|
|
* @return Collection<int, string>|null
|
|
*
|
|
* @throws InvalidWebhookPayloadException When the before or after value is not a commit SHA.
|
|
*/
|
|
private function githubPushComparedFiles(GithubApp $githubApp, mixed $payload): ?Collection
|
|
{
|
|
$before = $this->webhookCommitSha($payload, 'before');
|
|
$after = $this->webhookCommitSha($payload, 'after');
|
|
$fullName = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
|
|
if ($before === null || $after === null || $fullName === null || preg_match('/\A0+\z/', $before) === 1) {
|
|
return null;
|
|
}
|
|
|
|
[$owner, $repo] = explode('/', $fullName, 2);
|
|
$files = getGithubCommitRangeFiles($githubApp, $owner, $repo, $before, $after);
|
|
|
|
return $files === null ? null : collect($files);
|
|
}
|
|
|
|
/**
|
|
* Read and validate the pull_request payload fields that the handlers use.
|
|
*
|
|
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, is_fork_pull_request: bool}
|
|
*
|
|
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
|
|
*/
|
|
private function readPullRequestPayload(mixed $payload): array
|
|
{
|
|
$head_sha = $this->webhookCommitSha($payload, 'pull_request.head.sha');
|
|
|
|
return [
|
|
'action' => $this->webhookPayloadString($payload, 'action', required: true),
|
|
'pull_request_id' => $this->webhookPullRequestId($payload, 'number'),
|
|
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url', required: true),
|
|
'pull_request_title' => $this->webhookPayloadString($payload, 'pull_request.title'),
|
|
'branch' => $this->webhookString(data_get($payload, 'pull_request.head.ref')),
|
|
'base_branch' => $this->webhookString(data_get($payload, 'pull_request.base.ref')),
|
|
'before_sha' => $this->webhookCommitSha($payload, 'before'),
|
|
'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha,
|
|
'commit_sha' => $head_sha ?? 'HEAD',
|
|
'is_fork_pull_request' => $this->isForkPullRequest($payload),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine whether a pull_request webhook payload originates from a fork.
|
|
*
|
|
* A same-repository PR branch can only be pushed by users with write access,
|
|
* so this is the trust signal for preview deployments. GitHub's
|
|
* `author_association` is not reliable for this: users with access through a
|
|
* team or a private organization membership arrive as CONTRIBUTOR or NONE.
|
|
*
|
|
* The repository id comparison is the canonical signal; the `head.repo.fork`
|
|
* flag and a case-insensitive full_name comparison are fallbacks for payloads
|
|
* where the ids are unavailable. A PR whose head repository is unknown (e.g.
|
|
* a deleted fork) is treated as a fork.
|
|
*
|
|
* @throws InvalidWebhookPayloadException When a repository id is not a positive integer.
|
|
*/
|
|
private function isForkPullRequest(mixed $payload): bool
|
|
{
|
|
$headRepoId = $this->webhookPayloadId($payload, 'pull_request.head.repo.id');
|
|
$baseRepoId = $this->webhookPayloadId($payload, 'pull_request.base.repo.id');
|
|
|
|
if ($headRepoId !== null && $baseRepoId !== null) {
|
|
return $headRepoId !== $baseRepoId;
|
|
}
|
|
|
|
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
|
|
return true;
|
|
}
|
|
|
|
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
|
|
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
|
|
|
|
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
|
|
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function redirect(Request $request)
|
|
{
|
|
$code = (string) $request->query('code', '');
|
|
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
|
|
|
|
$github_app = $this->consumeGithubAppSetupState(
|
|
request: $request,
|
|
state: (string) $request->query('state', ''),
|
|
action: 'manifest',
|
|
);
|
|
|
|
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
|
|
|
|
$api_url = data_get($github_app, 'api_url');
|
|
$data = Http::GitSource($api_url)->withBody(null)
|
|
->accept('application/vnd.github+json')
|
|
->timeout(10)
|
|
->post("$api_url/app-manifests/$code/conversions")
|
|
->throw()
|
|
->json();
|
|
|
|
$id = data_get($data, 'id');
|
|
$slug = data_get($data, 'slug');
|
|
$client_id = data_get($data, 'client_id');
|
|
$client_secret = data_get($data, 'client_secret');
|
|
$private_key = data_get($data, 'pem');
|
|
$webhook_secret = data_get($data, 'webhook_secret');
|
|
|
|
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
|
|
|
|
$private_key = PrivateKey::create([
|
|
'name' => "github-app-{$slug}",
|
|
'private_key' => $private_key,
|
|
'team_id' => $github_app->team_id,
|
|
'is_git_related' => true,
|
|
]);
|
|
$github_app->name = $slug;
|
|
$github_app->app_id = $id;
|
|
$github_app->client_id = $client_id;
|
|
$github_app->client_secret = $client_secret;
|
|
$github_app->webhook_secret = $webhook_secret;
|
|
$github_app->private_key_id = $private_key->id;
|
|
$github_app->save();
|
|
|
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
|
}
|
|
|
|
public function install(Request $request)
|
|
{
|
|
$setup_action = (string) $request->query('setup_action', '');
|
|
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
|
|
|
|
$installation_id = (string) $request->query('installation_id', '');
|
|
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
|
|
|
|
if ($setup_action === 'update') {
|
|
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
|
|
}
|
|
|
|
$github_app = $this->consumeGithubAppSetupState(
|
|
request: $request,
|
|
state: (string) $request->query('state', ''),
|
|
action: 'install',
|
|
);
|
|
|
|
$verification_error = $this->githubInstallationVerificationError($github_app, $installation_id);
|
|
if ($verification_error) {
|
|
Log::warning('GitHub App installation could not be verified.', [
|
|
'github_app_id' => $github_app->id,
|
|
'installation_id' => $installation_id,
|
|
'reason' => $verification_error,
|
|
]);
|
|
|
|
$message = "GitHub App installation could not be verified: {$verification_error}";
|
|
abort_if($request->expectsJson(), 403, $message);
|
|
|
|
return redirect()
|
|
->route('source.github.show', ['github_app_uuid' => $github_app->uuid])
|
|
->with('error', $message);
|
|
}
|
|
|
|
$github_app->installation_id = $installation_id;
|
|
$github_app->save();
|
|
|
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
|
}
|
|
|
|
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
|
|
{
|
|
$github_app = GithubApp::ownedByCurrentTeam()
|
|
->where('installation_id', $installation_id)
|
|
->first();
|
|
|
|
if ($github_app) {
|
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
|
}
|
|
|
|
return redirect()->route('source.all');
|
|
}
|
|
|
|
/**
|
|
* Verify that the given installation id actually belongs to this GitHub App.
|
|
*
|
|
* The installation id arrives as an untrusted query parameter on an
|
|
* unauthenticated-reachable GET callback, so it must be confirmed against
|
|
* the GitHub API using the App's own credentials before it is persisted.
|
|
*
|
|
* @return string|null The reason the verification failed, or null when it passed.
|
|
*/
|
|
private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string
|
|
{
|
|
if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) {
|
|
return 'The GitHub App ID or private key is missing.';
|
|
}
|
|
|
|
try {
|
|
$jwt = generateGithubJwt($github_app);
|
|
$response = Http::GitSource($github_app->api_url)->withHeaders([
|
|
'Authorization' => "Bearer $jwt",
|
|
'Accept' => 'application/vnd.github+json',
|
|
])
|
|
->timeout(10)
|
|
->get("{$github_app->api_url}/app/installations/{$installation_id}");
|
|
} catch (\Throwable $e) {
|
|
return strip_tags(str_replace('<br>', ' ', $e->getMessage()));
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found');
|
|
}
|
|
|
|
if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) {
|
|
return 'The installation belongs to a different GitHub App.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
|
|
{
|
|
if (blank($state)) {
|
|
$this->rejectInvalidGithubAppSetupState($request);
|
|
}
|
|
|
|
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
|
|
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
|
|
$this->rejectInvalidGithubAppSetupState($request);
|
|
}
|
|
|
|
// The state is bound to the team that started the setup. The user may have switched
|
|
// teams while on GitHub, so check membership of that team, not the session team.
|
|
$team_id = (int) data_get($payload, 'team_id');
|
|
abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403);
|
|
|
|
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
|
->where('team_id', $team_id)
|
|
->firstOrFail();
|
|
|
|
if ($request->user()->currentTeam()?->id !== $team_id) {
|
|
refreshSession($github_app->team);
|
|
}
|
|
|
|
return $github_app;
|
|
}
|
|
|
|
private function rejectInvalidGithubAppSetupState(Request $request): never
|
|
{
|
|
if ($request->expectsJson()) {
|
|
abort(404);
|
|
}
|
|
|
|
throw new HttpResponseException(
|
|
redirect()
|
|
->route('source.all')
|
|
);
|
|
}
|
|
|
|
private function githubAppSetupStateCacheKey(string $state): string
|
|
{
|
|
return 'github-app-setup-state:'.hash('sha256', $state);
|
|
}
|
|
|
|
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
|
|
{
|
|
return filled($github_app->app_id)
|
|
|| filled($github_app->client_id)
|
|
|| filled($github_app->client_secret)
|
|
|| filled($github_app->webhook_secret)
|
|
|| filled($github_app->private_key_id);
|
|
}
|
|
}
|