From 7437564f8b723090c56424a1b13eed12950e5e24 Mon Sep 17 00:00:00 2001 From: mignot Date: Tue, 27 Oct 2026 16:57:19 +0100 Subject: [PATCH] feat(actions): add run-coolify-oneshot release-phase action Co-Authored-By: Claude Sonnet 5.5 --- .../actions/run-coolify-oneshot/action.yml | 160 ++++++++++++++++++ CHANGELOG.md | 6 + README.md | 34 ++++ 3 files changed, 200 insertions(+) create mode 100644 .github/actions/run-coolify-oneshot/action.yml diff --git a/.github/actions/run-coolify-oneshot/action.yml b/.github/actions/run-coolify-oneshot/action.yml new file mode 100644 index 0000000..5f72d26 --- /dev/null +++ b/.github/actions/run-coolify-oneshot/action.yml @@ -0,0 +1,160 @@ +# 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. +# 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 + +inputs: + app_name: + description: "Coolify application name whose running container supplies the env and network (e.g. gtmt-api)" + required: true + coolify_environment: + description: "Coolify environment name (production or staging)" + required: true + image: + description: "Image to run, ideally pinned by digest (e.g. ghcr.io/org/app@sha256:...)" + required: true + command: + description: "Command to run in the image, split on whitespace (e.g. python3 manage.py migrate --no-input)" + required: true + server_host: + description: "VPS host to SSH into" + required: true + server_deploy_username: + description: "SSH username for server_host (must be able to run docker)" + required: true + server_deploy_ssh_private_key: + description: "SSH private key for server_host" + required: true + timeout_minutes: + description: "Max minutes the command may run before it is stopped and the action fails" + required: false + default: "60" + discord_webhook_url: + description: "Discord webhook to alert when the command fails, times out or the job is cancelled" + required: false + default: "" + +runs: + using: composite + steps: + - name: Check required inputs + shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + COOLIFY_ENVIRONMENT: ${{ inputs.coolify_environment }} + IMAGE: ${{ inputs.image }} + COMMAND: ${{ inputs.command }} + SERVER_HOST: ${{ inputs.server_host }} + SERVER_DEPLOY_USERNAME: ${{ inputs.server_deploy_username }} + SERVER_DEPLOY_SSH_PRIVATE_KEY: ${{ inputs.server_deploy_ssh_private_key }} + TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} + run: | + missing=() + [ -z "$APP_NAME" ] && missing+=(app_name) + [ -z "$COOLIFY_ENVIRONMENT" ] && missing+=(coolify_environment) + [ -z "$IMAGE" ] && missing+=(image) + [ -z "$COMMAND" ] && missing+=(command) + [ -z "$SERVER_HOST" ] && missing+=(server_host) + [ -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: missing required input(s): ${missing[*]}." >&2 + exit 1 + fi + if ! [[ "$TIMEOUT_MINUTES" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: timeout_minutes must be a positive integer, got '$TIMEOUT_MINUTES'." >&2 + exit 1 + fi + + - name: Set up SSH + uses: webfactory/ssh-agent@v0.10.0 + with: + ssh-private-key: ${{ inputs.server_deploy_ssh_private_key }} + + - name: Add server to known_hosts + shell: bash + run: | + mkdir -p ~/.ssh + ssh-keyscan -H "${{ inputs.server_host }}" >> ~/.ssh/known_hosts + + - name: Run command on server + shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + COOLIFY_ENVIRONMENT: ${{ inputs.coolify_environment }} + IMAGE: ${{ inputs.image }} + COMMAND: ${{ inputs.command }} + SERVER_HOST: ${{ inputs.server_host }} + SERVER_DEPLOY_USERNAME: ${{ inputs.server_deploy_username }} + TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} + RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + run: | + # The script is piped over stdin, so the remote shell's own stdin is the script itself: every + # command that could read stdin gets &2 + exit 1 + fi + echo "Reusing env and network of ${containers}" + + env_file=$(mktemp) + cleanup() { + rm -f "$env_file" + docker rm -f "$name" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + # A cancelled job can't always stop its remote container; the workflow's concurrency group + # serializes runs, so any earlier one-off for this app/env is stale. + docker ps -aq --filter "name=^${name_prefix}-" /dev/null + + docker inspect "$containers" --format '{{range .Config.Env}}{{println .}}{{end}}' >"$env_file" &2 + fi + exit "$status" + REMOTE + + - name: Alert Discord on failure + if: (failure() || cancelled()) && inputs.discord_webhook_url != '' + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.discord_webhook_url }} + APP_NAME: ${{ inputs.app_name }} + COOLIFY_ENVIRONMENT: ${{ inputs.coolify_environment }} + COMMAND: ${{ inputs.command }} + OUTCOME: ${{ job.status == 'cancelled' && 'cancelled' || 'failed' }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + jq -n --arg app "$APP_NAME" --arg env "$COOLIFY_ENVIRONMENT" --arg repo "$GITHUB_REPOSITORY" \ + --arg ref "$GITHUB_REF_NAME" --arg sha "${GITHUB_SHA:0:7}" --arg url "$RUN_URL" \ + --arg outcome "$OUTCOME" --arg cmd "$COMMAND" \ + '{embeds: [{title: "One-off command \($outcome): \($app) (\($env))", url: $url, color: 15158332, + description: "`\($cmd)` — \($repo)@\($ref) (\($sha)) — see the run for details."}]}' \ + | curl -sf -o /dev/null -X POST -H "Content-Type: application/json" --data @- "$DISCORD_WEBHOOK_URL" diff --git a/CHANGELOG.md b/CHANGELOG.md index f09feff..345a9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ## [Unreleased] +### Added + +- **Run Coolify One-off Command**: new `run-coolify-oneshot` composite action + - Runs a command (e.g. `manage.py migrate`) from a given image on the Coolify host, reusing the running app container's env and network, so migrations can run as a release phase outside Coolify's healthcheck window + - Fails fast on missing inputs or when the app doesn't have exactly one running container, enforces `timeout_minutes`, cleans up its container on exit, and can alert Discord on failure + ## [4.4.0] - 2026-10-08 diff --git a/README.md b/README.md index f2c9db5..971b743 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines - [Set Image Tags On Server](#set-image-tags-on-server) - [Sync env to server](#sync-env-to-server) - [Trigger Coolify Deploy](#trigger-coolify-deploy) + - [Run Coolify One-off Command](#run-coolify-one-off-command) - [Deploy App Env File](#deploy-app-env-file) - [Deploy Nginx Env Fragment](#deploy-nginx-env-fragment) - [Deploy Partial Docker Compose](#deploy-partial-docker-compose) @@ -128,6 +129,39 @@ App-agnostic composite action: trigger a Coolify application deploy via API, pol health_check_origin_ip: ${{ vars.SERVER_HOST }} # bypass Cloudflare proxy (Bot Fight Mode 403s CI runners) ``` +### 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. + +**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`) | +| `coolify_environment` | Yes | Coolify environment name (`production` or `staging`) | +| `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 | +| `server_deploy_username` | Yes | SSH username (must be able to run `docker`) | +| `server_deploy_ssh_private_key` | Yes | SSH private key for `server_host` | +| `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. + +```yaml +- name: Run migrations + uses: BehindTheMusicTree/github-workflows/.github/actions/run-coolify-oneshot@v4.5.0 + with: + app_name: gtmt-api + coolify_environment: production + image: ghcr.io/behindthemusictree/gtmt-api@${{ needs.build.outputs.digest }} + command: python3 manage.py migrate --no-input + server_host: ${{ vars.SERVER_HOST }} + server_deploy_username: ${{ secrets.SERVER_DEPLOY_USERNAME }} + server_deploy_ssh_private_key: ${{ secrets.SERVER_DEPLOY_SSH_PRIVATE_KEY }} +``` + ### 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.