feat(webhook): skip deployment on [skip ci]/[skip cd] commit markers

Add DetectsSkipDeployCommits trait with two strategies: shouldSkipDeploy
(all commits must contain the marker) for push events, and
shouldSkipDeployAny (any single marker triggers skip) for PR/MR titles
and latest-commit signals.

Apply trait to Bitbucket, Gitea, GitHub, GitLab webhook controllers and
ProcessGithubPullRequestWebhook job. PRs pass pullRequestTitle through
to the job constructor for evaluation.
This commit is contained in:
Andras Bacsai
2026-04-29 09:12:24 +02:00
parent 9af0351144
commit 46180dbbf9
8 changed files with 296 additions and 1 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +13,8 @@ use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
use DetectsSkipDeployCommits;
public function manual(Request $request)
{
try {
@@ -31,6 +34,16 @@ class Bitbucket extends Controller
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
// are evaluated only on the visible 5.
$skip_deploy_commits = self::shouldSkipDeploy(
collect(data_get($payload, 'push.changes', []))
->flatMap(fn ($change) => data_get($change, 'commits', []))
->pluck('message')
->filter()
->values()
->all()
);
if (! $branch) {
return response([
@@ -45,6 +58,8 @@ class Bitbucket extends Controller
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
$pull_request_title = data_get($payload, 'pullrequest.title');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
@@ -119,6 +134,17 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -161,6 +187,15 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
if ($skip_deploy_pr ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
]);
continue;
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {