Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions .github/actions/run-coolify-oneshot/action.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
# App-agnostic composite action: run a one-off command (e.g. database migrations) in a throwaway container
# on the Coolify host, from a given image, with the environment and network of the app's running container.
# on the Coolify host, from a given image, with the app's current Coolify runtime env and the network of its
# running container. The env comes from the Coolify API, not the running container: a release adding an env var
# migrates before any container carries it.
# Meant as a release phase before trigger-coolify-deploy: the command runs with no healthcheck window to
# race, so a slow migration can't roll the deploy back.
name: Run Coolify one-off command

description: Run a one-off command from an image on the Coolify host, reusing the running app container's env and network
description: Run a one-off command from an image on the Coolify host, with the app's Coolify env and its running container's network

inputs:
app_name:
description: "Coolify application name whose running container supplies the env and network (e.g. gtmt-api)"
description: "Coolify application name supplying the env (Coolify API) and network (running container) (e.g. gtmt-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 able to read env values (composite actions have no secrets: block — pass via with:)"
required: true
image:
description: "Image to run, ideally pinned by digest (e.g. ghcr.io/org/app@sha256:...)"
required: true
Expand Down Expand Up @@ -45,6 +56,9 @@ runs:
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 }}
IMAGE: ${{ inputs.image }}
COMMAND: ${{ inputs.command }}
SERVER_HOST: ${{ inputs.server_host }}
Expand All @@ -55,6 +69,9 @@ runs:
missing=()
[ -z "$APP_NAME" ] && missing+=(app_name)
[ -z "$COOLIFY_ENVIRONMENT" ] && missing+=(coolify_environment)
[ -z "$COOLIFY_SUBDOMAIN" ] && missing+=(coolify_subdomain)
[ -z "$DOMAIN" ] && missing+=(domain)
[ -z "$COOLIFY_API_TOKEN" ] && missing+=(coolify_api_token)
[ -z "$IMAGE" ] && missing+=(image)
[ -z "$COMMAND" ] && missing+=(command)
[ -z "$SERVER_HOST" ] && missing+=(server_host)
Expand All @@ -69,6 +86,45 @@ runs:
exit 1
fi

- name: Fetch app env from Coolify
id: env
shell: bash
env:
APP_NAME: ${{ inputs.app_name }}
COOLIFY_ENVIRONMENT: ${{ inputs.coolify_environment }}
COOLIFY_API_URL: https://${{ inputs.coolify_subdomain }}.${{ inputs.domain }}
COOLIFY_API_TOKEN: ${{ inputs.coolify_api_token }}
run: |
api() { curl -sf -H "Authorization: Bearer ${COOLIFY_API_TOKEN}" "${COOLIFY_API_URL}/api/v1/$1"; }

# /api/v1/applications only exposes environment_id, so resolve the environment's id(s) across projects first.
env_ids="[]"
for project_uuid in $(api projects | jq -r '.[].uuid'); do
ids=$(api "projects/${project_uuid}" | 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
app_uuid=$(api applications | 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 named '${APP_NAME}' in environment '${COOLIFY_ENVIRONMENT}'." >&2
exit 1
fi

# Coolify mirrors every var as an is_preview=true row, and returns a literal var's real_value shell-quoted
# ('...'): its raw value is what the container gets. docker --env-file can't carry a newline.
envs=$(api "applications/${app_uuid}/envs" | jq -c '[.[] | select(.is_preview == false and .is_runtime)
| {key, value: (if .is_literal then .value else .real_value end)}]')
bad=$(jq -r '.[] | select(.value == null or (.value | test("\n"))) | .key' <<<"$envs")
if [ -n "$bad" ]; then
echo "ERROR: env var(s) unreadable or multiline, can't pass via --env-file: ${bad//$'\n'/ }" >&2
exit 1
fi
env_file="${RUNNER_TEMP}/coolify-oneshot.env"
( umask 077; jq -r '.[] | "\(.key)=\(.value)"' <<<"$envs" >"$env_file" )
echo "Fetched $(jq length <<<"$envs") runtime env var(s) of ${APP_NAME} (${app_uuid})"
echo "file=${env_file}" >>"$GITHUB_OUTPUT"

- name: Set up SSH
uses: webfactory/ssh-agent@v0.10.0
with:
Expand All @@ -91,16 +147,22 @@ runs:
SERVER_DEPLOY_USERNAME: ${{ inputs.server_deploy_username }}
TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
LOCAL_ENV_FILE: ${{ steps.env.outputs.file }}
run: |
target="${SERVER_DEPLOY_USERNAME}@${SERVER_HOST}"
env_file=$(ssh "$target" 'umask 077; mktemp')
trap 'rm -f "$LOCAL_ENV_FILE"; ssh "$target" "rm -f $env_file" || true' EXIT
scp -q "$LOCAL_ENV_FILE" "${target}:${env_file}"

# The script is piped over stdin, so the remote shell's own stdin is the script itself: every
# command that could read stdin gets </dev/null.
# ssh joins its arguments with spaces and the remote shell re-splits them, so each one is shell-quoted.
remote_args=$(printf ' %q' "$APP_NAME" "$COOLIFY_ENVIRONMENT" "$IMAGE" "$TIMEOUT_MINUTES" "$RUN_ID" "$COMMAND")
remote_args=$(printf ' %q' "$APP_NAME" "$COOLIFY_ENVIRONMENT" "$IMAGE" "$TIMEOUT_MINUTES" "$RUN_ID" "$COMMAND" \
"$env_file")
ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
"${SERVER_DEPLOY_USERNAME}@${SERVER_HOST}" \
"bash -s --${remote_args}" <<'REMOTE'
"$target" "bash -s --${remote_args}" <<'REMOTE'
set -euo pipefail
app=$1 env_name=$2 image=$3 timeout_minutes=$4 run_id=$5 command=$6
app=$1 env_name=$2 image=$3 timeout_minutes=$4 run_id=$5 command=$6 env_file=$7
name_prefix="${app}-oneshot-${env_name}"
name="${name_prefix}-${run_id}"

Expand All @@ -111,9 +173,8 @@ runs:
echo "ERROR: expected exactly one running ${app} (${env_name}) container, found: ${containers:-none}" >&2
exit 1
fi
echo "Reusing env and network of ${containers}"
echo "Reusing network of ${containers}"

env_file=$(mktemp)
cleanup() {
rm -f "$env_file"
docker rm -f "$name" >/dev/null 2>&1 || true
Expand All @@ -124,7 +185,6 @@ runs:
# serializes runs, so any earlier one-off for this app/env is stale.
docker ps -aq --filter "name=^${name_prefix}-" </dev/null | xargs -r docker rm -f >/dev/null

docker inspect "$containers" --format '{{range .Config.Env}}{{println .}}{{end}}' >"$env_file" </dev/null
network=$(docker inspect "$containers" \
--format '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' </dev/null | head -n1)

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat

## [Unreleased]

### Changed

- **`run-coolify-oneshot` requires `coolify_subdomain`, `domain` and `coolify_api_token`**: the one-off container's env now comes from the app's current Coolify config instead of the running container

### Fixed

- **`run-coolify-oneshot` missed env vars added since the last deploy**: it copied the running container's env, so a release introducing a new variable failed its migrations (e.g. `KeyError: 'CURATION_API_KEY'`)

## [4.5.1] - 2026-10-27


Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,17 @@ App-agnostic composite action: trigger a Coolify application deploy via API, pol

### Run Coolify One-off Command

App-agnostic composite action: run a one-off command (typically database migrations) in a throwaway container on the Coolify host, from a given image, with the env and network of the app's running container. Use it as a release phase _before_ `trigger-coolify-deploy`, so a slow migration is not bound by Coolify's healthcheck window and can't roll the deploy back. The container's output streams into the job log and its exit code becomes the step's result.
App-agnostic composite action: run a one-off command (typically database migrations) in a throwaway container on the Coolify host, from a given image, with the app's current Coolify runtime env (read from the Coolify API, so a release that adds an env var migrates with it) and the network of its running container. Use it as a release phase _before_ `trigger-coolify-deploy`, so a slow migration is not bound by Coolify's healthcheck window and can't roll the deploy back. The container's output streams into the job log and its exit code becomes the step's result.

**Action path:** `.github/actions/run-coolify-oneshot`

| Input | Required | Description |
| -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `app_name` | Yes | Coolify application whose single running container supplies the env and network (e.g. `gtmt-api`) |
| `app_name` | Yes | Coolify application supplying the env (Coolify API) and network (its single running container) (e.g. `gtmt-api`) |
| `coolify_environment` | Yes | Coolify environment name (`production` or `staging`) |
| `coolify_subdomain` | Yes | Coolify API subdomain (e.g. `coolify`) |
| `domain` | Yes | Base domain (e.g. `example.com`) |
| `coolify_api_token` | Yes | Coolify API token allowed to read env values |
| `image` | Yes | Image to run, ideally pinned by digest (`ghcr.io/org/app@sha256:...`) |
| `command` | Yes | Command to run, split on whitespace (no quoting support) |
| `server_host` | Yes | VPS host to SSH into |
Expand All @@ -147,14 +150,17 @@ App-agnostic composite action: run a one-off command (typically database migrati
| `timeout_minutes` | No | Max minutes before the command is stopped and the action fails. Default `60` |
| `discord_webhook_url` | No | Discord webhook to alert when the command fails, times out or the job is cancelled |

Fails fast unless exactly one non-preview container matches `app_name`/`coolify_environment`. Any earlier one-off container for the same app/environment is removed first, so rely on the caller's concurrency group to serialize runs.
Fails fast if any runtime env var is multiline (not expressible in `--env-file`), or unless exactly one non-preview container matches `app_name`/`coolify_environment`. Any earlier one-off container for the same app/environment is removed first, so rely on the caller's concurrency group to serialize runs.

```yaml
- name: Run migrations
uses: BehindTheMusicTree/github-workflows/.github/actions/run-coolify-oneshot@v4.5.0
uses: BehindTheMusicTree/github-workflows/.github/actions/run-coolify-oneshot@v4.6.0
with:
app_name: gtmt-api
coolify_environment: production
coolify_subdomain: ${{ vars.COOLIFY_API_SUBDOMAIN }}
domain: ${{ vars.DOMAIN_NAME }}
coolify_api_token: ${{ secrets.COOLIFY_API_TOKEN }}
image: ghcr.io/behindthemusictree/gtmt-api@${{ needs.build.outputs.digest }}
command: python3 manage.py migrate --no-input
server_host: ${{ vars.SERVER_HOST }}
Expand Down
Loading