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

33
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
jobs:
# Workflows pin `@v1`, so the major tag has to follow each release.
major-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Test before moving the tag
run: node --test
- name: Move the major version tag
run: |
major="${GITHUB_REF_NAME%%.*}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -f "$major" "$GITHUB_REF_NAME"
git push origin "refs/tags/$major" --force
echo "Moved $major to $GITHUB_REF_NAME"

62
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: test
on:
push:
branches: [main]
pull_request:
workflow_dispatch: {}
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: ["20", "22"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: node --test
# The action runs straight from source on every runner, so it must never
# acquire a dependency or a build step.
- name: Stay dependency-free
run: |
test ! -d node_modules || { echo "node_modules must not be committed"; exit 1; }
test ! -e package-lock.json || { echo "a lockfile means dependencies crept in"; exit 1; }
! grep -rn "require(\"[^.n]" src/ || { echo "src/ may only require node: builtins and local files"; exit 1; }
# A misconfigured step should fail with an explanation, not a stack trace.
misconfiguration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: No target named
env:
INPUT_HOST: https://dokploy.example.com
INPUT_API-KEY: not-a-real-key
run: |
set +e
output=$(node src/index.js 2>&1)
status=$?
echo "$output"
test "$status" -eq 1 || { echo "expected exit 1, got $status"; exit 1; }
echo "$output" | grep -q "No target" || { echo "expected a 'No target' error"; exit 1; }
- name: Host without a scheme
env:
INPUT_HOST: dokploy.example.com
INPUT_API-KEY: not-a-real-key
INPUT_APPLICATION-ID: whatever
run: |
set +e
output=$(node src/index.js 2>&1)
echo "$output"
echo "$output" | grep -q "must start with http" || { echo "expected a scheme error"; exit 1; }

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# This action runs from source on the runner. If either of these ever appears,
# something has gone wrong -- see the "Stay dependency-free" CI check.
node_modules/
package-lock.json
.DS_Store
*.log

356
LICENSE Normal file
View File

@@ -0,0 +1,356 @@
Copyright (c) 2026 Max Vojtkov
Mozilla Public License, version 2.0
1. Definitions
1.1. “Contributor”
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. “Contributor Version”
means the combination of the Contributions of others (if any) used by a
Contributor and that particular Contributors Contribution.
1.3. “Contribution”
means Covered Software of a particular Contributor.
1.4. “Covered Software”
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form, and
Modifications of such Source Code Form, in each case including portions
thereof.
1.5. “Incompatible With Secondary Licenses”
means
a. that the initial Contributor has attached the notice described in
Exhibit B to the Covered Software; or
b. that the Covered Software was made available under the terms of version
1.1 or earlier of the License, but not also under the terms of a
Secondary License.
1.6. “Executable Form”
means any form of the work other than Source Code Form.
1.7. “Larger Work”
means a work that combines Covered Software with other material, in a separate
file or files, that is not Covered Software.
1.8. “License”
means this document.
1.9. “Licensable”
means having the right to grant, to the maximum extent possible, whether at the
time of the initial grant or subsequently, any and all of the rights conveyed by
this License.
1.10. “Modifications”
means any of the following:
a. any file in Source Code Form that results from an addition to, deletion
from, or modification of the contents of Covered Software; or
b. any new file in Source Code Form that contains any Covered Software.
1.11. “Patent Claims” of a Contributor
means any patent claim(s), including without limitation, method, process,
and apparatus claims, in any patent Licensable by such Contributor that
would be infringed, but for the grant of the License, by the making,
using, selling, offering for sale, having made, import, or transfer of
either its Contributions or its Contributor Version.
1.12. “Secondary License”
means either the GNU General Public License, Version 2.0, the GNU Lesser
General Public License, Version 2.1, the GNU Affero General Public
License, Version 3.0, or any later versions of those licenses.
1.13. “Source Code Form”
means the form of the work preferred for making modifications.
1.14. “You” (or “Your”)
means an individual or a legal entity exercising rights under this
License. For legal entities, “You” includes any entity that controls, is
controlled by, or is under common control with You. For purposes of this
definition, “control” means (a) the power, direct or indirect, to cause
the direction or management of such entity, whether by contract or
otherwise, or (b) ownership of more than fifty percent (50%) of the
outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
a. under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or as
part of a Larger Work; and
b. under Patent Claims of such Contributor to make, use, sell, offer for
sale, have made, import, and otherwise transfer either its Contributions
or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution become
effective for each Contribution on the date the Contributor first distributes
such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under this
License. No additional rights or licenses will be implied from the distribution
or licensing of Covered Software under this License. Notwithstanding Section
2.1(b) above, no patent license is granted by a Contributor:
a. for any code that a Contributor has removed from Covered Software; or
b. for infringements caused by: (i) Your and any other third partys
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
c. under Patent Claims infringed by Covered Software in the absence of its
Contributions.
This License does not grant any rights in the trademarks, service marks, or
logos of any Contributor (except as may be necessary to comply with the
notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this License
(see Section 10.2) or under the terms of a Secondary License (if permitted
under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its Contributions
are its original creation(s) or it has sufficient rights to grant the
rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under applicable
copyright doctrines of fair use, fair dealing, or other equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under the
terms of this License. You must inform recipients that the Source Code Form
of the Covered Software is governed by the terms of this License, and how
they can obtain a copy of this License. You may not attempt to alter or
restrict the recipients rights in the Source Code Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
a. such Covered Software must also be made available in Source Code Form,
as described in Section 3.1, and You must inform recipients of the
Executable Form how they can obtain a copy of such Source Code Form by
reasonable means in a timely manner, at a charge no more than the cost
of distribution to the recipient; and
b. You may distribute such Executable Form under the terms of this License,
or sublicense it under different terms, provided that the license for
the Executable Form does not attempt to limit or alter the recipients
rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for the
Covered Software. If the Larger Work is a combination of Covered Software
with a work governed by one or more Secondary Licenses, and the Covered
Software is not Incompatible With Secondary Licenses, this License permits
You to additionally distribute such Covered Software under the terms of
such Secondary License(s), so that the recipient of the Larger Work may, at
their option, further distribute the Covered Software under the terms of
either this License or such Secondary License(s).
3.4. Notices
You may not remove or alter the substance of any license notices (including
copyright notices, patent notices, disclaimers of warranty, or limitations
of liability) contained within the Source Code Form of the Covered
Software, except that You may alter any license notices to the extent
required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on behalf
of any Contributor. You must make it absolutely clear that any such
warranty, support, indemnity, or liability obligation is offered by You
alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute, judicial
order, or regulation then You must: (a) comply with the terms of this License
to the maximum extent possible; and (b) describe the limitations and the code
they affect. Such description must be placed in a text file included with all
distributions of the Covered Software under this License. Except to the
extent prohibited by statute or regulation, such description must be
sufficiently detailed for a recipient of ordinary skill to be able to
understand it.
5. Termination
5.1. The rights granted under this License will terminate automatically if You
fail to comply with any of its terms. However, if You become compliant,
then the rights granted under this License from a particular Contributor
are reinstated (a) provisionally, unless and until such Contributor
explicitly and finally terminates Your grants, and (b) on an ongoing basis,
if such Contributor fails to notify You of the non-compliance by some
reasonable means prior to 60 days after You have come back into compliance.
Moreover, Your grants from a particular Contributor are reinstated on an
ongoing basis if such Contributor notifies You of the non-compliance by
some reasonable means, this is the first time You have received notice of
non-compliance with this License from such Contributor, and You become
compliant prior to 30 days after Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions, counter-claims,
and cross-claims) alleging that a Contributor Version directly or
indirectly infringes any patent, then the rights granted to You by any and
all Contributors for the Covered Software under Section 2.1 of this License
shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
license agreements (excluding distributors and resellers) which have been
validly granted by You or Your distributors under this License prior to
termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an “as is” basis, without
warranty of any kind, either expressed, implied, or statutory, including,
without limitation, warranties that the Covered Software is free of defects,
merchantable, fit for a particular purpose or non-infringing. The entire
risk as to the quality and performance of the Covered Software is with You.
Should any Covered Software prove defective in any respect, You (not any
Contributor) assume the cost of any necessary servicing, repair, or
correction. This disclaimer of warranty constitutes an essential part of this
License. No use of any Covered Software is authorized under this License
except under this disclaimer.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any
character including, without limitation, damages for lost profits, loss of
goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses, even if such party shall have been
informed of the possibility of such damages. This limitation of liability
shall not apply to liability for death or personal injury resulting from such
partys negligence to the extent applicable law prohibits such limitation.
Some jurisdictions do not allow the exclusion or limitation of incidental or
consequential damages, so this exclusion and limitation may not apply to You.
8. Litigation
Any litigation relating to this License may be brought only in the courts of
a jurisdiction where the defendant maintains its principal place of business
and such litigation shall be governed by laws of that jurisdiction, without
reference to its conflict-of-law provisions. Nothing in this Section shall
prevent a partys ability to bring cross-claims or counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject matter
hereof. If any provision of this License is held to be unenforceable, such
provision shall be reformed only to the extent necessary to make it
enforceable. Any law or regulation which provides that the language of a
contract shall be construed against the drafter shall not be used to construe
this License against a Contributor.
10. Versions of the License
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version of
the License under which You originally received the Covered Software, or
under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a modified
version of this License if you rename the license and remove any
references to the name of the license steward (except to note that such
modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file, then
You may include the notice in a location (such as a LICENSE file in a relevant
directory) where a recipient would be likely to look for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - “Incompatible With Secondary Licenses” Notice
This Source Code Form is “Incompatible
With Secondary Licenses”, as defined by
the Mozilla Public License, v. 2.0.

330
README.md Normal file
View File

@@ -0,0 +1,330 @@
# dokploy-deploy-action
Trigger a [Dokploy](https://dokploy.com) deployment from a CI job and wait for
it to finish. Works on **GitHub Actions** and **Gitea Actions**.
Dokploy's built-in Git integration deploys on every push, before anything has
had a chance to fail. This action makes the deployment an ordinary job, so it
can sit behind your linters and tests, deploy the exact image you just built,
and fail the run when the deployment fails.
```yaml
- uses: maxvojtkov/dokploy-deploy-action@v1
with:
host: ${{ secrets.DOKPLOY_HOST }}
api-key: ${{ secrets.DOKPLOY_API_KEY }}
project: shop
service: api
docker-image: ghcr.io/acme/api:${{ github.sha }}
```
## Contents
- [Why not the built-in webhook?](#why-not-the-built-in-webhook)
- [Quick start](#quick-start)
- [Gitea](#gitea)
- [Choosing the service](#choosing-the-service)
- [Deploying a new image](#deploying-a-new-image)
- [Compose stacks](#compose-stacks)
- [Other actions](#other-actions)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [How it decides the deployment failed](#how-it-decides-the-deployment-failed)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Related](#related)
## Why not the built-in webhook?
Dokploy's webhook is the right tool when a push *is* the deployment. It stops
being the right tool as soon as you want something between the two:
| | Webhook | This action |
| --- | --- | --- |
| Runs after your tests | no | yes |
| Deploys an image built in CI | no | yes, via `docker-image` |
| Fails the CI run on a bad deploy | no | yes |
| Build log in the CI job | no | yes, on failure or always |
| Deploy on a tag, on dispatch, on a schedule | no | yes |
| Promote the same artifact to staging then production | no | yes |
## Quick start
1. In Dokploy, go to **Settings → Profile → API/CLI** and create a token.
2. Add two repository secrets: `DOKPLOY_HOST` (e.g.
`https://dokploy.example.com`) and `DOKPLOY_API_KEY`.
3. Add a deploy job.
```yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: maxvojtkov/dokploy-deploy-action@v1
with:
host: ${{ secrets.DOKPLOY_HOST }}
api-key: ${{ secrets.DOKPLOY_API_KEY }}
project: shop
service: api
```
`host` and `api-key` may also come from `DOKPLOY_HOST` and `DOKPLOY_API_KEY` in
the environment, which is handy when a whole job talks to Dokploy:
```yaml
env:
DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }}
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
```
Worked examples, from lint through image build to deploy:
- [`examples/github-build-and-deploy.yml`](examples/github-build-and-deploy.yml)
- [`examples/gitea-build-and-deploy.yml`](examples/gitea-build-and-deploy.yml)
- [`examples/manual-promote.yml`](examples/manual-promote.yml) — promote or roll
back an existing tag from the Actions tab.
## Gitea
Nothing changes. The action is plain JavaScript with no dependencies and no
bundled `dist/`, so Gitea's `act_runner` executes it straight from the
repository, and Gitea sets the same `GITHUB_*` variables the defaults are built
from.
How you reference it depends on your instance's `DEFAULT_ACTIONS_URL`. If it is
the default (`https://github.com`), the short form works:
```yaml
- uses: maxvojtkov/dokploy-deploy-action@v1
```
Otherwise give the full URL, or mirror this repository into your instance:
```yaml
- uses: https://github.com/maxvojtkov/dokploy-deploy-action@v1
```
## Choosing the service
Either name the service, or give an id.
```yaml
# By name. `environment` defaults to the project's default environment.
project: shop
service: api
environment: staging
```
`service` matches either the name shown in the UI or the underlying Docker
service name, case-insensitively. If the name is wrong the error lists what
does exist in that environment, which is usually enough to fix it.
```yaml
# By id — from the Dokploy URL, or from a Terraform/Pulumi output.
application-id: kJ3n8xQ2vB
```
Ids never go stale in the way names do, but they change when a service is
recreated. Names read better in a workflow file; ids are better when the id is
already an output of whatever created the service.
## Deploying a new image
`docker-image` points the application at a tag and then deploys it. This is the
whole reason to run a deployment from CI: the image that gets deployed is the
one your tests just passed against.
```yaml
- uses: maxvojtkov/dokploy-deploy-action@v1
with:
project: shop
service: api
docker-image: ghcr.io/acme/api:${{ needs.build.outputs.tag }}
registry-url: ghcr.io
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GHCR_PULL_TOKEN }}
```
Two things to know:
- This switches the application's source type to **Docker**. An application
Dokploy builds from Git should not be given a `docker-image`; use `redeploy`
instead and let Dokploy build it.
- Dokploy **stores** the registry credentials and reuses them every time it
restarts the container. A job-scoped token like `GITHUB_TOKEN` will work for
the deployment and then stop working, so use a long-lived token — or
configure a registry in Dokploy once and leave the credentials out here.
Always deploy an immutable tag (a commit SHA or a digest) rather than `latest`.
With `latest`, nothing about the application changes between deploys, and what
you get depends on the node's pull policy.
## Compose stacks
Same thing, with `compose-id` or a `service` that names a stack. Images come
from the Compose file, so `docker-image` does not apply:
```yaml
- uses: maxvojtkov/dokploy-deploy-action@v1
with:
project: shop
service: worker
action: redeploy
```
## Other actions
`action` selects the operation. `deploy` and `redeploy` queue a build and are
followed to completion; the rest are immediate.
| `action` | Effect |
| --- | --- |
| `deploy` (default) | Build and deploy. |
| `redeploy` | Rebuild and deploy, without changing the source. |
| `start` | Start a stopped service. |
| `stop` | Stop a running service. |
| `reload` | Recreate the container from the current image. Applications only. |
## Inputs
Everything is optional; the required combinations are described above.
### Connection
| Input | Default | Description |
| --- | --- | --- |
| `host` | `$DOKPLOY_HOST` | Instance URL, e.g. `https://dokploy.example.com`. A trailing `/api` is tolerated. |
| `api-key` | `$DOKPLOY_API_KEY` | Token from Settings → Profile → API/CLI. |
| `insecure` | `false` | Skip TLS verification. Self-signed certificates only. |
| `request-timeout` | `60` | Seconds before a single API request is abandoned. |
### Target
| Input | Default | Description |
| --- | --- | --- |
| `application-id` | | Application to act on. |
| `compose-id` | | Compose stack to act on. |
| `project` | | Project name, for lookup by name. |
| `service` | | Application or stack name, for lookup by name. |
| `environment` | project default | Environment name. |
### Deployment
| Input | Default | Description |
| --- | --- | --- |
| `action` | `deploy` | `deploy`, `redeploy`, `start`, `stop` or `reload`. |
| `docker-image` | | Image to deploy. Applications only. |
| `registry-username` | | Registry username, alongside `docker-image`. |
| `registry-password` | | Registry password or token. Masked in the log. |
| `registry-url` | | Registry URL. |
| `title` | short commit SHA | Deployment title shown in Dokploy. |
| `description` | repo, ref, actor, run link | Deployment description. |
### Waiting
| Input | Default | Description |
| --- | --- | --- |
| `wait` | `true` | Follow the deployment and fail the step if it errors. |
| `timeout` | `600` | Seconds to wait for the deployment to finish. |
| `poll-interval` | `5` | Seconds between status checks. |
| `cancel-on-timeout` | `false` | Ask Dokploy to cancel the build when the timeout is hit. |
| `logs` | `on-failure` | `on-failure`, `always` or `never`. |
| `log-tail` | `200` | Number of log lines to print. |
## Outputs
| Output | Description |
| --- | --- |
| `status` | `done`, `error`, `cancelled`, `timed-out`, or `triggered` when `wait: false`. |
| `succeeded` | `"true"` or `"false"`. |
| `deployment-id` | The deployment this run started. |
| `service-kind` | `application` or `compose`. |
| `service-id` | Resolved service id. |
| `application-id` | Resolved application id; empty for a stack. |
| `compose-id` | Resolved stack id; empty for an application. |
| `app-name` | Docker service name. |
| `duration` | Seconds spent triggering and waiting. |
## How it decides the deployment failed
`application.deploy` is fire-and-forget: it puts a job on Dokploy's queue and
returns immediately. Reporting on that alone would mean every deployment
"succeeds". So the step:
1. Lists the service's existing deployments.
2. Triggers the action.
3. Polls until a deployment appears that was not in step 1, and follows it to
`done`, `error` or `cancelled`.
Because the snapshot is taken first, a deployment somebody else started is
never mistaken for this one. `done` passes; anything else fails the step and
prints the build log.
If `timeout` elapses first, the step fails with `status: timed-out` and the
build keeps running on Dokploy — set `cancel-on-timeout: true` to stop it
instead. Give `timeout` room: it covers the queue wait as well as the build.
Set `wait: false` to go back to fire-and-forget. The step then reports
`triggered` and cannot tell you whether the deployment worked.
## Troubleshooting
**`Host must start with http:// or https://`** — `host` needs a scheme.
**`dokploy API error (HTTP 401) UNAUTHORIZED`** — the token is wrong, expired,
or belongs to a different organization than the project.
**`... returned a non-JSON response; is host pointing at a Dokploy instance?`**
— a reverse proxy answered instead of Dokploy. Check the host, and that `/api`
is reachable from the runner.
**`No deployment appeared within Ns`** — the trigger was accepted but no build
started. Usually Dokploy's queue is stuck or the target server is unreachable;
check the service in the UI.
**A self-hosted Dokploy behind a private network** — the runner needs to reach
it. Either use a self-hosted runner or expose the API.
**A self-signed certificate**`insecure: true`. It disables verification for
the whole step, so prefer a real certificate.
## Development
No dependencies, no build step, no committed `dist/`. Node 20 or newer.
```bash
node --test # 47 unit tests, no network
```
`src/client.js` is the API client, `src/resolve.js` turns names into ids,
`src/deploy.js` triggers and follows a deployment, `src/core.js` is a small
stand-in for `@actions/core`, and `src/index.js` wires them together. The
action is deliberately dependency-free so both runners can execute it straight
from the repository — CI enforces that.
Releases: tag `vX.Y.Z`, and the release workflow moves the `vX` tag that
workflows pin.
## Related
Same Dokploy API, different jobs:
- [terraform-provider-dokploy](https://github.com/maxvojtkov/terraform-provider-dokploy)
— declare projects, applications, databases and domains.
- [pulumi-dokploy](https://github.com/maxvojtkov/pulumi-dokploy) — the same, in
TypeScript, Python, Go or .NET.
The usual division of labour: the provider creates the service and owns its
configuration, this action deploys code into it. Pass the provider's
`application_id` output straight into `application-id` here.
## License
[MPL-2.0](LICENSE).

127
action.yml Normal file
View File

@@ -0,0 +1,127 @@
name: Dokploy Deploy
description: Trigger a Dokploy application or Compose deployment and wait for it to finish.
author: maxvojtkov
branding:
icon: upload-cloud
color: purple
inputs:
host:
description: >-
Base URL of the Dokploy instance, e.g. https://dokploy.example.com.
A trailing /api is tolerated. Falls back to $DOKPLOY_HOST.
required: false
api-key:
description: >-
API token from Settings → Profile → API/CLI.
Falls back to $DOKPLOY_API_KEY.
required: false
# --- what to deploy: an id, or a name to look up -------------------------
application-id:
description: Application to act on.
required: false
compose-id:
description: Compose stack to act on.
required: false
project:
description: Project name, when looking the service up by name.
required: false
service:
description: >-
Application or Compose stack name, when looking it up by name.
Matches either the display name or the Docker service name.
required: false
environment:
description: Environment name. Defaults to the project's default environment.
required: false
# --- what to do ----------------------------------------------------------
action:
description: One of deploy, redeploy, start, stop, reload.
required: false
default: deploy
docker-image:
description: >-
Image to point the application at before deploying, e.g.
ghcr.io/acme/api:1.4.0. Switches the application's source type to Docker.
Applications only.
required: false
registry-username:
description: Username for a private registry, alongside docker-image.
required: false
registry-password:
description: Password or token for a private registry, alongside docker-image.
required: false
registry-url:
description: Registry URL for a private registry, alongside docker-image.
required: false
title:
description: Deployment title shown in Dokploy. Defaults to the short commit SHA.
required: false
description:
description: Deployment description. Defaults to repository, ref, actor and a link to the run.
required: false
# --- how to wait ---------------------------------------------------------
wait:
description: Wait for the deployment to reach a terminal status and fail the step if it errored.
required: false
default: "true"
timeout:
description: Seconds to wait for the deployment to finish.
required: false
default: "600"
poll-interval:
description: Seconds between deployment status checks.
required: false
default: "5"
request-timeout:
description: Seconds before a single API request is abandoned.
required: false
default: "60"
cancel-on-timeout:
description: Ask Dokploy to cancel the deployment when the timeout is hit.
required: false
default: "false"
logs:
description: When to print deployment logs — on-failure, always or never.
required: false
default: on-failure
log-tail:
description: Number of log lines to print.
required: false
default: "200"
insecure:
description: Skip TLS certificate verification. Only for self-signed certificates.
required: false
default: "false"
outputs:
status:
description: Terminal deployment status — done, error, cancelled, timed-out or triggered.
succeeded:
description: '"true" when the deployment finished successfully.'
deployment-id:
description: Id of the deployment this run started, when one was watched.
service-kind:
description: application or compose.
service-id:
description: Id of the resolved service.
application-id:
description: Resolved application id, empty for a Compose stack.
compose-id:
description: Resolved Compose id, empty for an application.
app-name:
description: Docker service name of the resolved service.
duration:
description: Seconds the step spent triggering and waiting.
runs:
using: node20
main: src/index.js

View File

@@ -0,0 +1,75 @@
# The same pipeline on Gitea Actions, pushing to Gitea's built-in registry.
#
# The only differences from the GitHub version are the registry host and the
# token: Gitea sets the same GITHUB_* variables, and the action reads them the
# same way. Requires a runner whose label provides Docker.
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
# Your Gitea instance, which is also its container registry.
REGISTRY: gitea.example.com
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run lint
- run: npm test
build:
needs: check
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
# Automatically provided by Gitea to every workflow run.
password: ${{ secrets.GITEA_TOKEN }}
- id: meta
run: |
image="$REGISTRY/$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]'):${GITHUB_SHA::7}"
echo "image=$image" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: https://github.com/maxvojtkov/dokploy-deploy-action@v1
with:
host: ${{ secrets.DOKPLOY_HOST }}
api-key: ${{ secrets.DOKPLOY_API_KEY }}
project: shop
service: api
docker-image: ${{ needs.build.outputs.image }}
registry-url: ${{ env.REGISTRY }}
registry-username: ${{ secrets.REGISTRY_USERNAME }}
# A Gitea access token with read:package, not the ephemeral
# GITEA_TOKEN: Dokploy needs credentials that outlive this run.
registry-password: ${{ secrets.REGISTRY_TOKEN }}
timeout: 900

View File

@@ -0,0 +1,82 @@
# Lint and test, build and push an image, then deploy that exact tag.
#
# This is the shape the action exists for: Dokploy's own Git webhook fires on
# every push, before anything has been checked. Here the deploy is one more
# job, gated behind the ones that can fail cheaply.
name: deploy
on:
push:
branches: [main]
# One deployment at a time. Do not cancel a run mid-deploy -- the build would
# keep going on the Dokploy side with nobody watching it.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
build:
needs: check
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# A digest or a commit-pinned tag, never `latest`: the deploy has to be
# able to name the exact artifact the tests passed against.
- id: meta
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: maxvojtkov/dokploy-deploy-action@v1
with:
host: ${{ secrets.DOKPLOY_HOST }}
api-key: ${{ secrets.DOKPLOY_API_KEY }}
project: shop
service: api
docker-image: ${{ needs.build.outputs.image }}
registry-url: ghcr.io
registry-username: ${{ github.actor }}
# GITHUB_TOKEN expires with this job. Dokploy stores these credentials
# and reuses them whenever it restarts the container, so use a
# long-lived PAT -- or a Dokploy registry -- for a private image.
registry-password: ${{ secrets.GHCR_PULL_TOKEN }}
timeout: 900

View File

@@ -0,0 +1,43 @@
# Promote an already-built image to an environment on demand, and roll back the
# same way. Nothing is rebuilt: the tag chosen here is one CI has already
# tested and pushed.
name: promote
on:
workflow_dispatch:
inputs:
environment:
description: Dokploy environment to deploy to
type: choice
options: [staging, production]
default: staging
tag:
description: Image tag to promote, e.g. a short commit SHA
required: true
jobs:
promote:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- id: deploy
uses: maxvojtkov/dokploy-deploy-action@v1
with:
host: ${{ secrets.DOKPLOY_HOST }}
api-key: ${{ secrets.DOKPLOY_API_KEY }}
project: shop
service: api
environment: ${{ inputs.environment }}
docker-image: ghcr.io/acme/api:${{ inputs.tag }}
title: Promote ${{ inputs.tag }} to ${{ inputs.environment }}
# Print the build log either way, since a human is watching.
logs: always
timeout: 900
- name: Announce
if: always()
run: |
echo "deployment ${{ steps.deploy.outputs.deployment-id }}" \
"finished with status ${{ steps.deploy.outputs.status }}" \
"in ${{ steps.deploy.outputs.duration }}s"

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "dokploy-deploy-action",
"version": "1.0.0",
"private": true,
"description": "GitHub Actions / Gitea Actions step that triggers a Dokploy deployment and waits for it to finish.",
"license": "MPL-2.0",
"engines": {
"node": ">=20"
},
"scripts": {
"test": "node --test"
}
}

207
src/client.js Normal file
View File

@@ -0,0 +1,207 @@
"use strict";
// A thin HTTP client for the Dokploy API.
//
// Dokploy exposes its whole tRPC router over REST via @dokploy/trpc-openapi.
// Every procedure lives at `<host>/api/<router>.<procedure>`:
//
// queries -> GET with flat query parameters
// mutations -> POST with a flat JSON body
//
// Authentication is a static API token in the `x-api-key` header. This mirrors
// internal/client in terraform-provider-dokploy, deliberately: the two agree on
// host normalisation and error shapes so their messages read the same.
const DEFAULT_TIMEOUT_MS = 60_000;
class DokployError extends Error {
constructor({ status, procedure, message, code, fieldErrors, raw }) {
const parts = [`dokploy API error (HTTP ${status})`];
if (code) parts.push(` ${code}`);
if (procedure) parts.push(` on ${procedure}`);
if (message) parts.push(`: ${message}`);
let text = parts.join("");
for (const [field, messages] of Object.entries(fieldErrors ?? {})) {
text += `\n - ${field}: ${messages.join("; ")}`;
}
if (!message && !Object.keys(fieldErrors ?? {}).length && raw) {
text += `: ${truncate(raw, 500)}`;
}
super(text);
this.name = "DokployError";
this.status = status;
this.procedure = procedure;
this.code = code;
this.fieldErrors = fieldErrors;
}
get isNotFound() {
if (this.status === 404 || this.code === "NOT_FOUND") return true;
// Dokploy often surfaces a missing row as a 500 carrying a service-layer
// message rather than a typed NOT_FOUND.
const message = String(this.message).toLowerCase();
return message.includes("not found") || message.includes("doesn't exist");
}
}
/**
* Normalises a user-supplied host into an API base URL. A trailing slash and a
* trailing `/api` are both tolerated, because both are things people paste.
*/
function normaliseHost(host) {
const trimmed = String(host ?? "").trim().replace(/\/+$/, "");
if (!trimmed) {
throw new Error("`host` must not be empty.");
}
const withoutApi = trimmed.replace(/\/api$/, "");
// Checked before parsing: `new URL("dokploy.example.com")` fails with a
// generic message, and a forgotten scheme is the mistake people actually
// make.
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(withoutApi)) {
throw new Error(`Host must start with http:// or https://, got ${JSON.stringify(host)}.`);
}
let url;
try {
url = new URL(withoutApi);
} catch {
throw new Error(`Invalid host ${JSON.stringify(host)}.`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Host must start with http:// or https://, got ${JSON.stringify(host)}.`);
}
return `${withoutApi}/api`;
}
class DokployClient {
/**
* @param {object} options
* @param {string} options.host Instance URL, e.g. https://dokploy.example.com
* @param {string} options.token API token from Settings -> Profile -> API/CLI
* @param {number} [options.timeoutMs]
* @param {number} [options.retries] Retries for idempotent GETs only.
* @param {(ms: number) => Promise<void>} [options.sleep] Injected for tests.
* @param {typeof fetch} [options.fetch] Injected for tests.
*/
constructor({ host, token, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 2, sleep, fetch: fetchImpl } = {}) {
this.baseURL = normaliseHost(host);
this.token = token;
this.timeoutMs = timeoutMs;
this.retries = retries;
this.sleep = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.fetch = fetchImpl ?? globalThis.fetch;
}
/** GET a tRPC query procedure. Non-null values become query parameters. */
async query(procedure, input = null) {
let endpoint = `${this.baseURL}/${procedure}`;
if (input) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
params.set(key, String(value));
}
const encoded = params.toString();
if (encoded) endpoint += `?${encoded}`;
}
// Queries have no side effects, so a flaky network is worth retrying --
// this is the path the deployment poller hammers.
return this.#withRetries(() => this.#request("GET", endpoint, null, procedure));
}
/** POST a tRPC mutation procedure with a flat JSON body. */
async mutate(procedure, input = {}) {
// Deliberately not retried: a retried `application.deploy` would queue a
// second build.
return this.#request("POST", `${this.baseURL}/${procedure}`, input ?? {}, procedure);
}
async #withRetries(attempt) {
let lastError;
for (let i = 0; i <= this.retries; i++) {
try {
return await attempt();
} catch (err) {
const retryable = !(err instanceof DokployError) || err.status >= 500;
if (!retryable || i === this.retries) throw err;
lastError = err;
await this.sleep(1000 * 2 ** i);
}
}
throw lastError;
}
async #request(method, endpoint, body, procedure) {
const init = {
method,
headers: {
"x-api-key": this.token,
accept: "application/json",
},
signal: AbortSignal.timeout(this.timeoutMs),
};
if (body !== null) {
init.headers["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
let response;
try {
response = await this.fetch(endpoint, init);
} catch (err) {
if (err.name === "TimeoutError" || err.name === "AbortError") {
throw new Error(`Calling ${procedure} timed out after ${this.timeoutMs}ms.`);
}
throw new Error(`Calling ${procedure}: ${err.message}`);
}
const text = await response.text();
if (!response.ok) {
throw parseError(response.status, procedure, text);
}
if (!text) return null;
try {
return JSON.parse(text);
} catch {
// A non-JSON 200 usually means a reverse proxy answered instead of
// Dokploy -- say so rather than reporting a parse error.
throw new Error(
`${procedure} returned a non-JSON response; is \`host\` pointing at a Dokploy instance? ` +
`First bytes: ${truncate(text, 200)}`,
);
}
}
}
function parseError(status, procedure, raw) {
const fields = { status, procedure, raw };
let envelope;
try {
envelope = JSON.parse(raw);
} catch {
return new DokployError(fields);
}
const data = envelope?.data ?? {};
const zod = data.zodError;
const fieldErrors = { ...(zod?.fieldErrors ?? {}) };
if (zod?.formErrors?.length) {
fieldErrors["(form)"] = zod.formErrors;
}
return new DokployError({
...fields,
message: envelope?.message ?? "",
code: envelope?.code || data.code || "",
procedure: data.path || procedure,
fieldErrors,
});
}
function truncate(text, limit) {
const value = String(text);
return value.length <= limit ? value : `${value.slice(0, limit)}...`;
}
module.exports = { DokployClient, DokployError, normaliseHost, parseError };

157
src/core.js Normal file
View File

@@ -0,0 +1,157 @@
"use strict";
// A minimal stand-in for @actions/core.
//
// This action ships with no dependencies and no bundling step so that GitHub
// Actions and Gitea Actions can both run it straight from the repository. The
// handful of runner conventions it needs -- INPUT_* variables, $GITHUB_OUTPUT,
// workflow commands on stdout -- are identical on both, so reimplementing them
// is cheaper than vendoring a toolkit.
const fs = require("node:fs");
const os = require("node:os");
const crypto = require("node:crypto");
// Workflow commands are newline-delimited, so any newline in a value would end
// the command early.
function escapeData(value) {
return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
}
function issue(command, message = "") {
process.stdout.write(`::${command}::${escapeData(message)}${os.EOL}`);
}
/**
* Reads an action input, falling back to an environment variable when the
* workflow did not set one. The fallback is what lets `DOKPLOY_HOST` and
* `DOKPLOY_API_KEY` work the same way they do for the Terraform and Pulumi
* providers.
*/
function getInput(name, { required = false, fallbackEnv = null, fallback = "" } = {}) {
const key = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`;
let value = (process.env[key] ?? "").trim();
if (!value && fallbackEnv) {
value = (process.env[fallbackEnv] ?? "").trim();
}
if (!value) {
value = fallback;
}
if (!value && required) {
throw new Error(`Input \`${name}\` is required.`);
}
return value;
}
function getBooleanInput(name, fallback = false) {
const value = getInput(name).toLowerCase();
if (!value) return fallback;
if (["true", "yes", "1", "on"].includes(value)) return true;
if (["false", "no", "0", "off"].includes(value)) return false;
throw new Error(`Input \`${name}\` must be a boolean, got ${JSON.stringify(value)}.`);
}
function getNumberInput(name, fallback) {
const value = getInput(name);
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Input \`${name}\` must be a non-negative number, got ${JSON.stringify(value)}.`);
}
return parsed;
}
function getChoiceInput(name, choices, fallback) {
const value = getInput(name).toLowerCase();
if (!value) return fallback;
if (!choices.includes(value)) {
throw new Error(`Input \`${name}\` must be one of ${choices.join(", ")}; got ${JSON.stringify(value)}.`);
}
return value;
}
function setOutput(name, value) {
const text = value === undefined || value === null ? "" : String(value);
const file = process.env.GITHUB_OUTPUT;
if (!file) {
// No output file when the script is run by hand, or on a very old runner.
issue(`set-output name=${name}`, text);
return;
}
// A heredoc keeps multi-line values intact; the delimiter is random so a
// value can never accidentally terminate its own block.
const delimiter = `ghadelimiter_${crypto.randomUUID()}`;
fs.appendFileSync(file, `${name}<<${delimiter}${os.EOL}${text}${os.EOL}${delimiter}${os.EOL}`);
}
function setSecret(value) {
if (value) issue("add-mask", value);
}
function info(message) {
process.stdout.write(`${message}${os.EOL}`);
}
function debug(message) {
issue("debug", message);
}
function warning(message) {
issue("warning", message);
}
function error(message) {
issue("error", message);
}
function startGroup(name) {
issue("group", name);
}
function endGroup() {
issue("endgroup");
}
function group(name, body) {
startGroup(name);
try {
return body();
} finally {
endGroup();
}
}
function summary(markdown) {
const file = process.env.GITHUB_STEP_SUMMARY;
if (!file) return;
try {
fs.appendFileSync(file, `${markdown}${os.EOL}`);
} catch (err) {
// A missing summary file must never fail a deployment that succeeded.
debug(`Could not write the job summary: ${err.message}`);
}
}
function setFailed(message) {
error(message);
process.exitCode = 1;
}
module.exports = {
escapeData,
getInput,
getBooleanInput,
getNumberInput,
getChoiceInput,
setOutput,
setSecret,
info,
debug,
warning,
error,
startGroup,
endGroup,
group,
summary,
setFailed,
};

263
src/deploy.js Normal file
View File

@@ -0,0 +1,263 @@
"use strict";
// Triggering a deployment and following it to a conclusion.
//
// `application.deploy` and `compose.deploy` are fire-and-forget: they push a
// job onto Dokploy's queue and return. That is fine for a webhook and useless
// for CI, where the whole point is to fail the job when the deployment fails.
// So the action snapshots the deployment list, triggers, spots the row that
// appeared, and polls it to a terminal status.
const { APPLICATION } = require("./resolve");
const TERMINAL = new Set(["done", "error", "cancelled"]);
const SUCCESS = new Set(["done"]);
// Actions that queue a build, and so produce a deployment row to watch.
const DEPLOYING_ACTIONS = new Set(["deploy", "redeploy"]);
const NOOP_LOG = { info() {}, warning() {} };
function idKey(target) {
return target.kind === APPLICATION ? "applicationId" : "composeId";
}
function idInput(target) {
return { [idKey(target)]: target.id };
}
/**
* Points an application at a new image before deploying it. This is the hook
* that makes "build, push, deploy this exact tag" work: without it the workflow
* would have to keep the tag in the Dokploy UI in sync by hand.
*
* `apiSaveDockerProvider` marks every field required, so the credentials have
* to be sent even when they are empty -- hence the explicit nulls. The
* procedure also flips `sourceType` to `docker`.
*/
async function setDockerImage(api, target, { dockerImage, username, password, registryUrl }) {
if (target.kind !== APPLICATION) {
throw new Error("`docker-image` only applies to applications; a Compose stack sets its images in the file.");
}
await api.mutate("application.saveDockerProvider", {
applicationId: target.id,
dockerImage,
username: username || null,
password: password || null,
registryUrl: registryUrl || null,
});
}
async function listDeployments(api, target) {
const procedure = target.kind === APPLICATION ? "deployment.all" : "deployment.allByCompose";
const rows = await api.query(procedure, idInput(target));
return Array.isArray(rows) ? rows : [];
}
async function trigger(api, target, action, { title, description }) {
const input = idInput(target);
switch (action) {
case "deploy":
case "redeploy": {
// Both are optional upstream; omitting them lets Dokploy pick its own
// default title rather than showing an empty one.
if (title) input.title = title;
if (description) input.description = description;
return api.mutate(`${target.kind}.${action}`, input);
}
case "start":
case "stop":
return api.mutate(`${target.kind}.${action}`, input);
case "reload":
if (target.kind !== APPLICATION) {
throw new Error("`reload` only applies to applications; use `redeploy` for a Compose stack.");
}
// apiReloadApplication wants the Docker service name alongside the id.
return api.mutate("application.reload", { ...input, appName: target.appName });
default:
throw new Error(`Unknown action ${JSON.stringify(action)}.`);
}
}
/**
* Best-effort log retrieval. Dokploy reads the deployment's log file off disk,
* so this can fail for reasons that have nothing to do with the deployment --
* never let it mask the actual result.
*/
async function fetchLogs(api, deploymentId, tail) {
try {
const payload = await api.query("deployment.readLogs", { deploymentId, tail });
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) return payload.join("\n");
if (payload && typeof payload.logs === "string") return payload.logs;
if (payload === null || payload === undefined) return "";
return JSON.stringify(payload, null, 2);
} catch (err) {
return `(could not read deployment logs: ${err.message})`;
}
}
async function cancel(api, target) {
await api.mutate(`${target.kind}.cancelDeployment`, idInput(target));
}
function newest(rows) {
return rows.reduce((best, row) => {
if (!best) return row;
return String(row.createdAt ?? "") > String(best.createdAt ?? "") ? row : best;
}, null);
}
/**
* Polls until the deployment triggered by this run reaches a terminal status.
*
* @param {Set<string>} knownIds Deployment ids that existed before the trigger.
*/
async function watch(api, target, { knownIds, deadline, pollMs, now, sleep, log = NOOP_LOG }) {
let deployment = null;
let lastStatus = null;
let lastHeartbeat = now();
for (;;) {
const rows = await listDeployments(api, target);
if (!deployment) {
const fresh = rows.filter((row) => row.deploymentId && !knownIds.has(row.deploymentId));
deployment = newest(fresh);
if (deployment) {
log.info(`Deployment ${deployment.deploymentId} started.`);
}
} else {
const updated = rows.find((row) => row.deploymentId === deployment.deploymentId);
if (updated) deployment = updated;
}
if (deployment) {
if (deployment.status !== lastStatus) {
lastStatus = deployment.status;
log.info(`Status: ${lastStatus}`);
lastHeartbeat = now();
}
if (TERMINAL.has(deployment.status)) {
return { deployment, timedOut: false };
}
}
if (now() >= deadline) {
return { deployment, timedOut: true };
}
// Keep the log moving so a long build does not look like a hung job.
if (now() - lastHeartbeat >= 30_000) {
const waitingFor = deployment ? `deployment ${deployment.deploymentId}` : "a deployment to appear";
log.info(`Still waiting on ${waitingFor}...`);
lastHeartbeat = now();
}
await sleep(Math.min(pollMs, Math.max(0, deadline - now())));
}
}
/**
* Runs one action end to end.
*
* @returns {Promise<{status: string, deploymentId: string, succeeded: boolean,
* durationMs: number, logs: string, timedOut: boolean}>}
*/
async function run(api, target, options) {
const {
action = "deploy",
dockerImage = "",
registryUsername = "",
registryPassword = "",
registryUrl = "",
title = "",
description = "",
wait = true,
timeoutMs = 600_000,
pollMs = 5_000,
logMode = "on-failure",
logTail = 200,
cancelOnTimeout = false,
now = () => Date.now(),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
log = NOOP_LOG,
} = options ?? {};
const startedAt = now();
if (dockerImage) {
log.info(`Setting image to ${dockerImage}`);
await setDockerImage(api, target, {
dockerImage,
username: registryUsername,
password: registryPassword,
registryUrl,
});
}
const watching = wait && DEPLOYING_ACTIONS.has(action);
// Snapshot before triggering, so the new row can be told apart from history.
const knownIds = new Set();
if (watching) {
for (const row of await listDeployments(api, target)) {
if (row.deploymentId) knownIds.add(row.deploymentId);
}
}
log.info(`Running \`${target.kind}.${action}\` on ${target.name || target.id}`);
await trigger(api, target, action, { title, description });
if (!watching) {
const status = wait ? "done" : "triggered";
return { status, deploymentId: "", succeeded: true, durationMs: now() - startedAt, logs: "", timedOut: false };
}
const { deployment, timedOut } = await watch(api, target, {
knownIds,
deadline: startedAt + timeoutMs,
pollMs,
now,
sleep,
log,
});
if (timedOut) {
if (cancelOnTimeout && deployment) {
log.warning("Timed out; asking Dokploy to cancel the deployment.");
try {
await cancel(api, target);
} catch (err) {
log.warning(`Could not cancel the deployment: ${err.message}`);
}
}
const logs = deployment && logMode !== "never" ? await fetchLogs(api, deployment.deploymentId, logTail) : "";
return {
status: "timed-out",
deploymentId: deployment?.deploymentId ?? "",
succeeded: false,
durationMs: now() - startedAt,
logs,
timedOut: true,
errorMessage: deployment
? `Deployment ${deployment.deploymentId} did not finish within ${Math.round(timeoutMs / 1000)}s.`
: `No deployment appeared within ${Math.round(timeoutMs / 1000)}s of triggering \`${target.kind}.${action}\`.`,
};
}
const succeeded = SUCCESS.has(deployment.status);
const wantLogs = logMode === "always" || (logMode === "on-failure" && !succeeded);
const logs = wantLogs ? await fetchLogs(api, deployment.deploymentId, logTail) : "";
return {
status: deployment.status,
deploymentId: deployment.deploymentId,
succeeded,
durationMs: now() - startedAt,
logs,
timedOut: false,
errorMessage: succeeded ? "" : deployment.errorMessage || `Deployment finished with status ${deployment.status}.`,
};
}
module.exports = { run, watch, trigger, setDockerImage, listDeployments, fetchLogs, TERMINAL, DEPLOYING_ACTIONS };

154
src/index.js Normal file
View File

@@ -0,0 +1,154 @@
"use strict";
const core = require("./core");
const { DokployClient } = require("./client");
const { resolveTarget, APPLICATION } = require("./resolve");
const { run } = require("./deploy");
const ACTIONS = ["deploy", "redeploy", "start", "stop", "reload"];
const LOG_MODES = ["on-failure", "always", "never"];
/**
* Both GitHub and Gitea expose the same GITHUB_* variables, so one set of
* defaults describes a deployment on either. Nothing here is required: run the
* action outside CI and the deployment is simply titled generically.
*/
function ciDefaults() {
const sha = process.env.GITHUB_SHA ?? "";
const shortSha = sha.slice(0, 7);
const repository = process.env.GITHUB_REPOSITORY ?? "";
const ref = process.env.GITHUB_REF_NAME ?? "";
const actor = process.env.GITHUB_ACTOR ?? "";
const server = (process.env.GITHUB_SERVER_URL ?? "").replace(/\/+$/, "");
const runId = process.env.GITHUB_RUN_ID ?? "";
const title = shortSha ? `CI ${shortSha}` : "CI deploy";
const parts = [];
if (repository) parts.push(repository);
if (ref) parts.push(ref);
if (actor) parts.push(`by ${actor}`);
if (server && repository && runId) parts.push(`${server}/${repository}/actions/runs/${runId}`);
return { title, description: parts.join(" · ") };
}
function readInputs() {
const defaults = ciDefaults();
const apiKey = core.getInput("api-key", { required: true, fallbackEnv: "DOKPLOY_API_KEY" });
const registryPassword = core.getInput("registry-password");
// Masked before anything else runs, so a later failure cannot echo them.
core.setSecret(apiKey);
core.setSecret(registryPassword);
return {
host: core.getInput("host", { required: true, fallbackEnv: "DOKPLOY_HOST" }),
apiKey,
applicationId: core.getInput("application-id"),
composeId: core.getInput("compose-id"),
project: core.getInput("project"),
service: core.getInput("service"),
environment: core.getInput("environment"),
action: core.getChoiceInput("action", ACTIONS, "deploy"),
dockerImage: core.getInput("docker-image"),
registryUsername: core.getInput("registry-username"),
registryPassword,
registryUrl: core.getInput("registry-url"),
title: core.getInput("title", { fallback: defaults.title }),
description: core.getInput("description", { fallback: defaults.description }),
wait: core.getBooleanInput("wait", true),
timeoutMs: core.getNumberInput("timeout", 600) * 1000,
pollMs: core.getNumberInput("poll-interval", 5) * 1000,
requestTimeoutMs: core.getNumberInput("request-timeout", 60) * 1000,
logMode: core.getChoiceInput("logs", LOG_MODES, "on-failure"),
logTail: core.getNumberInput("log-tail", 200),
cancelOnTimeout: core.getBooleanInput("cancel-on-timeout", false),
insecure: core.getBooleanInput("insecure", false),
};
}
function report(target, result, inputs) {
core.setOutput("status", result.status);
core.setOutput("succeeded", String(result.succeeded));
core.setOutput("deployment-id", result.deploymentId);
core.setOutput("service-kind", target.kind);
core.setOutput("service-id", target.id);
core.setOutput("application-id", target.kind === APPLICATION ? target.id : "");
core.setOutput("compose-id", target.kind === APPLICATION ? "" : target.id);
core.setOutput("app-name", target.appName);
core.setOutput("duration", String(Math.round(result.durationMs / 1000)));
if (result.logs) {
core.group(`Deployment logs (last ${inputs.logTail} lines)`, () => core.info(result.logs));
}
const rows = [
["Service", `${target.name || target.id} (${target.kind})`],
["Action", inputs.action],
["Status", result.status],
["Duration", `${Math.round(result.durationMs / 1000)}s`],
];
if (inputs.dockerImage) rows.splice(1, 0, ["Image", inputs.dockerImage]);
if (result.deploymentId) rows.push(["Deployment", result.deploymentId]);
let headline = "Dokploy deployment failed";
if (result.succeeded) {
// `triggered` means nobody waited for a verdict, so do not claim one.
headline = result.status === "triggered" ? "Dokploy deployment triggered" : "Dokploy deployment succeeded";
}
core.summary(
[
`### ${headline}`,
"",
"| | |",
"| --- | --- |",
...rows.map(([key, value]) => `| ${key} | ${value} |`),
"",
].join("\n"),
);
}
async function main() {
const inputs = readInputs();
if (inputs.insecure) {
// undici's fetch has no per-request TLS knob without pulling in a
// dependency, so this is process-wide. The action does nothing else over
// the network, which keeps the blast radius to this one client.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
core.warning("TLS certificate verification is disabled for this step (`insecure: true`).");
}
const api = new DokployClient({
host: inputs.host,
token: inputs.apiKey,
timeoutMs: inputs.requestTimeoutMs,
});
const target = await resolveTarget(api, inputs);
const where = target.projectName ? ` in ${target.projectName}/${target.environmentName}` : "";
core.info(`Resolved ${target.kind} ${target.name || target.id}${where}${target.id}`);
const result = await run(api, target, {
...inputs,
log: { info: core.info, warning: core.warning },
});
report(target, result, inputs);
if (!result.succeeded) {
throw new Error(result.errorMessage || `Deployment failed with status ${result.status}.`);
}
core.info(`Done in ${Math.round(result.durationMs / 1000)}s.`);
}
main().catch((err) => {
core.setFailed(err instanceof Error ? err.message : String(err));
});

138
src/resolve.js Normal file
View File

@@ -0,0 +1,138 @@
"use strict";
// Turning what the workflow author wrote into the service the API wants.
//
// Ids are stable but opaque, and pasting one into every workflow means a new
// commit whenever a service is recreated. So `project` + `service` is supported
// too, resolved through `project.all` -- one request that already carries every
// project, environment and service.
const APPLICATION = "application";
const COMPOSE = "compose";
/**
* @returns {Promise<{kind: string, id: string, name: string, appName: string,
* environmentId: string, projectName?: string, environmentName?: string}>}
*/
async function resolveTarget(api, { applicationId, composeId, project, service, environment }) {
if (applicationId && composeId) {
throw new Error("Set either `application-id` or `compose-id`, not both.");
}
if ((applicationId || composeId) && service) {
throw new Error("Set either an id (`application-id`/`compose-id`) or a name (`project` + `service`), not both.");
}
if (applicationId) return describeById(api, APPLICATION, applicationId);
if (composeId) return describeById(api, COMPOSE, composeId);
if (!project || !service) {
throw new Error(
"No target. Set `application-id`, or `compose-id`, or both `project` and `service` to look one up by name.",
);
}
return resolveByName(api, { project, service, environment });
}
async function describeById(api, kind, id) {
const key = kind === APPLICATION ? "applicationId" : "composeId";
const payload = await api.query(`${kind}.one`, { [key]: id });
if (!payload) {
throw new Error(`No ${kind} with id ${JSON.stringify(id)}.`);
}
return {
kind,
id,
name: payload.name ?? "",
appName: payload.appName ?? "",
environmentId: payload.environmentId ?? "",
};
}
async function resolveByName(api, { project, service, environment }) {
const projects = await api.query("project.all");
if (!Array.isArray(projects)) {
throw new Error("`project.all` did not return a list of projects.");
}
const matchedProject = pickByName(projects, project);
if (!matchedProject) {
throw new Error(
`No project named ${JSON.stringify(project)}. Available: ${listNames(projects) || "(none)"}.`,
);
}
const environments = matchedProject.environments ?? [];
let matchedEnvironment;
if (environment) {
matchedEnvironment = pickByName(environments, environment);
if (!matchedEnvironment) {
throw new Error(
`Project ${JSON.stringify(matchedProject.name)} has no environment named ${JSON.stringify(environment)}. ` +
`Available: ${listNames(environments) || "(none)"}.`,
);
}
} else {
// Leaving `environment` unset targets the project's default environment,
// which is what a single-environment project has.
matchedEnvironment = environments.find((env) => env.isDefault) ?? environments[0];
if (!matchedEnvironment) {
throw new Error(`Project ${JSON.stringify(matchedProject.name)} has no environments.`);
}
}
const candidates = [
...(matchedEnvironment.applications ?? []).map((item) => ({ kind: APPLICATION, item })),
...(matchedEnvironment.compose ?? []).map((item) => ({ kind: COMPOSE, item })),
];
// `name` is what the UI shows; `appName` is the Docker service name. Either
// is a reasonable thing for someone to have written down.
const matches = candidates.filter(
({ item }) => equalsIgnoringCase(item.name, service) || equalsIgnoringCase(item.appName, service),
);
if (matches.length === 0) {
const where = `${matchedProject.name}/${matchedEnvironment.name}`;
throw new Error(
`No application or Compose stack named ${JSON.stringify(service)} in ${where}. ` +
`Available: ${listNames(candidates.map((c) => c.item)) || "(none)"}.`,
);
}
if (matches.length > 1) {
throw new Error(
`${JSON.stringify(service)} is ambiguous in ${matchedProject.name}/${matchedEnvironment.name} ` +
`(${matches.map((m) => m.kind).join(" and ")}). Use \`application-id\` or \`compose-id\` instead.`,
);
}
const { kind, item } = matches[0];
return {
kind,
id: kind === APPLICATION ? item.applicationId : item.composeId,
name: item.name ?? "",
appName: item.appName ?? "",
environmentId: matchedEnvironment.environmentId ?? "",
projectName: matchedProject.name,
environmentName: matchedEnvironment.name,
};
}
// Exact match wins over a case-insensitive one, so two services differing only
// in case stay addressable.
function pickByName(items, wanted) {
return items.find((item) => item.name === wanted) ?? items.find((item) => equalsIgnoringCase(item.name, wanted));
}
function equalsIgnoringCase(a, b) {
return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
}
function listNames(items) {
return items
.map((item) => item.name)
.filter(Boolean)
.sort()
.join(", ");
}
module.exports = { resolveTarget, APPLICATION, COMPOSE };

138
test/client.test.js Normal file
View File

@@ -0,0 +1,138 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { DokployClient, DokployError, normaliseHost, parseError } = require("../src/client");
function response(status, body) {
return {
ok: status < 400,
status,
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
};
}
function clientWith(fetchImpl, options = {}) {
return new DokployClient({
host: "https://dokploy.example.com",
token: "secret",
fetch: fetchImpl,
sleep: async () => {},
...options,
});
}
test("normaliseHost tolerates trailing slashes and a trailing /api", () => {
const expected = "https://dokploy.example.com/api";
assert.equal(normaliseHost("https://dokploy.example.com"), expected);
assert.equal(normaliseHost("https://dokploy.example.com/"), expected);
assert.equal(normaliseHost("https://dokploy.example.com/api"), expected);
assert.equal(normaliseHost(" https://dokploy.example.com/api/ "), expected);
});
test("normaliseHost rejects empty and non-HTTP hosts", () => {
assert.throws(() => normaliseHost(""), /must not be empty/);
assert.throws(() => normaliseHost("dokploy.example.com"), /http:\/\/ or https:\/\//);
assert.throws(() => normaliseHost("ftp://dokploy.example.com"), /http:\/\/ or https:\/\//);
});
test("query flattens input into query parameters and drops nulls", async () => {
let seen;
const api = clientWith(async (url, init) => {
seen = { url, init };
return response(200, { ok: true });
});
await api.query("deployment.all", { applicationId: "app-1", tail: 100, since: null });
assert.equal(seen.url, "https://dokploy.example.com/api/deployment.all?applicationId=app-1&tail=100");
assert.equal(seen.init.method, "GET");
assert.equal(seen.init.headers["x-api-key"], "secret");
assert.equal(seen.init.body, undefined);
});
test("mutate posts a JSON body", async () => {
let seen;
const api = clientWith(async (url, init) => {
seen = { url, init };
return response(200, true);
});
const result = await api.mutate("application.deploy", { applicationId: "app-1", title: "CI abc1234" });
assert.equal(seen.url, "https://dokploy.example.com/api/application.deploy");
assert.equal(seen.init.method, "POST");
assert.equal(seen.init.headers["content-type"], "application/json");
assert.deepEqual(JSON.parse(seen.init.body), { applicationId: "app-1", title: "CI abc1234" });
assert.equal(result, true);
});
test("an error response becomes a DokployError carrying the Zod field errors", async () => {
const body = {
message: "Invalid input",
code: "BAD_REQUEST",
data: { code: "BAD_REQUEST", path: "application.deploy", zodError: { fieldErrors: { applicationId: ["Required"] } } },
};
const api = clientWith(async () => response(400, body));
await assert.rejects(
() => api.mutate("application.deploy", {}),
(err) => {
assert.ok(err instanceof DokployError);
assert.equal(err.status, 400);
assert.match(err.message, /HTTP 400/);
assert.match(err.message, /BAD_REQUEST/);
assert.match(err.message, /applicationId: Required/);
return true;
},
);
});
test("a 500 whose message says 'not found' still counts as not found", () => {
const err = parseError(500, "application.one", JSON.stringify({ message: "Application not found" }));
assert.equal(err.isNotFound, true);
});
test("queries retry a 5xx, mutations do not", async () => {
let queryAttempts = 0;
const flaky = clientWith(async () => {
queryAttempts += 1;
return queryAttempts < 3 ? response(502, "bad gateway") : response(200, { fine: true });
});
assert.deepEqual(await flaky.query("project.all"), { fine: true });
assert.equal(queryAttempts, 3);
let mutateAttempts = 0;
const failing = clientWith(async () => {
mutateAttempts += 1;
return response(502, "bad gateway");
});
await assert.rejects(() => failing.mutate("application.deploy", {}));
assert.equal(mutateAttempts, 1, "a retried deploy would queue a second build");
});
test("queries give up after the configured number of retries", async () => {
let attempts = 0;
const api = clientWith(
async () => {
attempts += 1;
return response(500, "boom");
},
{ retries: 1 },
);
await assert.rejects(() => api.query("project.all"));
assert.equal(attempts, 2);
});
test("an HTML response explains that the host is probably wrong", async () => {
const api = clientWith(async () => response(200, "<!doctype html><html><body>nginx</body></html>"));
await assert.rejects(() => api.query("project.all"), /is `host` pointing at a Dokploy instance\?/);
});
test("a network failure is reported against the procedure that caused it", async () => {
const api = clientWith(async () => {
throw new Error("ECONNREFUSED");
});
await assert.rejects(() => api.mutate("application.deploy", {}), /Calling application\.deploy: ECONNREFUSED/);
});

80
test/core.test.js Normal file
View File

@@ -0,0 +1,80 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const core = require("../src/core");
function withEnv(vars, body) {
const saved = { ...process.env };
Object.assign(process.env, vars);
try {
return body();
} finally {
process.env = saved;
}
}
test("inputs come from INPUT_* with spaces folded to underscores", () => {
withEnv({ INPUT_DOCKER_IMAGE: " ghcr.io/acme/api:1 " }, () => {
assert.equal(core.getInput("docker-image"), "");
assert.equal(core.getInput("docker image"), "ghcr.io/acme/api:1");
});
});
test("an unset input falls back to its environment variable, then its default", () => {
withEnv({ INPUT_HOST: "", DOKPLOY_HOST: "https://dokploy.example.com" }, () => {
assert.equal(core.getInput("host", { fallbackEnv: "DOKPLOY_HOST" }), "https://dokploy.example.com");
});
withEnv({ INPUT_HOST: "https://explicit.example.com", DOKPLOY_HOST: "https://env.example.com" }, () => {
assert.equal(core.getInput("host", { fallbackEnv: "DOKPLOY_HOST" }), "https://explicit.example.com");
});
withEnv({}, () => {
assert.equal(core.getInput("title", { fallback: "CI deploy" }), "CI deploy");
assert.throws(() => core.getInput("api-key", { required: true }), /`api-key` is required/);
});
});
test("boolean inputs accept the usual spellings and reject the rest", () => {
withEnv({ INPUT_WAIT: "TRUE" }, () => assert.equal(core.getBooleanInput("wait", false), true));
withEnv({ INPUT_WAIT: "no" }, () => assert.equal(core.getBooleanInput("wait", true), false));
withEnv({ INPUT_WAIT: "" }, () => assert.equal(core.getBooleanInput("wait", true), true));
withEnv({ INPUT_WAIT: "maybe" }, () => assert.throws(() => core.getBooleanInput("wait"), /must be a boolean/));
});
test("number inputs reject anything that is not a non-negative number", () => {
withEnv({ INPUT_TIMEOUT: "900" }, () => assert.equal(core.getNumberInput("timeout", 600), 900));
withEnv({ INPUT_TIMEOUT: "" }, () => assert.equal(core.getNumberInput("timeout", 600), 600));
withEnv({ INPUT_TIMEOUT: "-1" }, () => assert.throws(() => core.getNumberInput("timeout", 600), /non-negative/));
withEnv({ INPUT_TIMEOUT: "soon" }, () => assert.throws(() => core.getNumberInput("timeout", 600), /non-negative/));
});
test("choice inputs name the alternatives when they are wrong", () => {
withEnv({ INPUT_ACTION: "REDEPLOY" }, () =>
assert.equal(core.getChoiceInput("action", ["deploy", "redeploy"], "deploy"), "redeploy"),
);
withEnv({ INPUT_ACTION: "restart" }, () =>
assert.throws(() => core.getChoiceInput("action", ["deploy", "redeploy"], "deploy"), /must be one of deploy, redeploy/),
);
});
test("multi-line outputs survive the heredoc encoding", () => {
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-action-")), "output");
fs.writeFileSync(file, "");
withEnv({ GITHUB_OUTPUT: file }, () => {
core.setOutput("status", "done");
core.setOutput("logs", "line one\nline two");
});
const written = fs.readFileSync(file, "utf8");
assert.match(written, /^status<<ghadelimiter_[0-9a-f-]+\ndone\nghadelimiter_[0-9a-f-]+\n/);
assert.match(written, /logs<<ghadelimiter_[0-9a-f-]+\nline one\nline two\nghadelimiter_/);
});
test("workflow command payloads are escaped", () => {
assert.equal(core.escapeData("100% done\nnext"), "100%25 done%0Anext");
});

269
test/deploy.test.js Normal file
View File

@@ -0,0 +1,269 @@
"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"/);
});

78
test/helpers.js Normal file
View File

@@ -0,0 +1,78 @@
"use strict";
/** A DokployClient stand-in that records calls and replays canned answers. */
class FakeApi {
/**
* @param {object} handlers Procedure name -> value, or (input, call) => value.
* A value may be a function returning a promise, or an array consumed one
* entry per call so a poller can see a status change.
*/
constructor(handlers = {}) {
this.handlers = handlers;
this.calls = [];
this.counts = {};
}
#dispatch(kind, procedure, input) {
this.calls.push({ kind, procedure, input });
const index = (this.counts[procedure] = (this.counts[procedure] ?? 0) + 1) - 1;
if (!(procedure in this.handlers)) {
throw new Error(`FakeApi: unexpected ${kind} ${procedure}`);
}
const handler = this.handlers[procedure];
if (typeof handler === "function") return Promise.resolve(handler(input, index));
if (Array.isArray(handler) && handler.__sequence) {
return Promise.resolve(handler[Math.min(index, handler.length - 1)]);
}
return Promise.resolve(handler);
}
query(procedure, input) {
return this.#dispatch("query", procedure, input);
}
mutate(procedure, input) {
return this.#dispatch("mutate", procedure, input);
}
procedures() {
return this.calls.map((call) => call.procedure);
}
find(procedure) {
return this.calls.find((call) => call.procedure === procedure);
}
}
/** Marks an array as "one entry per call" rather than a literal return value. */
function sequence(values) {
const copy = [...values];
copy.__sequence = true;
return copy;
}
/** A clock that only moves when something sleeps, so tests never really wait. */
function fakeClock(start = 0) {
let current = start;
return {
now: () => current,
sleep: async (ms) => {
current += Math.max(ms, 1);
},
advance: (ms) => {
current += ms;
},
};
}
function collectingLog() {
const lines = [];
return {
lines,
info: (message) => lines.push(`info: ${message}`),
warning: (message) => lines.push(`warning: ${message}`),
};
}
module.exports = { FakeApi, sequence, fakeClock, collectingLog };

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);
});