mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
feat: integration the github action for deployment
This commit is contained in:
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
# v0.5.35 (2026-07-16)
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|||||||
+63
-5
@@ -25,14 +25,20 @@ first successful deployment**, or Dokploy will mount a new, empty volume.
|
|||||||
## 1. Create the Dokploy application
|
## 1. Create the Dokploy application
|
||||||
|
|
||||||
1. Create a **Compose** application in Dokploy and connect this repository.
|
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.
|
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
|
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.
|
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
|
the Compose `deploy.resources.limits` configuration. The limit is per
|
||||||
`9router` container instance; the optional `headroom` sidecar has independent
|
`9router` container instance; the optional `headroom` sidecar has independent
|
||||||
resource usage.
|
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
|
service on internal port `20128`. Let Dokploy/Traefik terminate TLS. With TLS
|
||||||
enabled, retain `AUTH_COOKIE_SECURE=true`.
|
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.
|
1. Run the first deployment, open `/dashboard`, and sign in.
|
||||||
2. Add a provider connection or a model combo, then change a setting.
|
2. Add a provider connection or a model combo, then change a setting.
|
||||||
3. Push a harmless commit to the configured branch.
|
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.
|
5. Verify the provider, combo, and setting are still present.
|
||||||
|
|
||||||
If the dashboard is empty after a redeploy, check that the running service has
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user