Deploy Dokploy applications from GitHub and Gitea Actions

A dependency-free node20 action that triggers application.deploy or
compose.deploy, then follows the deployment to a terminal status so a
failed build fails the CI run. Snapshots the deployment list before
triggering, so a concurrent deployment is never mistaken for this one.

Optionally points the application at a freshly built image first, which
is what lets a workflow lint, test, push and deploy the same artifact.
This commit is contained in:
2026-08-09 03:37:35 +03:00
commit c23b44983b
20 changed files with 2778 additions and 0 deletions

166
test/resolve.test.js Normal file
View File

@@ -0,0 +1,166 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { FakeApi } = require("./helpers");
const { resolveTarget } = require("../src/resolve");
const PROJECTS = [
{
projectId: "p1",
name: "shop",
environments: [
{
environmentId: "e1",
name: "production",
isDefault: true,
applications: [{ applicationId: "a1", name: "api", appName: "shop-api-prod" }],
compose: [],
},
{
environmentId: "e2",
name: "staging",
isDefault: false,
applications: [{ applicationId: "a2", name: "api", appName: "shop-api-stage" }],
compose: [{ composeId: "c1", name: "worker", appName: "shop-worker" }],
},
],
},
{ projectId: "p2", name: "blog", environments: [] },
];
function apiWithProjects(projects = PROJECTS) {
return new FakeApi({ "project.all": projects });
}
test("an application id is confirmed through application.one", async () => {
const api = new FakeApi({
"application.one": { name: "api", appName: "shop-api-prod", environmentId: "e1" },
});
const target = await resolveTarget(api, { applicationId: "a1" });
assert.deepEqual(target, { kind: "application", id: "a1", name: "api", appName: "shop-api-prod", environmentId: "e1" });
assert.deepEqual(api.find("application.one").input, { applicationId: "a1" });
});
test("a compose id is confirmed through compose.one", async () => {
const api = new FakeApi({ "compose.one": { name: "worker", appName: "shop-worker", environmentId: "e2" } });
const target = await resolveTarget(api, { composeId: "c1" });
assert.equal(target.kind, "compose");
assert.equal(target.id, "c1");
});
test("a name resolves against the project's default environment", async () => {
const api = apiWithProjects();
const target = await resolveTarget(api, { project: "shop", service: "api" });
assert.equal(target.id, "a1");
assert.equal(target.environmentId, "e1");
assert.equal(target.environmentName, "production");
assert.equal(target.appName, "shop-api-prod");
});
test("an explicit environment picks the other copy of the same name", async () => {
const api = apiWithProjects();
const target = await resolveTarget(api, { project: "shop", service: "api", environment: "staging" });
assert.equal(target.id, "a2");
assert.equal(target.environmentName, "staging");
});
test("a Compose stack resolves by name", async () => {
const api = apiWithProjects();
const target = await resolveTarget(api, { project: "shop", service: "worker", environment: "staging" });
assert.equal(target.kind, "compose");
assert.equal(target.id, "c1");
});
test("the Docker service name works as well as the display name", async () => {
const api = apiWithProjects();
const target = await resolveTarget(api, { project: "shop", service: "shop-api-prod" });
assert.equal(target.id, "a1");
});
test("names match case-insensitively", async () => {
const api = apiWithProjects();
const target = await resolveTarget(api, { project: "SHOP", service: "API" });
assert.equal(target.id, "a1");
});
test("an unknown project lists the ones that exist", async () => {
const api = apiWithProjects();
await assert.rejects(
() => resolveTarget(api, { project: "store", service: "api" }),
/No project named "store"\. Available: blog, shop\./,
);
});
test("an unknown environment lists the ones that exist", async () => {
const api = apiWithProjects();
await assert.rejects(
() => resolveTarget(api, { project: "shop", service: "api", environment: "qa" }),
/no environment named "qa"\. Available: production, staging\./,
);
});
test("an unknown service lists the ones in that environment", async () => {
const api = apiWithProjects();
await assert.rejects(
() => resolveTarget(api, { project: "shop", service: "cron", environment: "staging" }),
/No application or Compose stack named "cron" in shop\/staging\. Available: api, worker\./,
);
});
test("a name shared by an application and a Compose stack asks for an id", async () => {
const api = apiWithProjects([
{
projectId: "p1",
name: "shop",
environments: [
{
environmentId: "e1",
name: "production",
isDefault: true,
applications: [{ applicationId: "a1", name: "api", appName: "shop-api" }],
compose: [{ composeId: "c1", name: "api", appName: "shop-api-compose" }],
},
],
},
]);
await assert.rejects(
() => resolveTarget(api, { project: "shop", service: "api" }),
/ambiguous in shop\/production \(application and compose\)\. Use `application-id` or `compose-id`/,
);
});
test("a project with no environments is reported as such", async () => {
const api = apiWithProjects();
await assert.rejects(() => resolveTarget(api, { project: "blog", service: "api" }), /has no environments/);
});
test("conflicting or missing target inputs are rejected before any request", async () => {
const api = new FakeApi({});
await assert.rejects(() => resolveTarget(api, { applicationId: "a1", composeId: "c1" }), /not both/);
await assert.rejects(() => resolveTarget(api, { applicationId: "a1", project: "shop", service: "api" }), /not both/);
await assert.rejects(() => resolveTarget(api, {}), /No target/);
await assert.rejects(() => resolveTarget(api, { project: "shop" }), /No target/);
assert.equal(api.calls.length, 0);
});