Files
prod/.github/workflows/pipeline.yml
2026-01-25 19:24:55 +03:00

466 lines
18 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/'))
# Примечание: Для создания PR из той же ветки нужен PAT (Personal Access Token)
# Создайте PAT с правами repo и добавьте в Secrets как PAT
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
persist-credentials: true
- name: Check if PR already exists
id: check-pr
uses: actions/github-script@v6
with:
github-token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
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:
github-token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
script: |
const prNumber = parseInt('${{ steps.check-pr.outputs.number }}');
const branchName = context.ref.replace('refs/heads/', '');
const commitSha = '${{ github.sha }}';
const author = '${{ github.actor }}';
const updateBody = '## Updated Changes\n\n' +
'PR updated with new commits after successful CI tests.\n\n' +
'- Latest commit: ' + commitSha + '\n' +
'- Branch: `' + branchName + '`\n' +
'- Author: @' + author + '\n\n' +
'## Test Results\n\n' +
'✅ All tests passed successfully!\n\n' +
'Please review the changes and merge when ready.';
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
title: 'Merge ' + branchName + ' into main',
body: updateBody
});
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:
github-token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }}
script: |
const branchName = context.ref.replace('refs/heads/', '');
console.log('📝 Creating PR from ' + branchName + ' to main...');
try {
const commitSha = '${{ github.sha }}';
const author = '${{ github.actor }}';
const prBody = '## Changes\n\n' +
'This PR was automatically created after successful CI tests.\n\n' +
'- Branch: `' + branchName + '`\n' +
'- Commit: `' + commitSha + '`\n' +
'- Author: @' + author + '\n\n' +
'## Test Results\n\n' +
'✅ All tests passed successfully!\n\n' +
'Please review the changes and merge when ready.';
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: prBody,
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', '');
const serverUrl = '${{ github.server_url }}';
const repo = '${{ github.repository }}';
core.setOutput('url', serverUrl + '/' + repo + '/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