Files
mostovik-backend/.gitea/workflows/ci-cd.yml
Aleksandr Meshchriakov dddcb45965
All checks were successful
CI/CD Pipeline / Start Dev Containers in Dokploy (push) Has been skipped
CI/CD Pipeline / Quality Gate (push) Successful in 2m21s
CI/CD Pipeline / Build and Push Images (push) Successful in 1m59s
CI/CD Pipeline / Internal Notify (push) Successful in 1s
CI/CD Pipeline / Cleanup Dev Database (push) Has been skipped
ci: push images with docker and fold dev db cleanup
2026-04-28 11:09:21 +02:00

463 lines
16 KiB
YAML

name: CI/CD Pipeline
on:
push:
branches:
- main
- dev
- "feature/**"
- "codex/**"
pull_request:
branches:
- main
- dev
workflow_dispatch:
inputs:
manual_action:
description: "Manual action: dokploy_start or cleanup_dev_database"
required: true
default: "dokploy_start"
dokploy_target:
description: "Dokploy dev target: all, web, or celery"
required: true
default: "all"
cleanup_confirm:
description: "Type CLEAN_DEV_DB to drop and recreate the dev public schema"
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"
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
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}" \
| 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
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}" \
"${WEB_TAGS[@]}" \
.
docker push "${WEB_REF}:${BRANCH_TAG}"
docker push "${WEB_REF}:${BRANCH_TAG}-${SHA_SHORT}"
if [ "${GITHUB_REF_NAME}" = "main" ]; then
docker push "${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}" \
"${CELERY_TAGS[@]}" \
.
docker push "${CELERY_REF}:${BRANCH_TAG}"
docker push "${CELERY_REF}:${BRANCH_TAG}-${SHA_SHORT}"
if [ "${GITHUB_REF_NAME}" = "main" ]; then
docker push "${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' &&
github.event.inputs.manual_action == 'dokploy_start'
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}"
cleanup_dev_database:
name: Cleanup Dev Database
runs-on: ubuntu-latest
timeout-minutes: 10
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 public schema
run: |
set -euo pipefail
export PGPASSWORD="${POSTGRES_PASSWORD}"
psql \
--set ON_ERROR_STOP=1 \
--host="${POSTGRES_HOST}" \
--port="${POSTGRES_PORT}" \
--username="${POSTGRES_USER}" \
--dbname="${POSTGRES_DB}" \
<<'SQL'
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid();
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO public;
SQL
- name: Summary
run: |
set -euo pipefail
{
echo "Dev database cleanup completed."
echo "Database: ${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"