name: CI/CD Pipeline on: push: branches: - main - dev - "feature/**" - "codex/**" pull_request: branches: - main - dev workflow_dispatch: inputs: dokploy_target: description: "Dokploy dev target: all, web, or celery" required: true default: "all" 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" CRANE_VERSION: "v0.19.0" UV_VERSION: "0.7.2" PIP_DISABLE_PIP_VERSION_CHECK: "1" jobs: 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=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . git checkout "${GITHUB_SHA}" - name: Install Python and uv run: | set -euo pipefail PROJECT_PYTHON_VERSION="$(cat .python-version 2>/dev/null || printf '%s' "${PYTHON_VERSION}")" PYTHON_BIN="$(./scripts/ensure-ci-python.sh "${PROJECT_PYTHON_VERSION}")" printf 'PYTHON_BIN=%s\n' "${PYTHON_BIN}" > .ci-python-env - name: Create virtual environment and install dependencies run: | set -euo pipefail . ./.ci-python-env "${PYTHON_BIN}" -m venv .venv . .venv/bin/activate python -m pip install "uv==${UV_VERSION}" uv sync \ --dev \ --frozen \ --active \ --python "${PYTHON_BIN}" \ --no-managed-python \ --no-python-downloads - name: Run Ruff linting if: ${{ !contains(github.event.head_commit.message, '#no_lint') }} run: | set -euo pipefail . .venv/bin/activate ruff check src - name: Run Ruff formatting check if: ${{ !contains(github.event.head_commit.message, '#no_lint') }} run: | set -euo pipefail . .venv/bin/activate ruff format src --check - name: Run regular pytest suite if: ${{ !contains(github.event.head_commit.message, '#no_test') }} env: DJANGO_SETTINGS_MODULE: settings.test SECRET_KEY: test-secret-key-for-ci run: | set -euo pipefail export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" .venv/bin/python -m pytest tests --ignore=tests/test_api_inventory_e2e.py -q - name: Run API inventory pytest suite if: ${{ !contains(github.event.head_commit.message, '#no_test') }} env: DJANGO_SETTINGS_MODULE: settings.test SECRET_KEY: test-secret-key-for-ci run: | set -euo pipefail export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" .venv/bin/python -m pytest tests/test_api_inventory_e2e.py -q 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=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . git checkout "${GITHUB_SHA}" - name: Build and push branch images env: GITEA_TOKEN: ${{ gitea.token }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail curl -fsSL \ "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \ | tar xz crane chmod +x crane 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}" 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}" \ | ./crane auth login "${REGISTRY_HOST}" \ -u "${REGISTRY_USER}" \ --password-stdin docker build \ -f ./docker/Dockerfile \ --target runtime-web \ --build-arg INSTALL_DEV=false \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ -t "${WEB_IMAGE}:local" . docker save "${WEB_IMAGE}:local" -o /tmp/web.tar ./crane push /tmp/web.tar "${WEB_REF}:${BRANCH_TAG}" ./crane push /tmp/web.tar "${WEB_REF}:${BRANCH_TAG}-${SHA_SHORT}" if [ "${GITHUB_REF_NAME}" = "main" ]; then ./crane push /tmp/web.tar "${WEB_REF}:latest" fi docker build \ -f ./docker/Dockerfile \ --target runtime-celery \ --build-arg INSTALL_DEV=false \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ -t "${CELERY_IMAGE}:local" . docker save "${CELERY_IMAGE}:local" -o /tmp/celery.tar ./crane push /tmp/celery.tar "${CELERY_REF}:${BRANCH_TAG}" ./crane push /tmp/celery.tar "${CELERY_REF}:${BRANCH_TAG}-${SHA_SHORT}" if [ "${GITHUB_REF_NAME}" = "main" ]; then ./crane push /tmp/celery.tar "${CELERY_REF}:latest" fi { echo "Registry API: ${REGISTRY_API_URL}" 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}" 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' }} steps: - name: Trigger Dokploy webhooks env: DOKPLOY_TARGET: ${{ github.event.inputs.dokploy_target }} DOKPLOY_DEV_WEBHOOK_URL: ${{ secrets.DOKPLOY_DEV_WEBHOOK_URL }} DOKPLOY_DEV_WEB_WEBHOOK_URL: ${{ secrets.DOKPLOY_DEV_WEB_WEBHOOK_URL }} DOKPLOY_DEV_CELERY_WEBHOOK_URL: ${{ secrets.DOKPLOY_DEV_CELERY_WEBHOOK_URL }} DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} run: | set -euo pipefail TARGET="${DOKPLOY_TARGET:-all}" case "${TARGET}" in all|web|celery) ;; *) echo "dokploy_target must be one of: all, web, celery" >&2 exit 1 ;; esac call_webhook() { service_name="$1" webhook_url="$2" 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=$(python3 - <<'PY' import json import os payload = { "project": os.environ.get("GITHUB_REPOSITORY"), "branch": os.environ.get("GITHUB_REF_NAME"), "sha": os.environ.get("GITHUB_SHA"), "actor": os.environ.get("GITHUB_ACTOR"), "target": os.environ.get("CURRENT_DOKPLOY_TARGET"), "images": { "web": "registry.dev.nii-ecos.ru/avm/mostovik-backend-web:dev", "celery": "registry.dev.nii-ecos.ru/avm/mostovik-backend-celery:dev", }, } print(json.dumps(payload, ensure_ascii=True, separators=(",", ":"))) PY ) echo "Trigger Dokploy for ${service_name}" curl -fsS \ --connect-timeout 5 \ --max-time 30 \ --retry 2 \ --retry-delay 2 \ -X POST \ -H "Content-Type: application/json" \ "${AUTH_HEADER[@]}" \ --data "${PAYLOAD}" \ "${webhook_url}" } triggered=0 if [ "${TARGET}" = "all" ] && [ -n "${DOKPLOY_DEV_WEBHOOK_URL:-}" ]; then CURRENT_DOKPLOY_TARGET="all" call_webhook "dev stack" "${DOKPLOY_DEV_WEBHOOK_URL}" triggered=1 else if [ "${TARGET}" = "all" ] || [ "${TARGET}" = "web" ]; then CURRENT_DOKPLOY_TARGET="web" call_webhook "dev web" "${DOKPLOY_DEV_WEB_WEBHOOK_URL:-${DOKPLOY_DEV_WEBHOOK_URL:-}}" triggered=1 fi if [ "${TARGET}" = "all" ] || [ "${TARGET}" = "celery" ]; then CURRENT_DOKPLOY_TARGET="celery" call_webhook "dev celery" "${DOKPLOY_DEV_CELERY_WEBHOOK_URL:-${DOKPLOY_DEV_WEBHOOK_URL:-}}" triggered=1 fi 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.dev.nii-ecos.ru/avm/mostovik-backend-web:dev" echo "Celery image: registry.dev.nii-ecos.ru/avm/mostovik-backend-celery:dev" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"