name: CI/CD Pipeline on: push: branches: - main - dev - "feature/**" - "codex/**" pull_request: branches: - main - dev workflow_dispatch: inputs: manual_action: description: "Manual action: noop, build_golden_images, cleanup_dev_database, or dokploy_start" required: true default: "noop" dokploy_target: description: "Dokploy dev target: all, web, worker, or beat" required: true default: "all" cleanup_confirm: description: "Type CLEAN_DEV_DB to drop and recreate the dev database as UTF8" required: false default: "" concurrency: group: mostovik-backend-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true env: PYTHON_VERSION: "3.11" REGISTRY_API_URL: "https://registry.dev.nii-ecos.ru/v2/" REGISTRY_HOST: "registry.dev.nii-ecos.ru" REGISTRY_NAMESPACE: "${{ github.repository_owner }}" WEB_IMAGE: "mostovik-backend-web" CELERY_IMAGE: "mostovik-backend-celery" CI_GOLDEN_IMAGE: "mostovik-backend-ci-golden" WEB_GOLDEN_IMAGE: "mostovik-backend-web-golden" CELERY_GOLDEN_IMAGE: "mostovik-backend-celery-golden" GOLDEN_TAG: "py311-uv0.7.2" DOKPLOY_DEV_WEB_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/_EjfuYBpzGJ18uPwBZ3iF" DOKPLOY_DEV_WORKER_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/hltL7K2HmG1a8EIzr-mVA" DOKPLOY_DEV_BEAT_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/RkdykbqU6faErrZBAN9Rv" UV_VERSION: "0.7.2" PIP_DISABLE_PIP_VERSION_CHECK: "1" jobs: manual_action_noop: name: Manual Action Help runs-on: ubuntu-latest timeout-minutes: 1 if: | github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' && (github.event.inputs.manual_action == '' || github.event.inputs.manual_action == 'noop') steps: - name: Show manual action usage run: | set -euo pipefail { 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." } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" build_golden_images: name: Build Golden Images runs-on: ubuntu-latest timeout-minutes: 60 if: | github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' && github.event.inputs.manual_action == 'build_golden_images' steps: - name: Checkout code run: | set -euo pipefail REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" . git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}" - name: Build and push golden images env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail 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_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 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 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}" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" quality: name: Quality Gate runs-on: ubuntu-latest timeout-minutes: 25 if: ${{ github.event_name != 'workflow_dispatch' }} steps: - name: Checkout code run: | set -euo pipefail REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" . git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}" - name: Run quality in golden image env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} SKIP_LINT: ${{ contains(github.event.head_commit.message, '#no_lint') }} SKIP_TEST: ${{ contains(github.event.head_commit.message, '#no_test') }} run: | set -euo pipefail REGISTRY_PATH="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}" CI_GOLDEN_REF="${REGISTRY_PATH}/${CI_GOLDEN_IMAGE}" 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 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 if ! docker buildx imagetools inspect "${CI_GOLDEN_REF}:${GOLDEN_TAG}" >/dev/null 2>&1; then docker buildx prune --all --force || true docker builder prune --all --force || true docker buildx build \ -f ./docker/Dockerfile \ --target ci-deps-base \ --push \ -t "${CI_GOLDEN_REF}:${GOLDEN_TAG}" \ -t "${CI_GOLDEN_REF}:latest" \ . fi docker run --rm \ -v "${PWD}:/workspace" \ -w /workspace \ -e DJANGO_SETTINGS_MODULE=settings.test \ -e SECRET_KEY=test-secret-key-for-ci \ -e SKIP_LINT="${SKIP_LINT}" \ -e SKIP_TEST="${SKIP_TEST}" \ "${CI_GOLDEN_REF}:${GOLDEN_TAG}" \ bash -lc ' set -euo pipefail export PYTHONPATH="/workspace/src:${PYTHONPATH:-}" if [ "${SKIP_LINT}" != "true" ]; then ruff check src ruff format src --check fi if [ "${SKIP_TEST}" != "true" ]; then python -m pytest tests --ignore=tests/test_api_inventory_e2e.py -q python -m pytest tests/test_api_inventory_e2e.py -q fi ' build_push: name: Build and Push Images runs-on: ubuntu-latest timeout-minutes: 45 needs: [quality] if: | github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && needs.quality.result == 'success' && !contains(github.event.head_commit.message, '#no_image') steps: - name: Checkout code run: | set -euo pipefail REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" . git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}" - name: Free Docker build space run: | set -euo pipefail docker system df || true docker buildx prune --all --force || true docker builder prune --all --force || true docker system prune --all --force --volumes || true docker system df || true - name: Build and push branch images env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-branch}}" BRANCH_TAG=$(printf '%s' "${BRANCH}" \ | tr '[:upper:]' '[:lower:]' \ | sed -E 's#[/[:space:]]+#-#g; s#[^a-z0-9_.-]+#-#g; s#^-+##; s#-+$##') BRANCH_TAG="${BRANCH_TAG:-branch}" BRANCH_TAG=$(printf '%.120s' "${BRANCH_TAG}") SHA_SHORT=$(printf '%s' "${GITHUB_SHA}" | cut -c1-7) REGISTRY_PATH="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}" WEB_REF="${REGISTRY_PATH}/${WEB_IMAGE}" CELERY_REF="${REGISTRY_PATH}/${CELERY_IMAGE}" WEB_GOLDEN_REF="${REGISTRY_PATH}/${WEB_GOLDEN_IMAGE}" CELERY_GOLDEN_REF="${REGISTRY_PATH}/${CELERY_GOLDEN_IMAGE}" 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 WEB_TAGS=( -t "${WEB_REF}:${BRANCH_TAG}" -t "${WEB_REF}:${BRANCH_TAG}-${SHA_SHORT}" ) CELERY_TAGS=( -t "${CELERY_REF}:${BRANCH_TAG}" -t "${CELERY_REF}:${BRANCH_TAG}-${SHA_SHORT}" ) if [ "${GITHUB_REF_NAME}" = "main" ]; then WEB_TAGS+=(-t "${WEB_REF}:latest") CELERY_TAGS+=(-t "${CELERY_REF}:latest") fi 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 ensure_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 if docker buildx imagetools inspect "${ref}:${GOLDEN_TAG}" >/dev/null 2>&1; then return 0 fi docker buildx prune --all --force || true docker builder prune --all --force || true docker buildx build \ -f ./docker/Dockerfile \ --target "${target}" \ "${build_args[@]}" \ --push \ -t "${ref}:${GOLDEN_TAG}" \ -t "${ref}:latest" \ . } ensure_golden "web-deps-base" "${WEB_GOLDEN_REF}" ensure_golden "celery-deps-base" "${CELERY_GOLDEN_REF}" docker buildx build \ -f ./docker/Dockerfile \ --target runtime-web \ --build-arg INSTALL_DEV=false \ --build-arg GOLDEN_WEB_IMAGE="${WEB_GOLDEN_REF}:${GOLDEN_TAG}" \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --push \ "${WEB_TAGS[@]}" \ . docker buildx build \ -f ./docker/Dockerfile \ --target runtime-celery \ --build-arg INSTALL_DEV=false \ --build-arg GOLDEN_CELERY_IMAGE="${CELERY_GOLDEN_REF}:${GOLDEN_TAG}" \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --push \ "${CELERY_TAGS[@]}" \ . { echo "Registry API: ${REGISTRY_API_URL}" echo "Golden images:" echo "- ${WEB_GOLDEN_REF}:${GOLDEN_TAG}" echo "- ${CELERY_GOLDEN_REF}:${GOLDEN_TAG}" echo "Pushed images:" echo "- ${WEB_REF}:${BRANCH_TAG}" echo "- ${WEB_REF}:${BRANCH_TAG}-${SHA_SHORT}" echo "- ${CELERY_REF}:${BRANCH_TAG}" echo "- ${CELERY_REF}:${BRANCH_TAG}-${SHA_SHORT}" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" notify: name: Internal Notify runs-on: ubuntu-latest timeout-minutes: 1 needs: [quality, build_push] if: ${{ always() && github.event_name != 'workflow_dispatch' }} steps: - name: Send CI status webhook continue-on-error: true env: CI_NOTIFY_WEBHOOK_URL: ${{ secrets.CI_NOTIFY_WEBHOOK_URL }} CI_NOTIFY_TOKEN: ${{ secrets.CI_NOTIFY_TOKEN }} QUALITY_RESULT: ${{ needs.quality.result }} BUILD_PUSH_RESULT: ${{ needs.build_push.result }} run: | set -euo pipefail if [ -z "${CI_NOTIFY_WEBHOOK_URL:-}" ]; then echo "CI_NOTIFY_WEBHOOK_URL is not set; skip internal notification" exit 0 fi STATUS="success" if [ "${QUALITY_RESULT}" != "success" ]; then STATUS="${QUALITY_RESULT}" elif [ "${BUILD_PUSH_RESULT}" = "failure" ] || [ "${BUILD_PUSH_RESULT}" = "cancelled" ]; then STATUS="${BUILD_PUSH_RESULT}" fi export STATUS PAYLOAD=$(python3 - <<'PY' import json import os payload = { "project": os.environ.get("GITHUB_REPOSITORY"), "workflow": os.environ.get("GITHUB_WORKFLOW"), "status": os.environ.get("STATUS"), "branch": os.environ.get("GITHUB_HEAD_REF") or os.environ.get("GITHUB_REF_NAME"), "sha": os.environ.get("GITHUB_SHA"), "actor": os.environ.get("GITHUB_ACTOR"), "server_url": os.environ.get("GITHUB_SERVER_URL"), "run_id": os.environ.get("GITHUB_RUN_ID"), "results": { "quality": os.environ.get("QUALITY_RESULT"), "build_push": os.environ.get("BUILD_PUSH_RESULT"), }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY ) AUTH_HEADER=() if [ -n "${CI_NOTIFY_TOKEN:-}" ]; then AUTH_HEADER=(-H "Authorization: Bearer ${CI_NOTIFY_TOKEN}") fi curl -fsS \ --connect-timeout 3 \ --max-time 8 \ --retry 1 \ -H "Content-Type: application/json" \ "${AUTH_HEADER[@]}" \ --data "${PAYLOAD}" \ "${CI_NOTIFY_WEBHOOK_URL}" deploy_dev: name: Deploy Dev in Dokploy runs-on: ubuntu-latest timeout-minutes: 5 needs: [build_push] if: | github.event_name == 'push' && github.ref == 'refs/heads/dev' && needs.build_push.result == 'success' && !contains(github.event.head_commit.message, '#no_deploy') steps: - name: Trigger dev Dokploy webhooks env: DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} run: | set -euo pipefail call_webhook() { service_name="$1" webhook_url="$2" target="$3" AUTH_HEADER=() if [ -n "${DOKPLOY_API_TOKEN:-}" ]; then AUTH_HEADER=(-H "Authorization: Bearer ${DOKPLOY_API_TOKEN}") fi PAYLOAD=$(CURRENT_DOKPLOY_TARGET="${target}" 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']}/{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']}/{os.environ['WEB_IMAGE']}:{image_tag}", "worker": celery_image, "beat": celery_image, }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY ) 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 } call_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL}" "web" call_webhook "dev worker" "${DOKPLOY_DEV_WORKER_WEBHOOK_URL}" "worker" call_webhook "dev beat" "${DOKPLOY_DEV_BEAT_WEBHOOK_URL}" "beat" { echo "Dokploy dev deploy triggered." 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" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" dokploy_dev_start: name: Start Dev Containers in Dokploy runs-on: ubuntu-latest timeout-minutes: 5 if: | github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' && github.event.inputs.manual_action == 'dokploy_start' steps: - name: Trigger Dokploy webhooks env: DOKPLOY_TARGET: ${{ github.event.inputs.dokploy_target }} DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} run: | set -euo pipefail TARGET="${DOKPLOY_TARGET:-all}" case "${TARGET}" in all|web|worker|celery|beat) ;; *) echo "dokploy_target must be one of: all, web, worker, beat" >&2 exit 1 ;; esac call_webhook() { service_name="$1" webhook_url="$2" target="$3" if [ -z "${webhook_url}" ]; then echo "Dokploy webhook for ${service_name} is not configured" >&2 return 1 fi AUTH_HEADER=() if [ -n "${DOKPLOY_API_TOKEN:-}" ]; then AUTH_HEADER=(-H "Authorization: Bearer ${DOKPLOY_API_TOKEN}") fi PAYLOAD=$(CURRENT_DOKPLOY_TARGET="${target}" 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']}/{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']}/{os.environ['WEB_IMAGE']}:{image_tag}", "worker": celery_image, "beat": celery_image, }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY ) 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 } triggered=0 if [ "${TARGET}" = "all" ] || [ "${TARGET}" = "web" ]; then call_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL}" "web" triggered=1 fi if [ "${TARGET}" = "all" ] || [ "${TARGET}" = "worker" ] || [ "${TARGET}" = "celery" ]; then call_webhook "dev worker" "${DOKPLOY_DEV_WORKER_WEBHOOK_URL}" "worker" triggered=1 fi if [ "${TARGET}" = "all" ] || [ "${TARGET}" = "beat" ]; then call_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" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" cleanup_dev_database: name: Drop and Recreate Dev Database runs-on: ubuntu-latest timeout-minutes: 15 if: | github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' && github.event.inputs.manual_action == 'cleanup_dev_database' env: POSTGRES_HOST: "10.10.0.114" POSTGRES_PORT: "5432" POSTGRES_DB: "mostovik" POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "postgres" steps: - name: Validate confirmation env: CONFIRM: ${{ github.event.inputs.cleanup_confirm }} run: | set -euo pipefail if [ "${CONFIRM}" != "CLEAN_DEV_DB" ]; then echo "Manual confirmation must be exactly CLEAN_DEV_DB" >&2 exit 1 fi - name: Install PostgreSQL client run: | set -euo pipefail APT_RUNNER=() 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 - name: Drop and recreate UTF8 database run: | set -euo pipefail 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 - name: Trigger Dokploy after database recreate env: DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} run: | set -euo pipefail call_webhook() { service_name="$1" webhook_url="$2" target="$3" AUTH_HEADER=() if [ -n "${DOKPLOY_API_TOKEN:-}" ]; then AUTH_HEADER=(-H "Authorization: Bearer ${DOKPLOY_API_TOKEN}") fi PAYLOAD=$(CURRENT_DOKPLOY_TARGET="${target}" 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']}/{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']}/{os.environ['WEB_IMAGE']}:{image_tag}", "worker": celery_image, "beat": celery_image, }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY ) 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 } wait_for_migrations() { 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 } call_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL}" "web" wait_for_migrations call_webhook "dev worker" "${DOKPLOY_DEV_WORKER_WEBHOOK_URL}" "worker" call_webhook "dev beat" "${DOKPLOY_DEV_BEAT_WEBHOOK_URL}" "beat" - name: Summary run: | set -euo pipefail { 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." } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"