#!/usr/bin/env bash set -euo pipefail MANUAL_ACTION="${MANUAL_ACTION:-noop}" DOKPLOY_TARGET="${DOKPLOY_TARGET:-all}" CLEANUP_CONFIRM="${CLEANUP_CONFIRM:-}" SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/stdout}" require_dev_branch() { if [ "${GITHUB_REF:-}" != "refs/heads/dev" ]; then echo "Manual dev actions are allowed only from dev branch, got ${GITHUB_REF:-unknown}" >&2 exit 1 fi } write_usage() { { echo "No manual action selected." echo "For dev DB cleanup run with:" echo "- manual_action=cleanup_dev_database" echo "- cleanup_confirm=CLEAN_DEV_DB" echo "This drops and recreates the dev database, then triggers Dokploy web/worker/beat." echo "For base image refresh run with manual_action=build_golden_images." echo "For Dokploy start run with manual_action=dokploy_start and dokploy_target=all|web|worker|beat." } >>"${SUMMARY_FILE}" } registry_login() { local registry_user registry_password registry_user="${REGISTRY_USER:-${GITHUB_ACTOR}}" registry_password="${REGISTRY_PASSWORD:-${GITEA_TOKEN:-}}" unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY export NO_PROXY="${NO_PROXY:-},${REGISTRY_HOST}" export no_proxy="${no_proxy:-},${REGISTRY_HOST}" if [ -z "${registry_password}" ]; then echo "REGISTRY_TOKEN secret is not set and GITEA_TOKEN fallback is empty" >&2 exit 1 fi echo "${registry_password}" \ | docker login "${REGISTRY_HOST}" \ -u "${registry_user}" \ --password-stdin } ensure_buildx() { if ! docker buildx inspect mostovik-builder >/dev/null 2>&1; then docker buildx create --name mostovik-builder --use else docker buildx use mostovik-builder fi docker buildx inspect --bootstrap } build_golden_images() { local registry_path ci_golden_ref web_golden_ref celery_golden_ref registry_path="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}" ci_golden_ref="${registry_path}/${CI_GOLDEN_IMAGE}" web_golden_ref="${registry_path}/${WEB_GOLDEN_IMAGE}" celery_golden_ref="${registry_path}/${CELERY_GOLDEN_IMAGE}" registry_login ensure_buildx docker buildx prune --all --force || true docker builder prune --all --force || true build_golden() { local target="$1" local ref="$2" local build_args=() if [ "${target}" = "celery-deps-base" ]; then build_args+=(--build-arg "GOLDEN_WEB_IMAGE=${web_golden_ref}:${GOLDEN_TAG}") fi docker buildx build \ -f ./docker/Dockerfile \ --target "${target}" \ "${build_args[@]}" \ --push \ -t "${ref}:${GOLDEN_TAG}" \ -t "${ref}:latest" \ . } build_golden "ci-deps-base" "${ci_golden_ref}" build_golden "web-deps-base" "${web_golden_ref}" build_golden "celery-deps-base" "${celery_golden_ref}" { echo "Golden images pushed:" echo "- ${ci_golden_ref}:${GOLDEN_TAG}" echo "- ${web_golden_ref}:${GOLDEN_TAG}" echo "- ${celery_golden_ref}:${GOLDEN_TAG}" } >>"${SUMMARY_FILE}" } dokploy_payload() { CURRENT_DOKPLOY_TARGET="$1" python3 - <<'PY' import json import os repository = os.environ.get("GITHUB_REPOSITORY", "") repository_name = repository.rsplit("/", 1)[-1] branch = os.environ.get("GITHUB_REF_NAME") or "dev" sha = os.environ.get("GITHUB_SHA") or "" server_url = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") repository_url = f"{server_url}/{repository}" if server_url and repository else "" image_tag = f"{branch.replace('/', '-')}-{sha[:7]}" if branch and sha else branch or "dev" celery_image = ( f"{os.environ['REGISTRY_HOST']}/{os.environ['REGISTRY_NAMESPACE']}/" f"{os.environ['CELERY_IMAGE']}:{image_tag}" ) payload = { "ref": f"refs/heads/{branch}", "after": sha, "checkout_sha": sha, "repository": { "name": repository_name, "full_name": repository, "html_url": repository_url, "clone_url": f"{repository_url}.git" if repository_url else "", }, "sender": {"login": os.environ.get("GITHUB_ACTOR")}, "pusher": {"name": os.environ.get("GITHUB_ACTOR")}, "head_commit": { "id": sha, "message": f"CI deploy {os.environ.get('CURRENT_DOKPLOY_TARGET')}", "url": f"{repository_url}/commit/{sha}" if repository_url and sha else "", }, "commits": [ { "id": sha, "message": f"CI deploy {os.environ.get('CURRENT_DOKPLOY_TARGET')}", "url": f"{repository_url}/commit/{sha}" if repository_url and sha else "", } ], "project": repository, "branch": branch, "sha": sha, "actor": os.environ.get("GITHUB_ACTOR"), "target": os.environ.get("CURRENT_DOKPLOY_TARGET"), "image_tag": image_tag, "images": { "web": ( f"{os.environ['REGISTRY_HOST']}/{os.environ['REGISTRY_NAMESPACE']}/" f"{os.environ['WEB_IMAGE']}:{image_tag}" ), "worker": celery_image, "beat": celery_image, }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY } call_dokploy_webhook() { local service_name="$1" local webhook_url="$2" local target="$3" local auth_header=() local payload response if [ -z "${webhook_url}" ]; then echo "Dokploy webhook for ${service_name} is not configured" >&2 exit 1 fi if [ -n "${DOKPLOY_API_TOKEN:-}" ]; then auth_header=(-H "Authorization: Bearer ${DOKPLOY_API_TOKEN}") fi payload="$(dokploy_payload "${target}")" echo "Trigger Dokploy for ${service_name}" response="$(curl -fsS \ --connect-timeout 5 \ --max-time 30 \ --retry 2 \ --retry-delay 2 \ -X POST \ -H "Content-Type: application/json" \ -H "X-Gitea-Event: push" \ -H "X-Gogs-Event: push" \ -H "X-GitHub-Event: push" \ "${auth_header[@]}" \ --data "${payload}" \ "${webhook_url}")" printf '%s\n' "${response}" if printf '%s' "${response}" | grep -qi "Branch Not Match"; then echo "Dokploy rejected ${service_name}: branch did not match" >&2 exit 1 fi } trigger_dokploy() { local target="$1" local triggered=0 case "${target}" in all | web | worker | celery | beat) ;; *) echo "dokploy_target must be one of: all, web, worker, beat" >&2 exit 1 ;; esac if [ "${target}" = "all" ] || [ "${target}" = "web" ]; then call_dokploy_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL}" "web" triggered=1 fi if [ "${target}" = "all" ] || [ "${target}" = "worker" ] || [ "${target}" = "celery" ]; then call_dokploy_webhook "dev worker" "${DOKPLOY_DEV_WORKER_WEBHOOK_URL}" "worker" triggered=1 fi if [ "${target}" = "all" ] || [ "${target}" = "beat" ]; then call_dokploy_webhook "dev beat" "${DOKPLOY_DEV_BEAT_WEBHOOK_URL}" "beat" triggered=1 fi if [ "${triggered}" -ne 1 ]; then echo "No Dokploy webhook was triggered" >&2 exit 1 fi { echo "Dokploy dev trigger completed." echo "Target: ${target}" echo "Registry API: ${REGISTRY_API_URL}" echo "Web image: ${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${WEB_IMAGE}:dev" echo "Worker image: ${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${CELERY_IMAGE}:dev" echo "Beat image: ${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${CELERY_IMAGE}:dev" } >>"${SUMMARY_FILE}" } install_postgres_client() { local apt_runner=() if command -v psql >/dev/null 2>&1; then return 0 fi if [ "$(id -u)" -ne 0 ]; then apt_runner=(sudo) fi export DEBIAN_FRONTEND=noninteractive "${apt_runner[@]}" apt-get update "${apt_runner[@]}" apt-get install -y postgresql-client } drop_and_recreate_database() { local db_exists db_encoding export PGPASSWORD="${POSTGRES_PASSWORD}" db_exists="$(psql \ --set ON_ERROR_STOP=1 \ --host="${POSTGRES_HOST}" \ --port="${POSTGRES_PORT}" \ --username="${POSTGRES_USER}" \ --dbname=postgres \ --tuples-only \ --no-align \ --set=dbname="${POSTGRES_DB}" \ <<'SQL' SELECT 1 FROM pg_database WHERE datname = :'dbname'; SQL )" if [ "${db_exists:-}" = "1" ]; then echo "Closing active connections to ${POSTGRES_DB}" psql \ --set ON_ERROR_STOP=1 \ --host="${POSTGRES_HOST}" \ --port="${POSTGRES_PORT}" \ --username="${POSTGRES_USER}" \ --dbname=postgres \ --set=dbname="${POSTGRES_DB}" \ <<'SQL' ALTER DATABASE :"dbname" WITH ALLOW_CONNECTIONS false; SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'dbname' AND pid <> pg_backend_pid(); SQL fi echo "Dropping and recreating ${POSTGRES_DB} with UTF8 encoding" psql \ --set ON_ERROR_STOP=1 \ --host="${POSTGRES_HOST}" \ --port="${POSTGRES_PORT}" \ --username="${POSTGRES_USER}" \ --dbname=postgres \ --set=dbname="${POSTGRES_DB}" \ --set=dbuser="${POSTGRES_USER}" \ <<'SQL' DROP DATABASE IF EXISTS :"dbname"; CREATE DATABASE :"dbname" WITH OWNER :"dbuser" TEMPLATE template0 ENCODING 'UTF8'; SQL db_encoding="$(psql \ --set ON_ERROR_STOP=1 \ --host="${POSTGRES_HOST}" \ --port="${POSTGRES_PORT}" \ --username="${POSTGRES_USER}" \ --dbname=postgres \ --tuples-only \ --no-align \ --set=dbname="${POSTGRES_DB}" \ <<'SQL' SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = :'dbname'; SQL )" printf '%s\n' "${db_encoding}" | tee /tmp/mostovik-db-encoding if [ "${db_encoding}" != "UTF8" ]; then echo "Database ${POSTGRES_DB} is not UTF8 after cleanup" >&2 exit 1 fi } wait_for_migrations() { local schema_state export PGPASSWORD="${POSTGRES_PASSWORD}" for attempt in $(seq 1 60); do schema_state="$(psql \ --set ON_ERROR_STOP=1 \ --host="${POSTGRES_HOST}" \ --port="${POSTGRES_PORT}" \ --username="${POSTGRES_USER}" \ --dbname="${POSTGRES_DB}" \ --tuples-only \ --no-align \ <<'SQL' SELECT CASE WHEN to_regclass('public.django_migrations') IS NOT NULL AND to_regclass('public.core_backgroundjob') IS NOT NULL THEN 'ready' ELSE 'waiting' END; SQL )" if [ "${schema_state}" = "ready" ]; then echo "Database schema is ready after web deploy" return 0 fi echo "Waiting for web migrations (${attempt}/60)" sleep 5 done echo "Database schema was not ready after web deploy" >&2 exit 1 } cleanup_dev_database() { if [ "${CLEANUP_CONFIRM}" != "CLEAN_DEV_DB" ]; then echo "Manual confirmation must be exactly CLEAN_DEV_DB" >&2 exit 1 fi install_postgres_client drop_and_recreate_database call_dokploy_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL}" "web" wait_for_migrations call_dokploy_webhook "dev worker" "${DOKPLOY_DEV_WORKER_WEBHOOK_URL}" "worker" call_dokploy_webhook "dev beat" "${DOKPLOY_DEV_BEAT_WEBHOOK_URL}" "beat" { echo "Dev database was dropped and recreated." echo "Database: ${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" echo "Encoding: UTF8" echo "Dokploy web/worker/beat deploy was triggered." } >>"${SUMMARY_FILE}" } require_dev_branch case "${MANUAL_ACTION}" in "" | noop) write_usage ;; build_golden_images) build_golden_images ;; dokploy_start) trigger_dokploy "${DOKPLOY_TARGET}" ;; cleanup_dev_database) cleanup_dev_database ;; *) echo "Unknown manual_action: ${MANUAL_ACTION}" >&2 exit 1 ;; esac