name: CI & CD pipeline on: push: branches: [ main, 'develop', 'dev-*', 'feature/**' ] pull_request: branches: [ main, develop ] workflow_dispatch: inputs: action: description: 'Action to perform' required: true type: choice options: - rollback rollback_to_commit: description: 'Commit hash to rollback to (optional, uses last deploy if empty)' required: false type: string jobs: test: runs-on: ubuntu-latest name: Test & Code Quality steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r tests/infra/requirements-test.txt pip install flake8 black isort mypy || true - name: Code formatting check (Black) run: | echo "🔍 Checking code formatting with Black..." black --check . || (echo "❌ Code formatting issues found. Run 'black .' to fix." && exit 1) - name: Import sorting check (isort) run: | echo "🔍 Checking import sorting with isort..." isort --check-only . || (echo "❌ Import sorting issues found. Run 'isort .' to fix." && exit 1) - name: Linting (flake8) - Critical errors run: | echo "🔍 Running flake8 linter (critical errors only)..." flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - name: Linting (flake8) - Warnings run: | echo "🔍 Running flake8 linter (warnings)..." flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true continue-on-error: true - name: Run infrastructure tests run: | python -m pytest tests/infra/ -v --tb=short - name: Validate Prometheus config run: | python -m pytest tests/infra/test_prometheus_config.py -v - name: Check for merge conflicts if: github.event_name == 'pull_request' run: | echo "🔍 Checking for merge conflicts..." git fetch origin main if ! git merge --no-commit --no-ff origin/main; then echo "❌ Merge conflicts detected!" git merge --abort || true exit 1 fi git merge --abort || true echo "✅ No merge conflicts detected" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: | .pytest_cache/ htmlcov/ retention-days: 7 - name: Send test failure notification if: failure() uses: appleboy/telegram-action@v1.0.0 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} message: | ❌ CI Tests Failed 📦 Repository: prod 🌿 Branch: ${{ github.ref_name }} 📝 Commit: ${{ github.sha }} 👤 Author: ${{ github.actor }} ❌ Tests failed! Deployment blocked. Please fix the issues and try again. 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} continue-on-error: true create-pr: runs-on: ubuntu-latest name: Create Pull Request needs: test if: | github.event_name == 'push' && needs.test.result == 'success' && github.ref_name != 'main' && github.ref_name != 'develop' && (startsWith(github.ref_name, 'dev-') || startsWith(github.ref_name, 'feature/')) permissions: contents: write pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Check if PR already exists id: check-pr uses: actions/github-script@v6 with: script: | const branchName = context.ref.replace('refs/heads/', ''); const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, head: `${context.repo.owner}:${branchName}`, base: 'main', state: 'open' }); if (prs.length > 0) { core.setOutput('exists', 'true'); core.setOutput('number', prs[0].number); core.setOutput('url', prs[0].html_url); } else { core.setOutput('exists', 'false'); } - name: Update existing PR if: steps.check-pr.outputs.exists == 'true' uses: actions/github-script@v6 with: script: | const prNumber = parseInt('${{ steps.check-pr.outputs.number }}'); const branchName = context.ref.replace('refs/heads/', ''); await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, title: `Merge ${branchName} into main`, body: `## Updated Changes\n\nPR updated with new commits after successful CI tests.\n\n- Latest commit: ${{ github.sha }}\n- Branch: \`${branchName}\`\n- Author: @${{ github.actor }}\n\n## Test Results\n\n✅ All tests passed successfully!\n\nPlease review the changes and merge when ready.` }); console.log(`✅ PR #${prNumber} updated successfully`); - name: Create Pull Request if: steps.check-pr.outputs.exists == 'false' id: create-pr uses: actions/github-script@v6 with: script: | const branchName = context.ref.replace('refs/heads/', ''); console.log(`📝 Creating PR from ${branchName} to main...`); try { const { data: pr } = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, title: `Merge ${branchName} into main`, head: branchName, base: 'main', body: `## Changes This PR was automatically created after successful CI tests. - Branch: \`${branchName}\` - Commit: \`${{ github.sha }}\` - Author: @${{ github.actor }} ## Test Results ✅ All tests passed successfully! Please review the changes and merge when ready.`, draft: false }); // Добавляем labels try { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, labels: ['automated', 'ready-for-review'] }); } catch (labelError) { console.log(`⚠️ Could not add labels: ${labelError.message}`); } core.setOutput('number', pr.number.toString()); core.setOutput('url', pr.html_url); console.log(`✅ PR #${pr.number} created successfully: ${pr.html_url}`); } catch (error) { console.error(`❌ Failed to create PR: ${error.message}`); if (error.status === 422) { console.log('⚠️ PR might already exist or branch is up to date with base'); // Пробуем найти существующий PR const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, head: `${context.repo.owner}:${branchName}`, base: 'main', state: 'open' }); if (prs.length > 0) { const pr = prs[0]; core.setOutput('number', pr.number.toString()); core.setOutput('url', pr.html_url); console.log(`✅ Found existing PR #${pr.number}: ${pr.html_url}`); } else { core.setOutput('number', ''); core.setOutput('url', `${{ github.server_url }}/${{ github.repository }}/pulls`); throw error; } } else { throw error; } } - name: Send PR notification - PR exists if: steps.check-pr.outputs.exists == 'true' uses: appleboy/telegram-action@v1.0.0 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} message: | ℹ️ Pull Request Updated 📦 Repository: prod 🌿 Branch: ${{ github.ref_name }} → main 📝 Commit: ${{ github.sha }} 👤 Author: ${{ github.actor }} ✅ All tests passed! PR #${{ steps.check-pr.outputs.number }} already exists and has been updated. 🔗 View PR: ${{ steps.check-pr.outputs.url }} continue-on-error: true - name: Send PR notification - PR created if: steps.check-pr.outputs.exists == 'false' && steps.create-pr.outcome == 'success' uses: appleboy/telegram-action@v1.0.0 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} message: | 📝 Pull Request Created 📦 Repository: prod 🌿 Branch: ${{ github.ref_name }} → main 📝 Commit: ${{ github.sha }} 👤 Author: ${{ github.actor }} ${{ steps.create-pr.outputs.number != '' && format('✅ All tests passed! Pull request #{0} has been created and is ready for review.', steps.create-pr.outputs.number) || '✅ All tests passed! Pull request has been created and is ready for review.' }} ${{ steps.create-pr.outputs.url != '' && format('🔗 View PR: {0}', steps.create-pr.outputs.url) || format('🔗 View PRs: {0}/{1}/pulls', github.server_url, github.repository) }} continue-on-error: true rollback: runs-on: ubuntu-latest name: Manual Rollback if: | github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'rollback' environment: name: production steps: - name: Checkout code uses: actions/checkout@v4 with: ref: main - name: Manual Rollback uses: appleboy/ssh-action@v1.0.0 with: host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} script: | set -e echo "🔄 Starting manual rollback..." cd /home/prod DEPLOY_HISTORY="/home/prod/.deploy_history.txt" DEPLOY_HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}" # Определяем коммит для отката if [ -n "${{ github.event.inputs.rollback_to_commit }}" ]; then ROLLBACK_COMMIT="${{ github.event.inputs.rollback_to_commit }}" echo "📝 Using specified commit: $ROLLBACK_COMMIT" else # Используем последний успешный деплой из истории ROLLBACK_COMMIT=$(grep "|success" "$DEPLOY_HISTORY" 2>/dev/null | tail -1 | cut -d'|' -f2 || echo "") # Если нет в истории, используем сохраненный коммит if [ -z "$ROLLBACK_COMMIT" ]; then if [ -f "/tmp/last_deploy_commit.txt" ]; then ROLLBACK_COMMIT=$(cat /tmp/last_deploy_commit.txt) echo "📝 Using saved commit from /tmp/last_deploy_commit.txt: $ROLLBACK_COMMIT" else echo "❌ No commit specified and no previous deploy found!" exit 1 fi else echo "📝 Using last successful deploy from history: $ROLLBACK_COMMIT" fi fi # Проверяем что коммит существует git fetch origin main if ! git rev-parse --verify "$ROLLBACK_COMMIT" > /dev/null 2>&1; then echo "❌ Commit $ROLLBACK_COMMIT not found!" exit 1 fi # Откатываем код echo "🔄 Rolling back to commit: $ROLLBACK_COMMIT" # Исправляем права на файлы sudo chown -R deploy:deploy /home/prod || true git reset --hard "$ROLLBACK_COMMIT" # Устанавливаем правильные права после отката sudo chown -R deploy:deploy /home/prod || true echo "✅ Code rolled back to: $ROLLBACK_COMMIT" # Пересобираем все контейнеры с обновлением базовых образов и кешированием echo "🔨 Rebuilding all containers with --pull (updating base images, using cache)..." docker-compose down || true docker-compose build --pull docker-compose up -d echo "✅ Containers rebuilt and started" # Ждем запуска сервисов echo "⏳ Waiting for services to start (45 seconds)..." sleep 45 # Health checks с экспоненциальным retry check_health() { local service=$1 local url=$2 local attempt=1 local delays=(5 15 45) local max_attempts=${#delays[@]} echo "🔍 Checking $service health..." while [ $attempt -le $max_attempts ]; do if curl -f -s --max-time 10 "$url" > /dev/null 2>&1; then echo "✅ $service is healthy (attempt $attempt/$max_attempts)" return 0 else if [ $attempt -lt $max_attempts ]; then delay=${delays[$((attempt - 1))]} echo "⏳ $service not ready yet (attempt $attempt/$max_attempts), waiting ${delay} seconds..." sleep $delay else echo "❌ $service health check failed after $max_attempts attempts" return 1 fi fi attempt=$((attempt + 1)) done return 1 } HEALTH_CHECK_FAILED=0 check_health "Prometheus" "http://localhost:9090/-/healthy" || HEALTH_CHECK_FAILED=1 check_health "Grafana" "http://localhost:3000/api/health" || HEALTH_CHECK_FAILED=1 check_health "Telegram Bot" "http://localhost:8080/health" || HEALTH_CHECK_FAILED=1 check_health "AnonBot" "http://localhost:8081/health" || HEALTH_CHECK_FAILED=1 if [ $HEALTH_CHECK_FAILED -eq 1 ]; then echo "⚠️ Some health checks failed, but rollback completed" else echo "✅ All health checks passed after rollback!" fi # Обновляем историю TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" 2>/dev/null || echo "Manual rollback") COMMIT_AUTHOR="${{ github.actor }}" echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|rolled_back" >> "$DEPLOY_HISTORY" # Оставляем только последние N записей tail -n "$DEPLOY_HISTORY_SIZE" "$DEPLOY_HISTORY" > "${DEPLOY_HISTORY}.tmp" && mv "${DEPLOY_HISTORY}.tmp" "$DEPLOY_HISTORY" echo "✅ Rollback completed successfully" - name: Send rollback notification if: always() uses: appleboy/telegram-action@v1.0.0 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} message: | 🔄 Manual Rollback: ${{ job.status }} 📦 Repository: prod 🌿 Branch: main 📝 Commit: ${{ github.event.inputs.rollback_to_commit || 'Previous successful deploy' }} 👤 Author: ${{ github.actor }} ${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to specified version.' || '❌ Rollback failed! Check logs for details.' }} 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} continue-on-error: true