From 1cc3465aabd1294abcd277e57b4041895012c549 Mon Sep 17 00:00:00 2001 From: mignot Date: Tue, 27 Oct 2026 18:03:30 +0100 Subject: [PATCH] fix(oneshot): source the one-off env from coolify, not the running container Co-Authored-By: Claude Opus 5.5 --- .../actions/run-coolify-oneshot/action.yml | 80 ++++++++++++++++--- CHANGELOG.md | 8 ++ README.md | 14 +++- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/.github/actions/run-coolify-oneshot/action.yml b/.github/actions/run-coolify-oneshot/action.yml index c75d310..2040ebf 100644 --- a/.github/actions/run-coolify-oneshot/action.yml +++ b/.github/actions/run-coolify-oneshot/action.yml @@ -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 @@ -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 }} @@ -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) @@ -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: @@ -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 &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 @@ -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 - docker inspect "$containers" --format '{{range .Config.Env}}{{println .}}{{end}}' >"$env_file"