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,311 @@
import { appendFile, readFile, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
export class DokployApiError extends Error {
constructor(message, { status, retryable = false } = {}) {
super(message);
this.name = "DokployApiError";
this.status = status;
this.retryable = retryable;
}
}
export function normalizeDokployUrl(value) {
if (!value?.trim()) {
throw new Error("DOKPLOY_URL is required");
}
const url = new URL(value.trim());
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("DOKPLOY_URL must use http or https");
}
url.pathname = url.pathname.replace(/\/+$/, "");
url.search = "";
url.hash = "";
return url.toString().replace(/\/$/, "");
}
export function buildDeploymentsUrl(dokployUrl, composeId) {
if (!composeId?.trim()) {
throw new Error("DOKPLOY_COMPOSE_ID is required");
}
const url = new URL(`${normalizeDokployUrl(dokployUrl)}/api/deployment.allByCompose`);
url.searchParams.set("composeId", composeId.trim());
return url.toString();
}
function asPositiveInteger(value, fallback, name) {
if (value === undefined || value === "") return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return parsed;
}
function defaultSleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function fetchComposeDeployments({
dokployUrl,
composeId,
apiToken,
fetchImpl = globalThis.fetch,
sleep = defaultSleep,
requestTimeoutMs = 15_000,
retryDelayMs = 2_000,
maxRetries = 3,
}) {
if (!apiToken) {
throw new Error("DOKPLOY_API_TOKEN is required");
}
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required");
}
const url = buildDeploymentsUrl(dokployUrl, composeId);
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await fetchImpl(url, {
method: "GET",
headers: {
accept: "application/json",
"x-api-key": apiToken,
},
signal: controller.signal,
});
if (!response.ok) {
const retryable = RETRYABLE_STATUS_CODES.has(response.status);
throw new DokployApiError(
`Dokploy deployment API returned HTTP ${response.status}`,
{ status: response.status, retryable },
);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new DokployApiError("Dokploy deployment API returned an unexpected response");
}
return payload;
} catch (error) {
const retryable = error instanceof DokployApiError
? error.retryable
: error?.name === "AbortError" || error instanceof TypeError;
lastError = error instanceof DokployApiError
? error
: new DokployApiError(
error?.name === "AbortError"
? "Dokploy deployment API request timed out"
: "Unable to reach the Dokploy deployment API",
{ retryable },
);
if (!retryable || attempt === maxRetries) {
throw lastError;
}
} finally {
clearTimeout(timeout);
}
await sleep(retryDelayMs);
}
throw lastError;
}
export function createDeploymentSnapshot(deployments, capturedAt = new Date().toISOString()) {
return {
capturedAt,
deploymentIds: deployments
.map((deployment) => deployment?.deploymentId)
.filter((deploymentId) => typeof deploymentId === "string" && deploymentId.length > 0),
};
}
export function findNewDeployments(deployments, snapshot) {
const knownIds = new Set(snapshot?.deploymentIds ?? []);
return deployments.filter((deployment) => (
typeof deployment?.deploymentId === "string"
&& deployment.deploymentId.length > 0
&& !knownIds.has(deployment.deploymentId)
));
}
export async function waitForDeployment({
snapshot,
fetchDeployments,
sleep = defaultSleep,
now = Date.now,
pollIntervalMs = 10_000,
discoveryTimeoutMs = 120_000,
deploymentTimeoutMs = 1_800_000,
logger = console,
}) {
if (typeof fetchDeployments !== "function") {
throw new Error("fetchDeployments is required");
}
const startedAt = now();
const discoveryDeadline = startedAt + discoveryTimeoutMs;
const deploymentDeadline = startedAt + deploymentTimeoutMs;
let trackedDeploymentId;
let lastStatus;
while (now() <= deploymentDeadline) {
const deployments = await fetchDeployments();
if (!trackedDeploymentId) {
const newDeployments = findNewDeployments(deployments, snapshot);
if (newDeployments.length > 1) {
const ids = newDeployments.map(({ deploymentId }) => deploymentId).join(", ");
throw new Error(`Multiple new Dokploy deployments were found; correlation is ambiguous: ${ids}`);
}
if (newDeployments.length === 1) {
trackedDeploymentId = newDeployments[0].deploymentId;
logger.info(`Tracking Dokploy deployment ${trackedDeploymentId}`);
} else if (now() >= discoveryDeadline) {
throw new Error("Timed out waiting for Dokploy to create a deployment record");
}
}
if (trackedDeploymentId) {
const deployment = deployments.find(({ deploymentId }) => deploymentId === trackedDeploymentId);
if (deployment) {
const status = deployment.status ?? "unknown";
if (status !== lastStatus) {
logger.info(`Dokploy deployment ${trackedDeploymentId} status: ${status}`);
lastStatus = status;
}
if (TERMINAL_STATUSES.has(status)) {
return deployment;
}
}
}
const remainingMs = deploymentDeadline - now();
if (remainingMs <= 0) break;
await sleep(Math.min(pollIntervalMs, remainingMs));
}
throw new Error(
trackedDeploymentId
? `Timed out waiting for Dokploy deployment ${trackedDeploymentId} to finish`
: "Timed out waiting for a Dokploy deployment",
);
}
function outputValue(value) {
if (value === null || value === undefined) return "";
return typeof value === "string" ? value : JSON.stringify(value);
}
export function escapeGitHubCommandValue(value) {
return outputValue(value)
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A");
}
export async function writeGitHubOutputs(filePath, outputs) {
if (!filePath) return;
let content = "";
for (const [name, rawValue] of Object.entries(outputs)) {
const value = outputValue(rawValue);
const delimiter = `DOKPLOY_${randomUUID()}`;
content += `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
}
await appendFile(filePath, content, "utf8");
}
function deploymentOutputs(deployment) {
return {
deployment_id: deployment?.deploymentId,
status: deployment?.status,
title: deployment?.title,
created_at: deployment?.createdAt,
started_at: deployment?.startedAt,
finished_at: deployment?.finishedAt,
error_message: deployment?.errorMessage,
};
}
async function runCli() {
const command = process.argv[2];
const dokployUrl = process.env.DOKPLOY_URL;
const composeId = process.env.DOKPLOY_COMPOSE_ID;
const apiToken = process.env.DOKPLOY_API_TOKEN;
const snapshotFile = process.env.DOKPLOY_SNAPSHOT_FILE;
if (!snapshotFile) {
throw new Error("DOKPLOY_SNAPSHOT_FILE is required");
}
const requestOptions = {
dokployUrl,
composeId,
apiToken,
requestTimeoutMs: asPositiveInteger(process.env.DOKPLOY_REQUEST_TIMEOUT_MS, 15_000, "DOKPLOY_REQUEST_TIMEOUT_MS"),
retryDelayMs: asPositiveInteger(process.env.DOKPLOY_RETRY_DELAY_MS, 2_000, "DOKPLOY_RETRY_DELAY_MS"),
maxRetries: asPositiveInteger(process.env.DOKPLOY_MAX_RETRIES, 3, "DOKPLOY_MAX_RETRIES"),
};
if (command === "snapshot") {
const deployments = await fetchComposeDeployments(requestOptions);
const snapshot = createDeploymentSnapshot(deployments);
await writeFile(snapshotFile, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
console.info(`Captured ${snapshot.deploymentIds.length} existing Dokploy deployment IDs`);
return;
}
if (command === "wait") {
const snapshot = JSON.parse(await readFile(snapshotFile, "utf8"));
const deployment = await waitForDeployment({
snapshot,
fetchDeployments: () => fetchComposeDeployments(requestOptions),
pollIntervalMs: asPositiveInteger(process.env.DOKPLOY_POLL_INTERVAL_MS, 10_000, "DOKPLOY_POLL_INTERVAL_MS"),
discoveryTimeoutMs: asPositiveInteger(process.env.DOKPLOY_DISCOVERY_TIMEOUT_MS, 120_000, "DOKPLOY_DISCOVERY_TIMEOUT_MS"),
deploymentTimeoutMs: asPositiveInteger(process.env.DOKPLOY_DEPLOYMENT_TIMEOUT_MS, 1_800_000, "DOKPLOY_DEPLOYMENT_TIMEOUT_MS"),
});
await writeGitHubOutputs(process.env.GITHUB_OUTPUT, deploymentOutputs(deployment));
if (deployment.status !== "done") {
const detail = deployment.errorMessage ? `: ${deployment.errorMessage}` : "";
throw new Error(`Dokploy deployment ${deployment.deploymentId} ended with status ${deployment.status}${detail}`);
}
console.info(`Dokploy deployment ${deployment.deploymentId} completed successfully`);
return;
}
throw new Error("Usage: dokploy-deployment-tracker.mjs <snapshot|wait>");
}
const isCliEntry = process.argv[1]
&& import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCliEntry) {
runCli().catch((error) => {
console.error(
`::error title=Dokploy deployment tracking failed::${escapeGitHubCommandValue(error.message)}`,
);
process.exitCode = 1;
});
}
+167
View File
@@ -0,0 +1,167 @@
name: Deploy to Dokploy
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: dokploy-production
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy production
runs-on: ubuntu-latest
timeout-minutes: 40
environment:
name: production
url: ${{ vars.NINEROUTER_PUBLIC_URL }}
env:
DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}
DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}
DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }}
DOKPLOY_SNAPSHOT_FILE: ${{ runner.temp }}/dokploy-deployments-before.json
steps:
- name: Checkout deployment tooling
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Validate deployment configuration
env:
PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }}
run: |
node <<'NODE'
const required = [
"DOKPLOY_URL",
"DOKPLOY_COMPOSE_ID",
"DOKPLOY_API_TOKEN",
"PUBLIC_URL",
];
for (const name of required) {
if (!process.env[name]?.trim()) {
throw new Error(`${name} is required`);
}
}
for (const name of ["DOKPLOY_URL", "PUBLIC_URL"]) {
const url = new URL(process.env[name]);
if (url.protocol !== "https:") {
throw new Error(`${name} must use HTTPS`);
}
}
NODE
- name: Capture deployment baseline
run: node .github/scripts/dokploy-deployment-tracker.mjs snapshot
- name: Trigger Dokploy Compose deployment
id: trigger
uses: benbristow/dokploy-deploy-action@0.2.2
with:
dokploy_url: ${{ vars.DOKPLOY_URL }}
api_token: ${{ secrets.DOKPLOY_API_TOKEN }}
application_id: ${{ vars.DOKPLOY_COMPOSE_ID }}
service_type: compose
- name: Wait for Dokploy deployment
id: deployment
run: node .github/scripts/dokploy-deployment-tracker.mjs wait
- name: Verify production health
id: health
env:
PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }}
run: |
node <<'NODE'
const publicUrl = process.env.PUBLIC_URL?.trim();
if (!publicUrl) {
throw new Error("NINEROUTER_PUBLIC_URL is required");
}
const healthUrl = new URL("/api/health", publicUrl).toString();
const attempts = 18;
const intervalMs = 10_000;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(healthUrl, {
headers: { accept: "application/json" },
signal: controller.signal,
});
const body = response.ok ? await response.json() : null;
if (response.ok && body?.ok === true) {
console.info(`Production health check passed: ${healthUrl}`);
process.exit(0);
}
console.warn(`Health check ${attempt}/${attempts} returned HTTP ${response.status}`);
} catch (error) {
console.warn(`Health check ${attempt}/${attempts} failed: ${error.message}`);
} finally {
clearTimeout(timeout);
}
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
throw new Error(`Production health check failed after ${attempts} attempts: ${healthUrl}`);
NODE
- name: Publish deployment summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
DEPLOYMENT_STATUS: ${{ steps.deployment.outputs.status }}
DEPLOYMENT_TITLE: ${{ steps.deployment.outputs.title }}
DEPLOYMENT_CREATED_AT: ${{ steps.deployment.outputs.created_at }}
DEPLOYMENT_STARTED_AT: ${{ steps.deployment.outputs.started_at }}
DEPLOYMENT_FINISHED_AT: ${{ steps.deployment.outputs.finished_at }}
DEPLOYMENT_ERROR: ${{ steps.deployment.outputs.error_message }}
PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }}
run: |
sanitize_summary_value() {
printf '%s' "$1" | tr '\r\n|' ' '
}
safe_title=$(sanitize_summary_value "$DEPLOYMENT_TITLE")
safe_error=$(sanitize_summary_value "$DEPLOYMENT_ERROR")
{
echo "## Dokploy production deployment"
echo
echo "| Field | Value |"
echo "| --- | --- |"
printf '| Workflow result | `%s` |\n' "${JOB_STATUS:-unknown}"
printf '| Git ref | `%s` |\n' "$GITHUB_REF_NAME"
printf '| Commit | `%s` |\n' "$GITHUB_SHA"
printf '| Compose ID | `%s` |\n' "$DOKPLOY_COMPOSE_ID"
printf '| Deployment ID | `%s` |\n' "${DEPLOYMENT_ID:-not discovered}"
printf '| Deployment status | `%s` |\n' "${DEPLOYMENT_STATUS:-unknown}"
printf '| Created | `%s` |\n' "${DEPLOYMENT_CREATED_AT:-unknown}"
printf '| Started | `%s` |\n' "${DEPLOYMENT_STARTED_AT:-unknown}"
printf '| Finished | `%s` |\n' "${DEPLOYMENT_FINISHED_AT:-unknown}"
printf '| Health endpoint | %s/api/health |\n' "${PUBLIC_URL%/}"
if [ -n "$safe_title" ]; then
printf '\n**Deployment:** %s\n' "$safe_title"
fi
if [ -n "$safe_error" ]; then
printf '\n**Dokploy error:** %s\n' "$safe_error"
fi
} >> "$GITHUB_STEP_SUMMARY"
+5
View File
@@ -1,3 +1,8 @@
# Unreleased
## DevOps
- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries
# v0.5.35 (2026-07-16)
## Fixes
+63 -5
View File
@@ -25,14 +25,20 @@ first successful deployment**, or Dokploy will mount a new, empty volume.
## 1. Create the Dokploy application
1. Create a **Compose** application in Dokploy and connect this repository.
2. Select the branch that should deploy (for example, `main`).
2. Select the `master` branch.
3. Use the repository root and `docker-compose.yml` as the Compose file.
4. Enable automatic deployment on pushes for the selected branch.
4. Leave Dokploy **Auto Deploy** disabled. GitHub Actions is the deployment
authority for this setup.
Do not configure a Dokploy deployment webhook for the same branch. Enabling
both Dokploy Auto Deploy and the GitHub workflow can create two deployments for
one push and prevents the workflow from reliably identifying the rollout it
triggered.
The Compose file contains a `build` section, so every deploy builds the image
from the exact checked-out commit rather than pulling a published image.
The `9router` service is also limited to **0.5 CPU** and **2 GB RAM** through
The `9router` service is also limited to **0.5 CPU** and **512 MB RAM** through
the Compose `deploy.resources.limits` configuration. The limit is per
`9router` container instance; the optional `headroom` sidecar has independent
resource usage.
@@ -65,12 +71,64 @@ Add the application's public domain in Dokploy and target the `9router`
service on internal port `20128`. Let Dokploy/Traefik terminate TLS. With TLS
enabled, retain `AUTH_COOKIE_SECURE=true`.
## 4. Deploy and verify persistence
## 4. Configure GitHub Actions deployment
Create a protected GitHub Environment named `production`. Restrict it to the
`master` branch and optionally require a reviewer before production
deployments.
Add these environment values:
| Name | Kind | Value |
| --- | --- | --- |
| `DOKPLOY_API_TOKEN` | Secret | API key generated from the Dokploy profile settings |
| `DOKPLOY_URL` | Variable | Public Dokploy base URL without a trailing slash, for example `https://dokploy.example.com` |
| `DOKPLOY_COMPOSE_ID` | Variable | ID of this Compose service in Dokploy |
| `NINEROUTER_PUBLIC_URL` | Variable | Public 9Router URL used for the post-deployment health check |
Both URL variables must use HTTPS.
The API key must be allowed to deploy the Compose service and read its
deployment records. GitHub-hosted runners must be able to reach the Dokploy API
over HTTPS. If the trigger or tracking request consistently returns HTTP 403,
check whether Cloudflare bot protection is challenging Dokploy API requests.
The Compose ID can be found in the Dokploy service URL. The workflow passes it
to the action's `application_id` input because
`benbristow/dokploy-deploy-action@0.2.2` uses that input for both Applications
and Compose services. `service_type: compose` selects the Compose API.
The workflow in `.github/workflows/dokploy-deploy.yml` runs for pushes to
`master` and can also be started with **Run workflow**. It performs these steps:
1. Records the deployment IDs that already exist for the Compose service.
2. Triggers the Compose deployment with
`benbristow/dokploy-deploy-action@0.2.2`.
3. Finds the new deployment and waits for `done`, `error`, or `cancelled`.
4. After `done`, retries `GET /api/health` until it returns `{ "ok": true }`.
5. Writes deployment metadata and the final result to the GitHub job summary.
The action itself only confirms that Dokploy accepted the deploy request with
HTTP 200. The repository's tracking script performs the actual progress and
final-status checks. It does not report a percentage or copy full Dokploy build
logs into GitHub; use the Dokploy deployment page for detailed build logs.
Production deployments are serialized. Do not manually start another deploy
for this Compose service while the GitHub workflow is running. If multiple new
deployment records appear, the workflow fails safely instead of tracking an
ambiguous deployment.
Runtime secrets such as `JWT_SECRET`, `INITIAL_PASSWORD`, `API_KEY_SECRET`, and
`MACHINE_ID_SALT` remain in Dokploy. They do not need to be copied into the
GitHub Environment.
## 5. Deploy and verify persistence
1. Run the first deployment, open `/dashboard`, and sign in.
2. Add a provider connection or a model combo, then change a setting.
3. Push a harmless commit to the configured branch.
4. Wait for Dokploy's automatic deployment to finish and sign in again.
4. Wait for the GitHub Actions deployment and health check to finish, then sign
in again.
5. Verify the provider, combo, and setting are still present.
If the dashboard is empty after a redeploy, check that the running service has
@@ -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");
});
});