From c23b44983b0e2a776cea3a06c070836c699ec211 Mon Sep 17 00:00:00 2001 From: Max Vojtkov Date: Sun, 9 Aug 2026 03:37:35 +0300 Subject: [PATCH] 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. --- .github/workflows/release.yml | 33 +++ .github/workflows/test.yml | 62 +++++ .gitignore | 7 + LICENSE | 356 +++++++++++++++++++++++++++ README.md | 330 +++++++++++++++++++++++++ action.yml | 127 ++++++++++ examples/gitea-build-and-deploy.yml | 75 ++++++ examples/github-build-and-deploy.yml | 82 ++++++ examples/manual-promote.yml | 43 ++++ package.json | 13 + src/client.js | 207 ++++++++++++++++ src/core.js | 157 ++++++++++++ src/deploy.js | 263 ++++++++++++++++++++ src/index.js | 154 ++++++++++++ src/resolve.js | 138 +++++++++++ test/client.test.js | 138 +++++++++++ test/core.test.js | 80 ++++++ test/deploy.test.js | 269 ++++++++++++++++++++ test/helpers.js | 78 ++++++ test/resolve.test.js | 166 +++++++++++++ 20 files changed, 2778 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 action.yml create mode 100644 examples/gitea-build-and-deploy.yml create mode 100644 examples/github-build-and-deploy.yml create mode 100644 examples/manual-promote.yml create mode 100644 package.json create mode 100644 src/client.js create mode 100644 src/core.js create mode 100644 src/deploy.js create mode 100644 src/index.js create mode 100644 src/resolve.js create mode 100644 test/client.test.js create mode 100644 test/core.test.js create mode 100644 test/deploy.test.js create mode 100644 test/helpers.js create mode 100644 test/resolve.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c870b1b --- /dev/null +++ b/.github/workflows/release.yml @@ -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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..033854d --- /dev/null +++ b/.github/workflows/test.yml @@ -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; } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b1b047 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..77ea901 --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa820ef --- /dev/null +++ b/README.md @@ -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). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b826a12 --- /dev/null +++ b/action.yml @@ -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 diff --git a/examples/gitea-build-and-deploy.yml b/examples/gitea-build-and-deploy.yml new file mode 100644 index 0000000..f1f5563 --- /dev/null +++ b/examples/gitea-build-and-deploy.yml @@ -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 diff --git a/examples/github-build-and-deploy.yml b/examples/github-build-and-deploy.yml new file mode 100644 index 0000000..4a1e0d8 --- /dev/null +++ b/examples/github-build-and-deploy.yml @@ -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 diff --git a/examples/manual-promote.yml b/examples/manual-promote.yml new file mode 100644 index 0000000..ba6e52b --- /dev/null +++ b/examples/manual-promote.yml @@ -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" diff --git a/package.json b/package.json new file mode 100644 index 0000000..b199767 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..692a7f1 --- /dev/null +++ b/src/client.js @@ -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 `/api/.`: +// +// 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} [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 }; diff --git a/src/core.js b/src/core.js new file mode 100644 index 0000000..bb5d2f4 --- /dev/null +++ b/src/core.js @@ -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, +}; diff --git a/src/deploy.js b/src/deploy.js new file mode 100644 index 0000000..f7d6f82 --- /dev/null +++ b/src/deploy.js @@ -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} 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 }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2ef037b --- /dev/null +++ b/src/index.js @@ -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)); +}); diff --git a/src/resolve.js b/src/resolve.js new file mode 100644 index 0000000..bbb7af3 --- /dev/null +++ b/src/resolve.js @@ -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 }; diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..6911ad1 --- /dev/null +++ b/test/client.test.js @@ -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, "nginx")); + 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/); +}); diff --git a/test/core.test.js b/test/core.test.js new file mode 100644 index 0000000..aab3c9c --- /dev/null +++ b/test/core.test.js @@ -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< { + assert.equal(core.escapeData("100% done\nnext"), "100%25 done%0Anext"); +}); diff --git a/test/deploy.test.js b/test/deploy.test.js new file mode 100644 index 0000000..ba4788b --- /dev/null +++ b/test/deploy.test.js @@ -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"/); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..a57c2c5 --- /dev/null +++ b/test/helpers.js @@ -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 }; diff --git a/test/resolve.test.js b/test/resolve.test.js new file mode 100644 index 0000000..fe43970 --- /dev/null +++ b/test/resolve.test.js @@ -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); +});