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:
33
.github/workflows/release.yml
vendored
Normal file
33
.github/workflows/release.yml
vendored
Normal 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
62
.github/workflows/test.yml
vendored
Normal 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
7
.gitignore
vendored
Normal 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
356
LICENSE
Normal 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 Contributor’s 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 party’s
|
||||
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
|
||||
party’s 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 party’s 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
330
README.md
Normal 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
127
action.yml
Normal 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
|
||||
75
examples/gitea-build-and-deploy.yml
Normal file
75
examples/gitea-build-and-deploy.yml
Normal 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
|
||||
82
examples/github-build-and-deploy.yml
Normal file
82
examples/github-build-and-deploy.yml
Normal 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
|
||||
43
examples/manual-promote.yml
Normal file
43
examples/manual-promote.yml
Normal 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
13
package.json
Normal 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
207
src/client.js
Normal 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
157
src/core.js
Normal 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
263
src/deploy.js
Normal 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
154
src/index.js
Normal 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
138
src/resolve.js
Normal 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
138
test/client.test.js
Normal 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
80
test/core.test.js
Normal 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
269
test/deploy.test.js
Normal 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
78
test/helpers.js
Normal 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
166
test/resolve.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user