feat: integration the github action for deployment

This commit is contained in:
2026-07-21 15:10:57 +07:00
parent f5666a29da
commit 188d5f87b4
5 changed files with 767 additions and 5 deletions
@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from "vitest";
import {
DokployApiError,
buildDeploymentsUrl,
createDeploymentSnapshot,
escapeGitHubCommandValue,
fetchComposeDeployments,
findNewDeployments,
normalizeDokployUrl,
waitForDeployment,
} from "../../.github/scripts/dokploy-deployment-tracker.mjs";
const DOKPLOY_URL = "https://dokploy.example.com";
const COMPOSE_ID = "compose/id with spaces";
const API_TOKEN = "super-secret-token";
function response(payload, { ok = true, status = 200 } = {}) {
return {
ok,
status,
json: vi.fn().mockResolvedValue(payload),
};
}
function deployment(deploymentId, status = "running", overrides = {}) {
return {
deploymentId,
status,
title: `Deployment ${deploymentId}`,
createdAt: "2026-07-21T10:00:00.000Z",
...overrides,
};
}
function createClock() {
let time = 0;
return {
now: () => time,
sleep: vi.fn(async (ms) => {
time += ms;
}),
};
}
describe("Dokploy deployment tracker", () => {
it("normalizes the base URL and safely encodes the compose ID", () => {
expect(normalizeDokployUrl(" https://dokploy.example.com/// ")).toBe(DOKPLOY_URL);
const url = new URL(buildDeploymentsUrl(`${DOKPLOY_URL}/`, COMPOSE_ID));
expect(url.pathname).toBe("/api/deployment.allByCompose");
expect(url.searchParams.get("composeId")).toBe(COMPOSE_ID);
});
it("escapes untrusted text before writing a GitHub workflow command", () => {
expect(escapeGitHubCommandValue("build 50%\r\n::warning::unsafe"))
.toBe("build 50%25%0D%0A::warning::unsafe");
});
it("creates a snapshot and finds only unseen deployments", () => {
const existing = deployment("existing", "done");
const snapshot = createDeploymentSnapshot([existing, {}, null], "captured-at");
expect(snapshot).toEqual({
capturedAt: "captured-at",
deploymentIds: ["existing"],
});
expect(findNewDeployments([existing, deployment("new")], snapshot))
.toEqual([deployment("new")]);
});
it("sends the API key in a header without exposing it in errors", async () => {
const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 403 }));
await expect(fetchComposeDeployments({
dokployUrl: DOKPLOY_URL,
composeId: COMPOSE_ID,
apiToken: API_TOKEN,
fetchImpl,
maxRetries: 0,
})).rejects.toMatchObject({
message: "Dokploy deployment API returned HTTP 403",
status: 403,
});
const [, request] = fetchImpl.mock.calls[0];
expect(request.headers["x-api-key"]).toBe(API_TOKEN);
expect(fetchImpl.mock.calls[0][0]).not.toContain(API_TOKEN);
});
it("retries transient HTTP errors and network failures", async () => {
const sleep = vi.fn().mockResolvedValue(undefined);
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response([], { ok: false, status: 503 }))
.mockRejectedValueOnce(new TypeError("network failed"))
.mockResolvedValueOnce(response([deployment("new")]));
await expect(fetchComposeDeployments({
dokployUrl: DOKPLOY_URL,
composeId: COMPOSE_ID,
apiToken: API_TOKEN,
fetchImpl,
sleep,
retryDelayMs: 1,
maxRetries: 2,
})).resolves.toEqual([deployment("new")]);
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
});
it("does not retry authentication and configuration failures", async () => {
const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 401 }));
const sleep = vi.fn();
await expect(fetchComposeDeployments({
dokployUrl: DOKPLOY_URL,
composeId: COMPOSE_ID,
apiToken: API_TOKEN,
fetchImpl,
sleep,
})).rejects.toBeInstanceOf(DokployApiError);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
it("tracks a new deployment from running to done", async () => {
const existing = deployment("existing", "done");
const running = deployment("new", "running");
const done = deployment("new", "done", { finishedAt: "finished-at" });
const fetchDeployments = vi.fn()
.mockResolvedValueOnce([existing])
.mockResolvedValueOnce([running, existing])
.mockResolvedValueOnce([done, existing]);
const clock = createClock();
const logger = { info: vi.fn() };
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([existing]),
fetchDeployments,
sleep: clock.sleep,
now: clock.now,
pollIntervalMs: 10,
discoveryTimeoutMs: 100,
deploymentTimeoutMs: 200,
logger,
})).resolves.toEqual(done);
expect(logger.info).toHaveBeenCalledWith("Tracking Dokploy deployment new");
expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: running");
expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: done");
});
it("accepts a new deployment that is already done", async () => {
const done = deployment("new", "done");
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([]),
fetchDeployments: vi.fn().mockResolvedValue([done]),
pollIntervalMs: 1,
discoveryTimeoutMs: 10,
deploymentTimeoutMs: 20,
logger: { info: vi.fn() },
})).resolves.toEqual(done);
});
it.each(["error", "cancelled"])("returns terminal %s deployment details", async (status) => {
const failed = deployment("new", status, { errorMessage: "Build failed" });
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([]),
fetchDeployments: vi.fn().mockResolvedValue([failed]),
pollIntervalMs: 1,
discoveryTimeoutMs: 10,
deploymentTimeoutMs: 20,
logger: { info: vi.fn() },
})).resolves.toEqual(failed);
});
it("fails rather than guessing when multiple new deployments appear", async () => {
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([]),
fetchDeployments: vi.fn().mockResolvedValue([
deployment("new-a"),
deployment("new-b"),
]),
logger: { info: vi.fn() },
})).rejects.toThrow("correlation is ambiguous: new-a, new-b");
});
it("times out while waiting for a new deployment record", async () => {
const clock = createClock();
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([]),
fetchDeployments: vi.fn().mockResolvedValue([]),
sleep: clock.sleep,
now: clock.now,
pollIntervalMs: 10,
discoveryTimeoutMs: 20,
deploymentTimeoutMs: 100,
logger: { info: vi.fn() },
})).rejects.toThrow("Timed out waiting for Dokploy to create a deployment record");
});
it("times out when a deployment stays running", async () => {
const clock = createClock();
await expect(waitForDeployment({
snapshot: createDeploymentSnapshot([]),
fetchDeployments: vi.fn().mockResolvedValue([deployment("new")]),
sleep: clock.sleep,
now: clock.now,
pollIntervalMs: 10,
discoveryTimeoutMs: 20,
deploymentTimeoutMs: 30,
logger: { info: vi.fn() },
})).rejects.toThrow("Timed out waiting for Dokploy deployment new to finish");
});
});