diff --git a/.github/actions/trigger-coolify-deploy/action.yml b/.github/actions/trigger-coolify-deploy/action.yml new file mode 100644 index 0000000..1ba0bcd --- /dev/null +++ b/.github/actions/trigger-coolify-deploy/action.yml @@ -0,0 +1,227 @@ +# App-agnostic composite action: trigger a Coolify application deploy via API and wait +# for it to finish (and optionally become healthy) before returning. +# Used to enforce cross-application deploy ordering (e.g. api before web) from GitHub +# Actions instead of relying on Coolify's native, unordered git-push auto-deploy. +name: Trigger Coolify deploy + +description: Trigger a Coolify application deploy via API, wait for it to finish, and optionally health-check it + +inputs: + app_name: + description: "Coolify application name (e.g. tmd-admin-api)" + required: true + coolify_environment: + description: "Coolify environment name (production or staging)" + required: true + coolify_subdomain: + description: "Coolify subdomain (e.g. coolify)" + required: true + domain: + description: "Base domain (e.g. example.com)" + required: true + coolify_api_token: + description: "Coolify API token (composite actions have no secrets: block — pass via with:)" + required: true + pr_number: + description: "PR number to target a PR-preview deployment instead of the regular one" + required: false + default: "" + force: + description: "Force a fresh deploy even if the commit is already deployed" + required: false + default: "false" + poll_timeout_seconds: + description: "Max seconds to wait for the deployment (and health check, if any) to finish" + required: false + default: "900" + poll_interval_seconds: + description: "Seconds between deployment/health-check polls" + required: false + default: "15" + preview_not_found_timeout_seconds: + description: >- + Max seconds to retry triggering a PR-preview deploy while Coolify hasn't created the + ApplicationPreview record yet (races Coolify's own webhook-driven preview creation). + Ignored when pr_number is not set. + required: false + default: "180" + health_check_path: + description: "Optional path (e.g. /health) to poll for HTTP 200 after the deployment finishes" + required: false + default: "" + health_check_base_url_override: + description: >- + Base URL to health-check against instead of the application's own fqdn. Required when + both pr_number and health_check_path are set, since Coolify's API doesn't expose a + preview's generated fqdn. + required: false + default: "" + +runs: + using: composite + steps: + - name: Trigger deploy and wait + shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + COOLIFY_ENVIRONMENT: ${{ inputs.coolify_environment }} + COOLIFY_SUBDOMAIN: ${{ inputs.coolify_subdomain }} + DOMAIN: ${{ inputs.domain }} + COOLIFY_API_TOKEN: ${{ inputs.coolify_api_token }} + PR_NUMBER: ${{ inputs.pr_number }} + FORCE: ${{ inputs.force }} + POLL_TIMEOUT_SECONDS: ${{ inputs.poll_timeout_seconds }} + POLL_INTERVAL_SECONDS: ${{ inputs.poll_interval_seconds }} + PREVIEW_NOT_FOUND_TIMEOUT_SECONDS: ${{ inputs.preview_not_found_timeout_seconds }} + HEALTH_CHECK_PATH: ${{ inputs.health_check_path }} + HEALTH_CHECK_BASE_URL_OVERRIDE: ${{ inputs.health_check_base_url_override }} + run: | + set -euo pipefail + + COOLIFY_API_URL="https://${COOLIFY_SUBDOMAIN}.${DOMAIN}" + + echo "Looking up Coolify app '${APP_NAME}' in environment '${COOLIFY_ENVIRONMENT}'..." + + # /api/v1/applications only exposes environment_id (no nested environment name), + # so resolve the target environment's id(s) across all projects first. + projects=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/projects") + + env_ids="[]" + for project_uuid in $(echo "$projects" | jq -r '.[].uuid'); do + project=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/projects/${project_uuid}") + ids=$(echo "$project" | jq -c --arg env "$COOLIFY_ENVIRONMENT" \ + '[.environments[] | select(.name == $env) | .id]') + env_ids=$(jq -c -n --argjson a "$env_ids" --argjson b "$ids" '$a + $b') + done + + apps=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/applications") + app_uuid=$(echo "$apps" | jq -r \ + --arg name "$APP_NAME" \ + --argjson env_ids "$env_ids" \ + '.[] | select(.name == $name and (.environment_id | IN($env_ids[]))) | .uuid' | head -1) + if [ -z "$app_uuid" ]; then + echo "ERROR: No Coolify app found with name '${APP_NAME}' in environment '${COOLIFY_ENVIRONMENT}'" + exit 1 + fi + echo "Found app UUID: ${app_uuid}" + + 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}" + fi + + trigger_deploy() { + curl -s -w '\n%{http_code}' -X POST \ + -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "$deploy_url" + } + + response="" + http_code="" + if [ -n "$PR_NUMBER" ]; then + echo "Triggering deploy for PR #${PR_NUMBER} preview (retrying up to ${PREVIEW_NOT_FOUND_TIMEOUT_SECONDS}s if the preview doesn't exist yet)..." + start=$(date +%s) + while :; do + raw=$(trigger_deploy) + http_code=$(printf '%s' "$raw" | tail -1) + response=$(printf '%s' "$raw" | sed '$d') + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + break + fi + if printf '%s' "$response" | grep -qi "not found"; then + elapsed=$(( $(date +%s) - start )) + if [ "$elapsed" -ge "$PREVIEW_NOT_FOUND_TIMEOUT_SECONDS" ]; then + echo "ERROR: PR #${PR_NUMBER} preview not found after ${PREVIEW_NOT_FOUND_TIMEOUT_SECONDS}s: ${response}" + exit 1 + fi + echo " Preview not found yet (${elapsed}s elapsed) — retrying..." + sleep "$POLL_INTERVAL_SECONDS" + continue + fi + echo "ERROR: HTTP ${http_code} triggering deploy: ${response}" + exit 1 + done + else + echo "Triggering deploy..." + raw=$(trigger_deploy) + http_code=$(printf '%s' "$raw" | tail -1) + response=$(printf '%s' "$raw" | sed '$d') + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "ERROR: HTTP ${http_code} triggering deploy: ${response}" + exit 1 + fi + fi + + deployment_uuid=$(printf '%s' "$response" | jq -r '.deployments[0].deployment_uuid // empty') + if [ -z "$deployment_uuid" ]; then + echo "ERROR: No deployment_uuid in response: ${response}" + exit 1 + fi + echo "Deployment queued: ${deployment_uuid}" + + echo "Polling deployment status (timeout ${POLL_TIMEOUT_SECONDS}s)..." + start=$(date +%s) + status="" + while :; do + status=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/deployments/${deployment_uuid}" | jq -r '.status // empty') + case "$status" in + finished) + echo "Deployment finished." + break + ;; + failed|cancelled-by-user) + echo "ERROR: Deployment ended with status '${status}'" + exit 1 + ;; + esac + elapsed=$(( $(date +%s) - start )) + if [ "$elapsed" -ge "$POLL_TIMEOUT_SECONDS" ]; then + echo "ERROR: Timed out after ${POLL_TIMEOUT_SECONDS}s waiting for deployment (last status: '${status}')" + exit 1 + fi + sleep "$POLL_INTERVAL_SECONDS" + done + + if [ -n "$HEALTH_CHECK_PATH" ]; then + if [ -n "$PR_NUMBER" ]; then + if [ -z "$HEALTH_CHECK_BASE_URL_OVERRIDE" ]; then + echo "ERROR: health_check_base_url_override is required when both pr_number and health_check_path are set" + exit 1 + fi + base_url="$HEALTH_CHECK_BASE_URL_OVERRIDE" + else + base_url=$(curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \ + "${COOLIFY_API_URL}/api/v1/applications/${app_uuid}" | jq -r '.fqdn // empty' | cut -d',' -f1) + if [ -z "$base_url" ]; then + echo "ERROR: Could not resolve application fqdn for health check" + exit 1 + fi + case "$base_url" in + http*) : ;; + *) base_url="https://${base_url}" ;; + esac + fi + + health_url="${base_url%/}${HEALTH_CHECK_PATH}" + echo "Health-checking ${health_url} (timeout ${POLL_TIMEOUT_SECONDS}s)..." + start=$(date +%s) + while :; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$health_url" || true) + if [ "$code" = "200" ]; then + echo "Health check passed." + break + fi + elapsed=$(( $(date +%s) - start )) + if [ "$elapsed" -ge "$POLL_TIMEOUT_SECONDS" ]; then + echo "ERROR: Timed out after ${POLL_TIMEOUT_SECONDS}s waiting for ${health_url} to return 200 (last: ${code})" + exit 1 + fi + sleep "$POLL_INTERVAL_SECONDS" + done + fi + + echo "Done — '${APP_NAME}' (${COOLIFY_ENVIRONMENT}) deployed${HEALTH_CHECK_PATH:+ and healthy}." diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b39c7..2f8ca94 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 composite action to trigger a Coolify application deploy via API, poll it to a terminal status, and optionally HTTP health-check it before returning. Lets callers enforce cross-application deploy ordering (e.g. deploy `tmd-admin-api` before `tmd-admin-web`) from GitHub Actions instead of relying on Coolify's native, unordered git-push auto-deploy — needed because the VPS has a single concurrent build slot, so two independently-triggered, order-dependent builds can otherwise deadlock or race. Supports PR-preview deploys (`pr_number`) with retry-on-not-found to tolerate the race against Coolify's own webhook-driven preview creation. + ## [4.1.0] - 2026-07-29 diff --git a/README.md b/README.md index 6da980d..874b561 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines - [Call Redeployment Webhook](#call-redeployment-webhook) - [Set Image Tags On Server](#set-image-tags-on-server) - [Sync env to server](#sync-env-to-server) + - [Trigger Coolify Deploy](#trigger-coolify-deploy) - [Deploy App Env File](#deploy-app-env-file) - [Deploy Nginx Env Fragment](#deploy-nginx-env-fragment) - [Deploy Partial Docker Compose](#deploy-partial-docker-compose) @@ -90,6 +91,39 @@ App-agnostic reusable: upload an env fragment into **`ENV_POOL_DIR`** on the ser **Caller must:** (1) Build the fragment in a job (app-specific keys), write it to a file (e.g. `fragment.env`), and upload it with `actions/upload-artifact` using the same artifact name. (2) Have a job that calls this workflow with `needs: build-fragment`, `secrets: inherit`, and inputs `sync_env`, `app_name`, `fragment_artifact`. Required **vars** (repo or environment): `SERVER_HOST`, `ENV_POOL_DIR`, `SYNC_ENV_REMOTE_FILENAME_PREFIX_BASE`, and either `HTMT_API_APP_NAME` or `APP_NAME` (app name for fragment path). Required **secrets**: `SERVER_DEPLOY_USERNAME`, `SERVER_DEPLOY_SSH_PRIVATE_KEY`, plus any vars/secrets for the keys included in the fragment (see workflow: `FRAGMENT_KEYS` and the “Build env fragment” step). Trigger **redeploy** after sync so promoted **`sync-env`** files reach **`compose/*.env`** / compose generation. To support a new app or new keys, add the key to `FRAGMENT_KEYS` and to the Build env fragment step env in this repo. +### Trigger Coolify Deploy + +App-agnostic composite action: trigger a Coolify application deploy via API, poll it to a terminal status (`finished`/`failed`/`cancelled-by-user`), and optionally HTTP health-check it before returning. Use this to enforce cross-application deploy ordering (e.g. deploy an API before its dependent web app) instead of relying on Coolify's native, unordered git-push auto-deploy — most useful on single-build-slot Coolify instances where two order-dependent builds triggered independently can otherwise deadlock or race. + +**Action path:** `.github/actions/trigger-coolify-deploy` + +| Input | Required | Description | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `app_name` | Yes | Coolify application name (e.g. `tmd-admin-api`) | +| `coolify_environment` | Yes | Coolify environment name (`production` or `staging`) | +| `coolify_subdomain` | Yes | Coolify subdomain (e.g. `coolify`) | +| `domain` | Yes | Base domain (e.g. `example.com`) | +| `coolify_api_token` | Yes | Coolify API token (composite actions have no `secrets:` block — pass via `with:`) | +| `pr_number` | No | PR number to target a PR-preview deployment instead of the regular one | +| `force` | No | Force a fresh deploy even if the commit is already deployed. Default `false` | +| `poll_timeout_seconds` | No | Max seconds to wait for the deployment (and health check, if any) to finish. Default `900` | +| `poll_interval_seconds` | No | Seconds between deployment/health-check polls. Default `15` | +| `preview_not_found_timeout_seconds`| No | Max seconds to retry a PR-preview deploy trigger while Coolify hasn't created the preview yet. Default `180` | +| `health_check_path` | No | Path (e.g. `/health`) to poll for HTTP 200 after the deployment finishes | +| `health_check_base_url_override` | No | Base URL to health-check instead of the app's own fqdn. Required when both `pr_number` and `health_check_path` are set (Coolify's API doesn't expose a preview's fqdn) | + +```yaml +- name: Trigger tmd-admin-api deploy + uses: BehindTheMusicTree/github-workflows/.github/actions/trigger-coolify-deploy@v4.2.0 + with: + app_name: tmd-admin-api + coolify_environment: staging + coolify_subdomain: ${{ vars.COOLIFY_API_SUBDOMAIN }} + domain: ${{ vars.DOMAIN_NAME }} + coolify_api_token: ${{ secrets.COOLIFY_API_TOKEN }} + health_check_path: /health +``` + ### Deploy App Env File Uploads compose env files to `pool/compose//` on the server. Caller must upload an artifact (e.g. `app-env-files`) containing env files. Use **non-dotfile names** in the artifact (e.g. `env_api`, `env_gtmt_front`) so `upload-artifact` includes them; this workflow renames them to dotfiles (e.g. `.env_api`) before uploading.