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.
270 lines
9.4 KiB
JavaScript
270 lines
9.4 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { FakeApi, sequence, fakeClock, collectingLog } = require("./helpers");
|
|
const { run } = require("../src/deploy");
|
|
|
|
const APP = { kind: "application", id: "a1", name: "api", appName: "shop-api", environmentId: "e1" };
|
|
const STACK = { kind: "compose", id: "c1", name: "worker", appName: "shop-worker", environmentId: "e1" };
|
|
|
|
const HISTORY = [{ deploymentId: "d1", status: "done", createdAt: "2026-08-01T00:00:00Z" }];
|
|
|
|
function options(overrides = {}) {
|
|
const clock = fakeClock();
|
|
return {
|
|
clock,
|
|
options: {
|
|
timeoutMs: 60_000,
|
|
pollMs: 5_000,
|
|
now: clock.now,
|
|
sleep: clock.sleep,
|
|
log: collectingLog(),
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("a deployment is watched from trigger to done", async () => {
|
|
const running = { deploymentId: "d2", status: "running", createdAt: "2026-08-02T00:00:00Z" };
|
|
const api = new FakeApi({
|
|
"deployment.all": sequence([
|
|
HISTORY, // snapshot taken before the trigger
|
|
HISTORY, // the queue has not picked it up yet
|
|
[...HISTORY, running], // the build started
|
|
[...HISTORY, { ...running, status: "done" }],
|
|
]),
|
|
"application.deploy": true,
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts, action: "deploy", title: "CI abc1234", description: "shop/main" });
|
|
|
|
assert.equal(result.status, "done");
|
|
assert.equal(result.succeeded, true);
|
|
assert.equal(result.deploymentId, "d2");
|
|
assert.equal(result.timedOut, false);
|
|
assert.equal(result.logs, "", "logs are only printed on failure by default");
|
|
assert.deepEqual(api.find("application.deploy").input, {
|
|
applicationId: "a1",
|
|
title: "CI abc1234",
|
|
description: "shop/main",
|
|
});
|
|
});
|
|
|
|
test("a deployment that already existed is never mistaken for this run's", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.all": sequence([
|
|
// A deployment newer than the one we trigger is already in flight, and
|
|
// must not be adopted as ours.
|
|
[{ deploymentId: "d9", status: "running", createdAt: "2026-08-03T00:00:00Z" }],
|
|
[
|
|
{ deploymentId: "d9", status: "running", createdAt: "2026-08-03T00:00:00Z" },
|
|
{ deploymentId: "d10", status: "done", createdAt: "2026-08-03T00:01:00Z" },
|
|
],
|
|
]),
|
|
"application.deploy": true,
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts });
|
|
|
|
assert.equal(result.deploymentId, "d10");
|
|
});
|
|
|
|
test("a failed deployment fails the step and pulls the logs", async () => {
|
|
const failed = {
|
|
deploymentId: "d2",
|
|
status: "error",
|
|
createdAt: "2026-08-02T00:00:00Z",
|
|
errorMessage: "exit code 1",
|
|
};
|
|
const api = new FakeApi({
|
|
"deployment.all": sequence([HISTORY, [...HISTORY, failed]]),
|
|
"application.deploy": true,
|
|
"deployment.readLogs": "npm ERR! build failed",
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts, logTail: 50 });
|
|
|
|
assert.equal(result.status, "error");
|
|
assert.equal(result.succeeded, false);
|
|
assert.equal(result.errorMessage, "exit code 1");
|
|
assert.equal(result.logs, "npm ERR! build failed");
|
|
assert.deepEqual(api.find("deployment.readLogs").input, { deploymentId: "d2", tail: 50 });
|
|
});
|
|
|
|
test("a cancelled deployment is a failure", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "cancelled", createdAt: "x" }]]),
|
|
"application.deploy": true,
|
|
"deployment.readLogs": "",
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts });
|
|
|
|
assert.equal(result.status, "cancelled");
|
|
assert.equal(result.succeeded, false);
|
|
assert.match(result.errorMessage, /status cancelled/);
|
|
});
|
|
|
|
test("unreadable logs never mask the deployment result", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "error", createdAt: "x" }]]),
|
|
"application.deploy": true,
|
|
"deployment.readLogs": () => {
|
|
throw new Error("ENOENT");
|
|
},
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts });
|
|
|
|
assert.equal(result.status, "error");
|
|
assert.match(result.logs, /could not read deployment logs: ENOENT/);
|
|
});
|
|
|
|
test("a deployment that never finishes times out", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.all": (_input, index) =>
|
|
index === 0 ? HISTORY : [...HISTORY, { deploymentId: "d2", status: "running", createdAt: "x" }],
|
|
"application.deploy": true,
|
|
"deployment.readLogs": "still building",
|
|
});
|
|
const { options: opts } = options({ timeoutMs: 20_000 });
|
|
|
|
const result = await run(api, APP, { ...opts });
|
|
|
|
assert.equal(result.status, "timed-out");
|
|
assert.equal(result.succeeded, false);
|
|
assert.equal(result.timedOut, true);
|
|
assert.equal(result.deploymentId, "d2");
|
|
assert.match(result.errorMessage, /did not finish within 20s/);
|
|
assert.equal(api.counts["compose.cancelDeployment"], undefined);
|
|
assert.equal(api.counts["application.cancelDeployment"], undefined);
|
|
});
|
|
|
|
test("cancel-on-timeout asks Dokploy to stop the build", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.all": (_input, index) =>
|
|
index === 0 ? HISTORY : [...HISTORY, { deploymentId: "d2", status: "running", createdAt: "x" }],
|
|
"application.deploy": true,
|
|
"application.cancelDeployment": true,
|
|
"deployment.readLogs": "",
|
|
});
|
|
const { options: opts } = options({ timeoutMs: 20_000 });
|
|
|
|
await run(api, APP, { ...opts, cancelOnTimeout: true });
|
|
|
|
assert.deepEqual(api.find("application.cancelDeployment").input, { applicationId: "a1" });
|
|
});
|
|
|
|
test("a timeout with no deployment row at all says so", async () => {
|
|
const api = new FakeApi({ "deployment.all": HISTORY, "application.deploy": true });
|
|
const { options: opts } = options({ timeoutMs: 10_000 });
|
|
|
|
const result = await run(api, APP, { ...opts });
|
|
|
|
assert.equal(result.status, "timed-out");
|
|
assert.equal(result.deploymentId, "");
|
|
assert.match(result.errorMessage, /No deployment appeared within 10s/);
|
|
});
|
|
|
|
test("docker-image points the application at the new tag before deploying", async () => {
|
|
const api = new FakeApi({
|
|
"application.saveDockerProvider": true,
|
|
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "done", createdAt: "x" }]]),
|
|
"application.deploy": true,
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
await run(api, APP, {
|
|
...opts,
|
|
dockerImage: "ghcr.io/acme/api:1.4.0",
|
|
registryUsername: "acme",
|
|
registryPassword: "token",
|
|
});
|
|
|
|
// Every field of apiSaveDockerProvider is required, so the unset ones have
|
|
// to travel as explicit nulls.
|
|
assert.deepEqual(api.find("application.saveDockerProvider").input, {
|
|
applicationId: "a1",
|
|
dockerImage: "ghcr.io/acme/api:1.4.0",
|
|
username: "acme",
|
|
password: "token",
|
|
registryUrl: null,
|
|
});
|
|
assert.deepEqual(api.procedures().slice(0, 2), ["application.saveDockerProvider", "deployment.all"]);
|
|
});
|
|
|
|
test("docker-image is refused for a Compose stack", async () => {
|
|
const api = new FakeApi({});
|
|
const { options: opts } = options();
|
|
|
|
await assert.rejects(
|
|
() => run(api, STACK, { ...opts, dockerImage: "ghcr.io/acme/api:1.4.0" }),
|
|
/only applies to applications/,
|
|
);
|
|
});
|
|
|
|
test("wait: false triggers and returns without polling", async () => {
|
|
const api = new FakeApi({ "application.deploy": true });
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts, wait: false });
|
|
|
|
assert.equal(result.status, "triggered");
|
|
assert.equal(result.succeeded, true);
|
|
assert.deepEqual(api.procedures(), ["application.deploy"]);
|
|
});
|
|
|
|
test("start and stop do not create a deployment to watch", async () => {
|
|
const api = new FakeApi({ "application.stop": true });
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, APP, { ...opts, action: "stop" });
|
|
|
|
assert.equal(result.status, "done");
|
|
assert.deepEqual(api.procedures(), ["application.stop"]);
|
|
assert.deepEqual(api.find("application.stop").input, { applicationId: "a1" });
|
|
});
|
|
|
|
test("reload sends the Docker service name alongside the id", async () => {
|
|
const api = new FakeApi({ "application.reload": true });
|
|
const { options: opts } = options();
|
|
|
|
await run(api, APP, { ...opts, action: "reload" });
|
|
|
|
assert.deepEqual(api.find("application.reload").input, { applicationId: "a1", appName: "shop-api" });
|
|
});
|
|
|
|
test("reload is refused for a Compose stack", async () => {
|
|
const api = new FakeApi({});
|
|
const { options: opts } = options();
|
|
|
|
await assert.rejects(() => run(api, STACK, { ...opts, action: "reload" }), /use `redeploy` for a Compose stack/);
|
|
});
|
|
|
|
test("a Compose stack uses the compose procedures throughout", async () => {
|
|
const api = new FakeApi({
|
|
"deployment.allByCompose": sequence([[], [{ deploymentId: "d1", status: "done", createdAt: "x" }]]),
|
|
"compose.redeploy": true,
|
|
});
|
|
const { options: opts } = options();
|
|
|
|
const result = await run(api, STACK, { ...opts, action: "redeploy" });
|
|
|
|
assert.equal(result.succeeded, true);
|
|
assert.deepEqual(api.find("compose.redeploy").input, { composeId: "c1" });
|
|
});
|
|
|
|
test("an unknown action is rejected", async () => {
|
|
const api = new FakeApi({});
|
|
const { options: opts } = options();
|
|
|
|
await assert.rejects(() => run(api, APP, { ...opts, action: "restart" }), /Unknown action "restart"/);
|
|
});
|