From e53e8e1d94e656f85de43ad35999bb9653376fa4 Mon Sep 17 00:00:00 2001 From: mignot Date: Wed, 2 Sep 2026 11:38:31 +0200 Subject: [PATCH 1/5] feat(trigger-coolify-deploy): add cancel_native_only mode Lets draft-PR workflows force-stop Coolify's native webhook-triggered preview build without triggering our own authoritative deploy, so draft PRs don't leave a phantom build consuming the host's single build slot while we intentionally skip deploying them. --- .../actions/trigger-coolify-deploy/action.yml | 108 ++++++++++++------ CHANGELOG.md | 4 + 2 files changed, 75 insertions(+), 37 deletions(-) diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml index 556ea7b..773955f 100644 --- a/.github/actions/trigger-coolify-deploy/action.yml +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -84,12 +84,28 @@ inputs: description: "SSH private key for server_host (see server_host)" required: false default: "" + cancel_native_only: + description: >- + Skip triggering our own deploy entirely — only look up and force-stop any native + (webhook-triggered) preview build already queued/in-progress for pr_number, then exit. + For draft PRs, where we don't want an authoritative deploy but still want to stop + Coolify's unmanaged native build from consuming the host's single build slot. Requires + pr_number, server_host, server_deploy_username, and server_deploy_ssh_private_key. + required: false + default: "false" runs: using: composite steps: + - name: Check cancel_native_only requires pr_number + if: inputs.cancel_native_only == 'true' && inputs.pr_number == '' + shell: bash + run: | + echo "ERROR: cancel_native_only is true but pr_number is not set — nothing to filter phantom builds by." >&2 + exit 1 + - name: Check required build-kill inputs - if: inputs.pr_number != '' + if: inputs.pr_number != '' || inputs.cancel_native_only == 'true' shell: bash env: SERVER_HOST: ${{ inputs.server_host }} @@ -107,13 +123,13 @@ runs: fi - name: Set up SSH for build-kill fallback - if: inputs.pr_number != '' + if: inputs.pr_number != '' || inputs.cancel_native_only == 'true' uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ inputs.server_deploy_ssh_private_key }} - name: Add server to known_hosts - if: inputs.pr_number != '' + if: inputs.pr_number != '' || inputs.cancel_native_only == 'true' shell: bash run: | mkdir -p ~/.ssh @@ -137,11 +153,55 @@ runs: HEALTH_CHECK_ORIGIN_IP: ${{ inputs.health_check_origin_ip }} SERVER_HOST: ${{ inputs.server_host }} SERVER_DEPLOY_USERNAME: ${{ inputs.server_deploy_username }} + CANCEL_NATIVE_ONLY: ${{ inputs.cancel_native_only }} run: | set -euo pipefail COOLIFY_API_URL="https://${COOLIFY_SUBDOMAIN}.${DOMAIN}" + # Cancels (soft-cancel API + SSH force-stop for in-progress) any native + # (webhook-triggered) deployment still queued/in-progress for $PR_NUMBER on $app_uuid, + # excluding $1 (our own deployment_uuid, or "" to exclude none — used by cancel_native_only). + cancel_phantom_builds() { + local mine_uuid="$1" + set +e + local phantoms + phantoms=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/deployments/applications/${app_uuid}?take=20" | jq -c \ + --arg pr "$PR_NUMBER" --arg mine "$mine_uuid" \ + '.deployments[]? | select((.pull_request_id | tostring) == $pr and .is_webhook == true and (.status == "queued" or .status == "in_progress") and .deployment_uuid != $mine)') + set -e + while IFS= read -r phantom; do + [ -z "$phantom" ] && continue + local phantom_uuid phantom_status + phantom_uuid=$(printf '%s' "$phantom" | jq -r '.deployment_uuid') + phantom_status=$(printf '%s' "$phantom" | jq -r '.status') + + echo " Cancelling native deploy ${phantom_uuid} (PR #${PR_NUMBER}, was ${phantom_status})..." + curl -s -o /dev/null -X POST -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/deployments/${phantom_uuid}/cancel" || true + + if [ "$phantom_status" != "in_progress" ]; then + continue + fi + # Coolify's cancel endpoint only marks an already-running deployment cancelled + # in the DB — the build process itself keeps running unattended in the + # background (upstream bug: coollabsio/coolify#5850). The only real kill is + # docker stop on the build container, which Coolify names after the + # deployment_uuid (see ApplicationDeploymentJob::graceful_shutdown_container). + # SERVER_HOST/SERVER_DEPLOY_USERNAME presence is already enforced by the + # "Check required build-kill inputs" step — no fallback here. + if ! printf '%s' "$phantom_uuid" | grep -qE '^[0-9a-f-]{36}$'; then + echo " WARNING: unexpected deployment_uuid format '${phantom_uuid}', skipping force-stop" + continue + fi + echo " Force-stopping in-progress native build container ${phantom_uuid} via SSH..." + ssh -o StrictHostKeyChecking=accept-new "${SERVER_DEPLOY_USERNAME}@${SERVER_HOST}" \ + "docker stop --time 5 ${phantom_uuid}" \ + || echo " WARNING: failed to stop ${phantom_uuid} (may have already finished)" + done <<< "$phantoms" + } + echo "Looking up Coolify app '${APP_NAME}' in environment '${COOLIFY_ENVIRONMENT}'..." # /api/v1/applications only exposes environment_id (no nested environment name), @@ -170,6 +230,13 @@ runs: fi echo "Found app UUID: ${app_uuid}" + if [ "$CANCEL_NATIVE_ONLY" = "true" ]; then + echo "cancel_native_only set — stopping any native (webhook-triggered) build for PR #${PR_NUMBER} without triggering our own deploy..." + cancel_phantom_builds "" + echo "Done — no deploy triggered for '${APP_NAME}' (cancel_native_only)." + exit 0 + fi + deploy_url="${COOLIFY_API_URL}/api/v1/deploy?uuid=${app_uuid}&force=${FORCE}" if [ -n "$PR_NUMBER" ]; then deploy_url="${deploy_url}&pr=${PR_NUMBER}" @@ -226,40 +293,7 @@ runs: if [ -n "$PR_NUMBER" ]; then echo "Checking for a still-running native (webhook-triggered) preview build to stop..." - set +e - phantoms=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ - "${COOLIFY_API_URL}/api/v1/deployments/applications/${app_uuid}?take=20" | jq -c \ - --arg pr "$PR_NUMBER" --arg mine "$deployment_uuid" \ - '.deployments[]? | select((.pull_request_id | tostring) == $pr and .is_webhook == true and (.status == "queued" or .status == "in_progress") and .deployment_uuid != $mine)') - set -e - while IFS= read -r phantom; do - [ -z "$phantom" ] && continue - phantom_uuid=$(printf '%s' "$phantom" | jq -r '.deployment_uuid') - phantom_status=$(printf '%s' "$phantom" | jq -r '.status') - - echo " Cancelling native deploy ${phantom_uuid} (PR #${PR_NUMBER}, was ${phantom_status})..." - curl -s -o /dev/null -X POST -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ - "${COOLIFY_API_URL}/api/v1/deployments/${phantom_uuid}/cancel" || true - - if [ "$phantom_status" != "in_progress" ]; then - continue - fi - # Coolify's cancel endpoint only marks an already-running deployment cancelled - # in the DB — the build process itself keeps running unattended in the - # background (upstream bug: coollabsio/coolify#5850). The only real kill is - # docker stop on the build container, which Coolify names after the - # deployment_uuid (see ApplicationDeploymentJob::graceful_shutdown_container). - # SERVER_HOST/SERVER_DEPLOY_USERNAME presence is already enforced by the - # "Check required build-kill inputs" step — no fallback here. - if ! printf '%s' "$phantom_uuid" | grep -qE '^[0-9a-f-]{36}$'; then - echo " WARNING: unexpected deployment_uuid format '${phantom_uuid}', skipping force-stop" - continue - fi - echo " Force-stopping in-progress native build container ${phantom_uuid} via SSH..." - ssh -o StrictHostKeyChecking=accept-new "${SERVER_DEPLOY_USERNAME}@${SERVER_HOST}" \ - "docker stop --time 5 ${phantom_uuid}" \ - || echo " WARNING: failed to stop ${phantom_uuid} (may have already finished)" - done <<< "$phantoms" + cancel_phantom_builds "$deployment_uuid" # Cancelling a queued native/phantom build can collaterally cancel our own # just-queued deployment too — Coolify appears to hold a single queued-build diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b558b0..cb2b3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ## [Unreleased] +### Added + +- **trigger-coolify-deploy**: new `cancel_native_only` input. When set, the action skips triggering its own deploy entirely and only looks up and force-stops any native (webhook-triggered) build already queued/in-progress for `pr_number`, then exits — for draft PRs, where consumers don't want an authoritative deploy but still want to stop Coolify's unmanaged native build from consuming the host's single build slot. Requires `pr_number`, `server_host`, `server_deploy_username`, and `server_deploy_ssh_private_key`. + ## [4.2.5] - 2026-08-23 ### Fixed From 3878753c1ca5ef080346b6cb8cd837792370c131 Mon Sep 17 00:00:00 2001 From: mignot Date: Wed, 2 Sep 2026 12:17:11 +0200 Subject: [PATCH 2/5] fix(trigger-coolify-deploy): reword build-kill error for cancel_native_only mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight check now also runs when cancel_native_only is set, where no deploy is triggered — the old message referenced "PR-preview deploys" specifically, which no longer fit. --- .github/actions/trigger-coolify-deploy/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml index 773955f..e9661a5 100644 --- a/.github/actions/trigger-coolify-deploy/action.yml +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -117,8 +117,8 @@ runs: [ -z "$SERVER_DEPLOY_USERNAME" ] && missing+=(server_deploy_username) [ -z "$SERVER_DEPLOY_SSH_PRIVATE_KEY" ] && missing+=(server_deploy_ssh_private_key) if [ "${#missing[@]}" -gt 0 ]; then - echo "ERROR: pr_number is set but missing required input(s): ${missing[*]}." >&2 - echo "PR-preview deploys must be able to force-stop a stuck native build (coollabsio/coolify#5850) — no silent fallback." >&2 + echo "ERROR: pr_number (or cancel_native_only) is set but missing required input(s): ${missing[*]}." >&2 + echo "Cancelling/force-stopping a stuck native build (coollabsio/coolify#5850) requires SSH access to the server — no silent fallback." >&2 exit 1 fi From d8233dc0bfa047e89a639108bdaa557ac851c519 Mon Sep 17 00:00:00 2001 From: mignot Date: Wed, 2 Sep 2026 12:20:18 +0200 Subject: [PATCH 3/5] fix(trigger-coolify-deploy): warn instead of silently no-op on phantom-query failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel_phantom_builds() disables set -e around its curl|jq query but wasn't checking the pipeline's exit status, so a failed API call or jq parse error silently produced an empty result — worst case for cancel_native_only, which could then exit 0 having cancelled nothing. --- .github/actions/trigger-coolify-deploy/action.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml index e9661a5..de337c0 100644 --- a/.github/actions/trigger-coolify-deploy/action.yml +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -170,7 +170,12 @@ runs: "${COOLIFY_API_URL}/api/v1/deployments/applications/${app_uuid}?take=20" | jq -c \ --arg pr "$PR_NUMBER" --arg mine "$mine_uuid" \ '.deployments[]? | select((.pull_request_id | tostring) == $pr and .is_webhook == true and (.status == "queued" or .status == "in_progress") and .deployment_uuid != $mine)') + local query_status=$? set -e + if [ "$query_status" -ne 0 ]; then + echo " WARNING: failed to query deployments for app ${app_uuid} (exit ${query_status}) — cannot detect native builds to cancel." >&2 + return 0 + fi while IFS= read -r phantom; do [ -z "$phantom" ] && continue local phantom_uuid phantom_status From 0271e1e2b53f350a1a3e7b6d817b3d6e66953a51 Mon Sep 17 00:00:00 2001 From: mignot Date: Wed, 2 Sep 2026 12:25:08 +0200 Subject: [PATCH 4/5] fix(trigger-coolify-deploy): validate deployment_uuid before using it in the cancel URL phantom_uuid comes straight from the Coolify API response and was only validated right before the SSH force-stop, after it had already been used unvalidated in the /cancel POST URL. Move the format check earlier so a malformed value from the API is rejected before it touches any URL or SSH command. --- .github/actions/trigger-coolify-deploy/action.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml index de337c0..1525398 100644 --- a/.github/actions/trigger-coolify-deploy/action.yml +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -182,6 +182,14 @@ runs: phantom_uuid=$(printf '%s' "$phantom" | jq -r '.deployment_uuid') phantom_status=$(printf '%s' "$phantom" | jq -r '.status') + # deployment_uuid comes from the Coolify API response — validate its shape + # before using it in a URL or SSH command, in case Coolify ever returns + # something malformed. + if ! printf '%s' "$phantom_uuid" | grep -qE '^[0-9a-f-]{36}$'; then + echo " WARNING: unexpected deployment_uuid format '${phantom_uuid}', skipping" + continue + fi + echo " Cancelling native deploy ${phantom_uuid} (PR #${PR_NUMBER}, was ${phantom_status})..." curl -s -o /dev/null -X POST -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ "${COOLIFY_API_URL}/api/v1/deployments/${phantom_uuid}/cancel" || true @@ -196,10 +204,6 @@ runs: # deployment_uuid (see ApplicationDeploymentJob::graceful_shutdown_container). # SERVER_HOST/SERVER_DEPLOY_USERNAME presence is already enforced by the # "Check required build-kill inputs" step — no fallback here. - if ! printf '%s' "$phantom_uuid" | grep -qE '^[0-9a-f-]{36}$'; then - echo " WARNING: unexpected deployment_uuid format '${phantom_uuid}', skipping force-stop" - continue - fi echo " Force-stopping in-progress native build container ${phantom_uuid} via SSH..." ssh -o StrictHostKeyChecking=accept-new "${SERVER_DEPLOY_USERNAME}@${SERVER_HOST}" \ "docker stop --time 5 ${phantom_uuid}" \ From fced4eb3d3d9d7f2a731a287bea346994023fd81 Mon Sep 17 00:00:00 2001 From: mignot Date: Wed, 2 Sep 2026 12:32:16 +0200 Subject: [PATCH 5/5] fix(trigger-coolify-deploy): tighten deployment_uuid regex to canonical UUID shape The previous ^[0-9a-f-]{36}$ pattern accepted malformed strings (e.g. all hyphens, or hyphens in arbitrary positions) as long as they were 36 chars of hex digits and dashes. Use the canonical 8-4-4-4-12 form instead. --- .github/actions/trigger-coolify-deploy/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml index 1525398..969d7e4 100644 --- a/.github/actions/trigger-coolify-deploy/action.yml +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -185,7 +185,7 @@ runs: # deployment_uuid comes from the Coolify API response — validate its shape # before using it in a URL or SSH command, in case Coolify ever returns # something malformed. - if ! printf '%s' "$phantom_uuid" | grep -qE '^[0-9a-f-]{36}$'; then + if ! printf '%s' "$phantom_uuid" | grep -qiE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then echo " WARNING: unexpected deployment_uuid format '${phantom_uuid}', skipping" continue fi