diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index be29748eb..233a83d81 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -252,10 +252,13 @@ describe("deployApplication - Command Generation Tests", () => { const execCalls = vi.mocked(execProcess.execAsync).mock.calls; expect(execCalls.length).toBeGreaterThan(0); - const fullCommand = execCalls[0]?.[0]; - expect(fullCommand).toContain("set -e"); - expect(fullCommand).toContain("git clone"); - expect(fullCommand).toContain("nixpacks build"); + const cloneCommand = execCalls[0]?.[0]; + expect(cloneCommand).toContain("set -e"); + expect(cloneCommand).toContain("git clone"); + + const buildCommand = execCalls[1]?.[0]; + expect(buildCommand).toContain("set -e"); + expect(buildCommand).toContain("nixpacks build"); }); it("should include log redirection in command", async () => { diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 010ae25e7..1e58cc67b 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -44,6 +44,7 @@ export const AddGithubProvider = () => { metadata: "read", emails: "read", pull_requests: "write", + checks: "write", }, default_events: ["pull_request", "push"], }, diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 61a77ae5a..6226107f0 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -31,6 +31,7 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; +import { setStatusCheck } from "./checks"; import { createDeployment, createDeploymentPreview, @@ -182,29 +183,53 @@ export const deployApplication = async ({ description: descriptionLog, }); + let commitInfo: { + message: string; + hash: string; + } | null = null; + try { - let command = "set -e;"; + let cloneCommand = "set -e;"; if (application.sourceType === "github") { - command += await cloneGithubRepository(application); + cloneCommand += await cloneGithubRepository(application); } else if (application.sourceType === "gitlab") { - command += await cloneGitlabRepository(application); + cloneCommand += await cloneGitlabRepository(application); } else if (application.sourceType === "gitea") { - command += await cloneGiteaRepository(application); + cloneCommand += await cloneGiteaRepository(application); } else if (application.sourceType === "bitbucket") { - command += await cloneBitbucketRepository(application); + cloneCommand += await cloneBitbucketRepository(application); } else if (application.sourceType === "git") { - command += await cloneGitRepository(application); + cloneCommand += await cloneGitRepository(application); } else if (application.sourceType === "docker") { - command += await buildRemoteDocker(application); + cloneCommand += await buildRemoteDocker(application); } - command += await getBuildCommand(application); - - const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; + const cloneCommandWithLog = `(${cloneCommand}) >> ${deployment.logPath} 2>&1`; if (serverId) { - await execAsyncRemote(serverId, commandWithLog); + await execAsyncRemote(serverId, cloneCommandWithLog); } else { - await execAsync(commandWithLog); + await execAsync(cloneCommandWithLog); + } + + // Only extract commit info for non-docker sources + if (application.sourceType !== "docker") { + commitInfo = await getGitCommitInfo(application); + } + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "in_progress"); + } + + let buildCommand = "set -e;"; + buildCommand += await getBuildCommand(application); + + if (buildCommand) { + const buildCommandWithLog = `(${buildCommand}) >> ${deployment.logPath} 2>&1`; + if (serverId) { + await execAsyncRemote(serverId, buildCommandWithLog); + } else { + await execAsync(buildCommandWithLog); + } } await mechanizeDockerContainer(application); @@ -220,6 +245,10 @@ export const deployApplication = async ({ domains: application.domains, environmentName: application.environment.name, }); + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "success"); + } } catch (error) { let command = ""; @@ -236,6 +265,10 @@ export const deployApplication = async ({ } else { await execAsync(command); } + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "failure"); + } await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); @@ -251,16 +284,11 @@ export const deployApplication = async ({ throw error; } finally { - // Only extract commit info for non-docker sources - if (application.sourceType !== "docker") { - const commitInfo = await getGitCommitInfo(application); - - if (commitInfo) { - await updateDeployment(deployment.deploymentId, { - title: commitInfo.message, - description: `Commit: ${commitInfo.hash}`, - }); - } + if (commitInfo) { + await updateDeployment(deployment.deploymentId, { + title: commitInfo.message, + description: `Commit: ${commitInfo.hash}`, + }); } } return true; @@ -285,7 +313,21 @@ export const rebuildApplication = async ({ description: descriptionLog, }); + let commitInfo: { + message: string; + hash: string; + } | null = null; + try { + // Only extract commit info for non-docker sources + if (application.sourceType !== "docker") { + commitInfo = await getGitCommitInfo(application); + } + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "in_progress"); + } + let command = "set -e;"; // Check case for docker only command += await getBuildCommand(application); @@ -308,6 +350,10 @@ export const rebuildApplication = async ({ domains: application.domains, environmentName: application.environment.name, }); + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "success"); + } } catch (error) { let command = ""; @@ -324,6 +370,10 @@ export const rebuildApplication = async ({ } else { await execAsync(command); } + + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "failure"); + } await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); throw error; @@ -366,6 +416,12 @@ export const deployPreviewApplication = async ({ comment_id: Number.parseInt(previewDeployment.pullRequestCommentId), githubId: application?.githubId || "", }; + + let commitInfo: { + message: string; + hash: string; + } | null = null; + try { const commentExists = await issueCommentExists({ ...issueParams, @@ -406,20 +462,34 @@ export const deployPreviewApplication = async ({ application.rollbackRegistry = null; application.registry = null; - let command = "set -e;"; if (application.sourceType === "github") { - command += await cloneGithubRepository({ + let cloneCommand = "set -e;"; + cloneCommand += await cloneGithubRepository({ ...application, appName: previewDeployment.appName, branch: previewDeployment.branch, }); - command += await getBuildCommand(application); + const cloneCommandWithLog = `(${cloneCommand}) >> ${deployment.logPath} 2>&1`; + if (application.serverId) { + await execAsyncRemote(application.serverId, cloneCommandWithLog); + } else { + await execAsync(cloneCommandWithLog); + } + + commitInfo = await getGitCommitInfo(application); - const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "in_progress"); + } + + let buildCommand = "set -e;"; + buildCommand += await getBuildCommand(application); + + const buildCommandWithLog = `(${buildCommand}) >> ${deployment.logPath} 2>&1`; if (application.serverId) { - await execAsyncRemote(application.serverId, commandWithLog); + await execAsyncRemote(application.serverId, buildCommandWithLog); } else { - await execAsync(commandWithLog); + await execAsync(buildCommandWithLog); } await mechanizeDockerContainer(application); } @@ -436,12 +506,18 @@ export const deployPreviewApplication = async ({ await updatePreviewDeployment(previewDeploymentId, { previewStatus: "done", }); + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "success"); + } } catch (error) { const comment = getIssueComment(application.name, "error", previewDomain); await updateIssueComment({ ...issueParams, body: `### Dokploy Preview Deployment\n\n${comment}`, }); + if (commitInfo) { + await setStatusCheck(application, commitInfo.hash, "failure"); + } await updateDeploymentStatus(deployment.deploymentId, "error"); await updatePreviewDeployment(previewDeploymentId, { previewStatus: "error", diff --git a/packages/server/src/services/checks.ts b/packages/server/src/services/checks.ts new file mode 100644 index 000000000..f0096e5a0 --- /dev/null +++ b/packages/server/src/services/checks.ts @@ -0,0 +1,34 @@ +import type { ApplicationNested } from "../utils/builders"; +import { getDokployUrl } from "./admin"; +import * as github from "./github"; + +export type CheckStatus = "queued" | "in_progress" | "success" | "failure"; + +export async function setStatusCheck( + application: ApplicationNested, + head_sha: string, + status: CheckStatus, +) { + // @TODO: check for preview deployment and update link + const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`; + + try { + switch (application.sourceType) { + case "github": + return await github.setStatusCheck({ + owner: application.owner!, + repository: application.repository!, + githubId: application.githubId!, + head_sha, + name: `Dokploy (${application.environment.project.name}/${application.name})`, + status, + details_url: buildLink, + }); + } + } catch (error) { + console.error( + `❌ Failed to write status check to "${application.sourceType}"`, + error, + ); + } +} diff --git a/packages/server/src/services/github.ts b/packages/server/src/services/github.ts index c23564e58..442353ec8 100644 --- a/packages/server/src/services/github.ts +++ b/packages/server/src/services/github.ts @@ -153,6 +153,83 @@ export const updateIssueComment = async ({ }); }; +interface StatusCheckCreate { + owner: string; + repository: string; + issue_number?: string; + head_sha: string; + githubId: string; + name: string; + status: "queued" | "in_progress" | "success" | "failure"; + details_url: string; +} + +export const setStatusCheck = async ({ + owner, + repository, + issue_number, + head_sha, + githubId, + name, + status, + details_url, +}: StatusCheckCreate) => { + const github = await findGithubById(githubId); + const octokit = authGithub(github); + + const existing_check = await octokit.rest.checks.listForRef({ + owner, + repo: repository, + ref: head_sha, + check_name: name, + }); + + const data = { + owner, + repo: repository, + issue: issue_number ? Number.parseInt(issue_number) : undefined, + head_sha, + name, + status: + status === "queued" + ? "queued" + : status === "in_progress" + ? "in_progress" + : "completed", + conclusion: + status === "success" + ? "success" + : status === "failure" + ? "failure" + : undefined, + details_url, + } as const; + + const existing = existing_check.data.check_runs[0]; + + if (existing) { + if (existing.status === "completed" && data.status !== "completed") { + await octokit.rest.checks.update({ + check_run_id: existing.id, + ...data, + conclusion: "neutral", + output: { + title: "Obsolete", + summary: "Superseded", + }, + }); + } else { + await octokit.rest.checks.update({ + check_run_id: existing.id, + ...data, + }); + return; + } + } + + await octokit.rest.checks.create(data); +}; + interface CommentCreate { appName: string; owner: string;