From 662ea75f4232ef0ff73e027b5bfb5013f105f31e Mon Sep 17 00:00:00 2001 From: andikadevs Date: Sat, 13 Jun 2026 10:14:08 +0700 Subject: [PATCH] fix: honor triggerType on refresh-token webhook deploys (#3710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub App webhook handler (github.ts) already respects the application/compose `triggerType` ("push" vs "tag"), but the refresh-token webhook handlers did not look at it at all: - apps/dokploy/pages/api/deploy/[refreshToken].ts - apps/dokploy/pages/api/deploy/compose/[refreshToken].ts For a GitHub source these handlers only checked watchPaths + branch match. As a result a project configured with "On tag" still deployed on every branch push (the field was ignored), and real tag pushes — ref `refs/tags/x`, which extractBranchName leaves unstripped — failed the branch match and never deployed. Add an exported `extractTagName` helper and gate the `github` branch of both handlers on `triggerType`, mirroring github.ts semantics: - triggerType "tag": deploy only on tag events (skip branch/watchPaths, since tags are not branch-scoped and the UI hides watchPaths for tags), titled "Tag created: "; ignore non-tag pushes. - triggerType "push" (default): ignore tag events; keep the existing branch + watchPaths checks unchanged. Scope is GitHub only, matching the issue and the original feature (PR #1613). No schema, migration, or UI changes — the triggerType column and GitHub UI selector already exist. Adds unit tests for extractTagName across GitHub/Gitea/GitLab/Bitbucket. --- apps/dokploy/__test__/deploy/github.test.ts | 68 +++++++++++++++ .../pages/api/deploy/[refreshToken].ts | 83 +++++++++++++++---- .../api/deploy/compose/[refreshToken].ts | 55 ++++++++---- 3 files changed, 174 insertions(+), 32 deletions(-) diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts index 104e108f1c..0915b54adc 100644 --- a/apps/dokploy/__test__/deploy/github.test.ts +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -4,6 +4,7 @@ import { extractImageName, extractImageTag, extractImageTagFromRequest, + extractTagName, } from "@/pages/api/deploy/[refreshToken]"; describe("GitHub Webhook Skip CI", () => { @@ -113,6 +114,73 @@ describe("GitHub Webhook Skip CI", () => { }); }); +describe("extractTagName", () => { + it("should extract the tag name from a GitHub tag push", () => { + expect( + extractTagName({ "x-github-event": "push" }, { ref: "refs/tags/v1.0.0" }), + ).toBe("v1.0.0"); + }); + + it("should return null for a GitHub branch push", () => { + expect( + extractTagName({ "x-github-event": "push" }, { ref: "refs/heads/main" }), + ).toBeNull(); + }); + + it("should extract the tag name from a Gitea tag push", () => { + expect( + extractTagName({ "x-gitea-event": "push" }, { ref: "refs/tags/v2.3.4" }), + ).toBe("v2.3.4"); + }); + + it("should extract the tag name from a GitLab tag push", () => { + expect( + extractTagName( + { "x-gitlab-event": "Tag Push Hook" }, + { ref: "refs/tags/release-1" }, + ), + ).toBe("release-1"); + }); + + it("should return null for a GitLab branch push", () => { + expect( + extractTagName( + { "x-gitlab-event": "Push Hook" }, + { ref: "refs/heads/develop" }, + ), + ).toBeNull(); + }); + + it("should extract the tag name from a Bitbucket tag push", () => { + expect( + extractTagName( + { "x-event-key": "repo:push" }, + { push: { changes: [{ new: { type: "tag", name: "v9.9.9" } }] } }, + ), + ).toBe("v9.9.9"); + }); + + it("should return null for a Bitbucket branch push", () => { + expect( + extractTagName( + { "x-event-key": "repo:push" }, + { push: { changes: [{ new: { type: "branch", name: "main" } }] } }, + ), + ).toBeNull(); + }); + + it("should return null when ref is missing or empty", () => { + expect(extractTagName({ "x-github-event": "push" }, {})).toBeNull(); + expect( + extractTagName({ "x-github-event": "push" }, { ref: "" }), + ).toBeNull(); + }); + + it("should return null for unknown providers", () => { + expect(extractTagName({}, { ref: "refs/tags/v1.0.0" })).toBeNull(); + }); +}); + describe("GitHub Packages Docker Image Tag Extraction", () => { it("should extract tag from container_metadata", () => { const headers = { "x-github-event": "registry_package" }; diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index bb6eb06d37..44f112e383 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -65,7 +65,7 @@ export default async function handler( return; } - const deploymentTitle = extractCommitMessage(req.headers, req.body); + let deploymentTitle = extractCommitMessage(req.headers, req.body); const deploymentHash = extractHash(req.headers, req.body); const sourceType = application.sourceType; @@ -119,24 +119,46 @@ export default async function handler( } // If webhook doesn't provide image info, we'll use the configured image (old behavior) } else if (sourceType === "github") { - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const tagName = extractTagName(req.headers, req.body); + const isTagEvent = !!tagName; - const shouldDeployPaths = shouldDeploy( - application.watchPaths, - normalizedCommits, - ); + if (application.triggerType === "tag") { + if (!isTagEvent) { + res.status(301).json({ + message: "Trigger type is 'tag'; ignoring non-tag push", + }); + return; + } + // Tag event: deploy without branch/watchPaths checks (tags are not + // branch-scoped and the UI hides watchPaths for the tag trigger). + deploymentTitle = `Tag created: ${tagName}`; + } else { + if (isTagEvent) { + res.status(301).json({ + message: "Trigger type is 'push'; ignoring tag event", + }); + return; + } - if (!shouldDeployPaths) { - res.status(301).json({ message: "Watch Paths Not Match" }); - return; - } + const normalizedCommits = req.body?.commits?.flatMap( + (commit: any) => commit.modified, + ); - const branchName = extractBranchName(req.headers, req.body); - if (!branchName || branchName !== application.branch) { - res.status(301).json({ message: "Branch Not Match" }); - return; + const shouldDeployPaths = shouldDeploy( + application.watchPaths, + normalizedCommits, + ); + + if (!shouldDeployPaths) { + res.status(301).json({ message: "Watch Paths Not Match" }); + return; + } + + const branchName = extractBranchName(req.headers, req.body); + if (!branchName || branchName !== application.branch) { + res.status(301).json({ message: "Branch Not Match" }); + return; + } } } else if (sourceType === "git") { const branchName = extractBranchName(req.headers, req.body); @@ -535,6 +557,35 @@ export const extractBranchName = (headers: any, body: any) => { return null; }; +/** + * Return the tag name for a tag-creation webhook event, or null when the event + * is not a tag push. Used to honor the application/compose `triggerType` ("tag" + * vs "push") on the refresh-token webhook path, mirroring the GitHub App handler. + */ +export const extractTagName = (headers: any, body: any) => { + // GitHub / Gitea: ref = refs/tags/ + if (headers["x-github-event"] || headers["x-gitea-event"]) { + return body?.ref?.startsWith("refs/tags/") + ? body.ref.replace("refs/tags/", "") + : null; + } + + // GitLab: Tag Push Hook, ref = refs/tags/ + if (headers["x-gitlab-event"]) { + return body?.ref?.startsWith("refs/tags/") + ? body.ref.replace("refs/tags/", "") + : null; + } + + // Bitbucket: push change with new.type === "tag" + if (headers["x-event-key"]?.includes("repo:push")) { + const change = body?.push?.changes?.[0]?.new; + return change?.type === "tag" ? change?.name : null; + } + + return null; +}; + export const getProviderByHeader = (headers: any) => { if (headers["x-github-event"]) { return "github"; diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 85a379eb3e..d5432c7073 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -11,6 +11,7 @@ import { extractCommitMessage, extractCommittedPaths, extractHash, + extractTagName, getProviderByHeader, logWebhookError, } from "../[refreshToken]"; @@ -48,29 +49,51 @@ export default async function handler( return; } - const deploymentTitle = extractCommitMessage(req.headers, req.body); + let deploymentTitle = extractCommitMessage(req.headers, req.body); const deploymentHash = extractHash(req.headers, req.body); const sourceType = composeResult.sourceType; if (sourceType === "github") { - const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const tagName = extractTagName(req.headers, req.body); + const isTagEvent = !!tagName; + + if (composeResult.triggerType === "tag") { + if (!isTagEvent) { + res.status(301).json({ + message: "Trigger type is 'tag'; ignoring non-tag push", + }); + return; + } + // Tag event: deploy without branch/watchPaths checks (tags are not + // branch-scoped and the UI hides watchPaths for the tag trigger). + deploymentTitle = `Tag created: ${tagName}`; + } else { + if (isTagEvent) { + res.status(301).json({ + message: "Trigger type is 'push'; ignoring tag event", + }); + return; + } + + const branchName = extractBranchName(req.headers, req.body); + const normalizedCommits = req.body?.commits?.flatMap( + (commit: any) => commit.modified, + ); - const shouldDeployPaths = shouldDeploy( - composeResult.watchPaths, - normalizedCommits, - ); + const shouldDeployPaths = shouldDeploy( + composeResult.watchPaths, + normalizedCommits, + ); - if (!shouldDeployPaths) { - res.status(301).json({ message: "Watch Paths Not Match" }); - return; - } + if (!shouldDeployPaths) { + res.status(301).json({ message: "Watch Paths Not Match" }); + return; + } - if (!branchName || branchName !== composeResult.branch) { - res.status(301).json({ message: "Branch Not Match" }); - return; + if (!branchName || branchName !== composeResult.branch) { + res.status(301).json({ message: "Branch Not Match" }); + return; + } } } else if (sourceType === "gitlab") { const branchName = extractBranchName(req.headers, req.body);