Skip to content

Commit 1d61c8f

Browse files
committed
feat(deployment): add github status checks for deployments
1 parent 068deec commit 1d61c8f

File tree

5 files changed

+223
-32
lines changed

5 files changed

+223
-32
lines changed

apps/dokploy/__test__/deploy/application.command.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,13 @@ describe("deployApplication - Command Generation Tests", () => {
252252
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
253253
expect(execCalls.length).toBeGreaterThan(0);
254254

255-
const fullCommand = execCalls[0]?.[0];
256-
expect(fullCommand).toContain("set -e");
257-
expect(fullCommand).toContain("git clone");
258-
expect(fullCommand).toContain("nixpacks build");
255+
const cloneCommand = execCalls[0]?.[0];
256+
expect(cloneCommand).toContain("set -e");
257+
expect(cloneCommand).toContain("git clone");
258+
259+
const buildCommand = execCalls[1]?.[0];
260+
expect(buildCommand).toContain("set -e");
261+
expect(buildCommand).toContain("nixpacks build");
259262
});
260263

261264
it("should include log redirection in command", async () => {

apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const AddGithubProvider = () => {
4444
metadata: "read",
4545
emails: "read",
4646
pull_requests: "write",
47+
checks: "write",
4748
},
4849
default_events: ["pull_request", "push"],
4950
},

packages/server/src/services/application.ts

Lines changed: 104 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { TRPCError } from "@trpc/server";
3131
import { eq } from "drizzle-orm";
3232
import { encodeBase64 } from "../utils/docker/utils";
3333
import { getDokployUrl } from "./admin";
34+
import { setStatusCheck } from "./checks";
3435
import {
3536
createDeployment,
3637
createDeploymentPreview,
@@ -182,29 +183,53 @@ export const deployApplication = async ({
182183
description: descriptionLog,
183184
});
184185

186+
let commitInfo: {
187+
message: string;
188+
hash: string;
189+
} | null = null;
190+
185191
try {
186-
let command = "set -e;";
192+
let cloneCommand = "set -e;";
187193
if (application.sourceType === "github") {
188-
command += await cloneGithubRepository(application);
194+
cloneCommand += await cloneGithubRepository(application);
189195
} else if (application.sourceType === "gitlab") {
190-
command += await cloneGitlabRepository(application);
196+
cloneCommand += await cloneGitlabRepository(application);
191197
} else if (application.sourceType === "gitea") {
192-
command += await cloneGiteaRepository(application);
198+
cloneCommand += await cloneGiteaRepository(application);
193199
} else if (application.sourceType === "bitbucket") {
194-
command += await cloneBitbucketRepository(application);
200+
cloneCommand += await cloneBitbucketRepository(application);
195201
} else if (application.sourceType === "git") {
196-
command += await cloneGitRepository(application);
202+
cloneCommand += await cloneGitRepository(application);
197203
} else if (application.sourceType === "docker") {
198-
command += await buildRemoteDocker(application);
204+
cloneCommand += await buildRemoteDocker(application);
199205
}
200206

201-
command += await getBuildCommand(application);
202-
203-
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
207+
const cloneCommandWithLog = `(${cloneCommand}) >> ${deployment.logPath} 2>&1`;
204208
if (serverId) {
205-
await execAsyncRemote(serverId, commandWithLog);
209+
await execAsyncRemote(serverId, cloneCommandWithLog);
206210
} else {
207-
await execAsync(commandWithLog);
211+
await execAsync(cloneCommandWithLog);
212+
}
213+
214+
// Only extract commit info for non-docker sources
215+
if (application.sourceType !== "docker") {
216+
commitInfo = await getGitCommitInfo(application);
217+
}
218+
219+
if (commitInfo) {
220+
await setStatusCheck(application, commitInfo.hash, "in_progress");
221+
}
222+
223+
let buildCommand = "set -e;";
224+
buildCommand += await getBuildCommand(application);
225+
226+
if (buildCommand) {
227+
const buildCommandWithLog = `(${buildCommand}) >> ${deployment.logPath} 2>&1`;
228+
if (serverId) {
229+
await execAsyncRemote(serverId, buildCommandWithLog);
230+
} else {
231+
await execAsync(buildCommandWithLog);
232+
}
208233
}
209234

210235
await mechanizeDockerContainer(application);
@@ -220,6 +245,10 @@ export const deployApplication = async ({
220245
domains: application.domains,
221246
environmentName: application.environment.name,
222247
});
248+
249+
if (commitInfo) {
250+
await setStatusCheck(application, commitInfo.hash, "success");
251+
}
223252
} catch (error) {
224253
let command = "";
225254

@@ -236,6 +265,10 @@ export const deployApplication = async ({
236265
} else {
237266
await execAsync(command);
238267
}
268+
269+
if (commitInfo) {
270+
await setStatusCheck(application, commitInfo.hash, "failure");
271+
}
239272
await updateDeploymentStatus(deployment.deploymentId, "error");
240273
await updateApplicationStatus(applicationId, "error");
241274

@@ -251,16 +284,11 @@ export const deployApplication = async ({
251284

252285
throw error;
253286
} finally {
254-
// Only extract commit info for non-docker sources
255-
if (application.sourceType !== "docker") {
256-
const commitInfo = await getGitCommitInfo(application);
257-
258-
if (commitInfo) {
259-
await updateDeployment(deployment.deploymentId, {
260-
title: commitInfo.message,
261-
description: `Commit: ${commitInfo.hash}`,
262-
});
263-
}
287+
if (commitInfo) {
288+
await updateDeployment(deployment.deploymentId, {
289+
title: commitInfo.message,
290+
description: `Commit: ${commitInfo.hash}`,
291+
});
264292
}
265293
}
266294
return true;
@@ -285,7 +313,21 @@ export const rebuildApplication = async ({
285313
description: descriptionLog,
286314
});
287315

316+
let commitInfo: {
317+
message: string;
318+
hash: string;
319+
} | null = null;
320+
288321
try {
322+
// Only extract commit info for non-docker sources
323+
if (application.sourceType !== "docker") {
324+
commitInfo = await getGitCommitInfo(application);
325+
}
326+
327+
if (commitInfo) {
328+
await setStatusCheck(application, commitInfo.hash, "in_progress");
329+
}
330+
289331
let command = "set -e;";
290332
// Check case for docker only
291333
command += await getBuildCommand(application);
@@ -308,6 +350,10 @@ export const rebuildApplication = async ({
308350
domains: application.domains,
309351
environmentName: application.environment.name,
310352
});
353+
354+
if (commitInfo) {
355+
await setStatusCheck(application, commitInfo.hash, "success");
356+
}
311357
} catch (error) {
312358
let command = "";
313359

@@ -324,6 +370,10 @@ export const rebuildApplication = async ({
324370
} else {
325371
await execAsync(command);
326372
}
373+
374+
if (commitInfo) {
375+
await setStatusCheck(application, commitInfo.hash, "failure");
376+
}
327377
await updateDeploymentStatus(deployment.deploymentId, "error");
328378
await updateApplicationStatus(applicationId, "error");
329379
throw error;
@@ -366,6 +416,12 @@ export const deployPreviewApplication = async ({
366416
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
367417
githubId: application?.githubId || "",
368418
};
419+
420+
let commitInfo: {
421+
message: string;
422+
hash: string;
423+
} | null = null;
424+
369425
try {
370426
const commentExists = await issueCommentExists({
371427
...issueParams,
@@ -406,20 +462,34 @@ export const deployPreviewApplication = async ({
406462
application.rollbackRegistry = null;
407463
application.registry = null;
408464

409-
let command = "set -e;";
410465
if (application.sourceType === "github") {
411-
command += await cloneGithubRepository({
466+
let cloneCommand = "set -e;";
467+
cloneCommand += await cloneGithubRepository({
412468
...application,
413469
appName: previewDeployment.appName,
414470
branch: previewDeployment.branch,
415471
});
416-
command += await getBuildCommand(application);
472+
const cloneCommandWithLog = `(${cloneCommand}) >> ${deployment.logPath} 2>&1`;
473+
if (application.serverId) {
474+
await execAsyncRemote(application.serverId, cloneCommandWithLog);
475+
} else {
476+
await execAsync(cloneCommandWithLog);
477+
}
478+
479+
commitInfo = await getGitCommitInfo(application);
417480

418-
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
481+
if (commitInfo) {
482+
await setStatusCheck(application, commitInfo.hash, "in_progress");
483+
}
484+
485+
let buildCommand = "set -e;";
486+
buildCommand += await getBuildCommand(application);
487+
488+
const buildCommandWithLog = `(${buildCommand}) >> ${deployment.logPath} 2>&1`;
419489
if (application.serverId) {
420-
await execAsyncRemote(application.serverId, commandWithLog);
490+
await execAsyncRemote(application.serverId, buildCommandWithLog);
421491
} else {
422-
await execAsync(commandWithLog);
492+
await execAsync(buildCommandWithLog);
423493
}
424494
await mechanizeDockerContainer(application);
425495
}
@@ -436,12 +506,18 @@ export const deployPreviewApplication = async ({
436506
await updatePreviewDeployment(previewDeploymentId, {
437507
previewStatus: "done",
438508
});
509+
if (commitInfo) {
510+
await setStatusCheck(application, commitInfo.hash, "success");
511+
}
439512
} catch (error) {
440513
const comment = getIssueComment(application.name, "error", previewDomain);
441514
await updateIssueComment({
442515
...issueParams,
443516
body: `### Dokploy Preview Deployment\n\n${comment}`,
444517
});
518+
if (commitInfo) {
519+
await setStatusCheck(application, commitInfo.hash, "failure");
520+
}
445521
await updateDeploymentStatus(deployment.deploymentId, "error");
446522
await updatePreviewDeployment(previewDeploymentId, {
447523
previewStatus: "error",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ApplicationNested } from "../utils/builders";
2+
import { getDokployUrl } from "./admin";
3+
import * as github from "./github";
4+
5+
export type CheckStatus = "queued" | "in_progress" | "success" | "failure";
6+
7+
export async function setStatusCheck(
8+
application: ApplicationNested,
9+
head_sha: string,
10+
status: CheckStatus,
11+
) {
12+
// @TODO: check for preview deployment and update link
13+
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
14+
15+
try {
16+
switch (application.sourceType) {
17+
case "github":
18+
return await github.setStatusCheck({
19+
owner: application.owner!,
20+
repository: application.repository!,
21+
githubId: application.githubId!,
22+
head_sha,
23+
name: `Dokploy (${application.environment.project.name}/${application.name})`,
24+
status,
25+
details_url: buildLink,
26+
});
27+
}
28+
} catch (error) {
29+
console.error(
30+
`❌ Failed to write status check to "${application.sourceType}"`,
31+
error,
32+
);
33+
}
34+
}

packages/server/src/services/github.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,83 @@ export const updateIssueComment = async ({
153153
});
154154
};
155155

156+
interface StatusCheckCreate {
157+
owner: string;
158+
repository: string;
159+
issue_number?: string;
160+
head_sha: string;
161+
githubId: string;
162+
name: string;
163+
status: "queued" | "in_progress" | "success" | "failure";
164+
details_url: string;
165+
}
166+
167+
export const setStatusCheck = async ({
168+
owner,
169+
repository,
170+
issue_number,
171+
head_sha,
172+
githubId,
173+
name,
174+
status,
175+
details_url,
176+
}: StatusCheckCreate) => {
177+
const github = await findGithubById(githubId);
178+
const octokit = authGithub(github);
179+
180+
const existing_check = await octokit.rest.checks.listForRef({
181+
owner,
182+
repo: repository,
183+
ref: head_sha,
184+
check_name: name,
185+
});
186+
187+
const data = {
188+
owner,
189+
repo: repository,
190+
issue: issue_number ? Number.parseInt(issue_number) : undefined,
191+
head_sha,
192+
name,
193+
status:
194+
status === "queued"
195+
? "queued"
196+
: status === "in_progress"
197+
? "in_progress"
198+
: "completed",
199+
conclusion:
200+
status === "success"
201+
? "success"
202+
: status === "failure"
203+
? "failure"
204+
: undefined,
205+
details_url,
206+
} as const;
207+
208+
const existing = existing_check.data.check_runs[0];
209+
210+
if (existing) {
211+
if (existing.status === "completed" && data.status !== "completed") {
212+
await octokit.rest.checks.update({
213+
check_run_id: existing.id,
214+
...data,
215+
conclusion: "neutral",
216+
output: {
217+
title: "Obsolete",
218+
summary: "Superseded",
219+
},
220+
});
221+
} else {
222+
await octokit.rest.checks.update({
223+
check_run_id: existing.id,
224+
...data,
225+
});
226+
return;
227+
}
228+
}
229+
230+
await octokit.rest.checks.create(data);
231+
};
232+
156233
interface CommentCreate {
157234
appName: string;
158235
owner: string;

0 commit comments

Comments
 (0)