Files
TimeTracker/.github/workflows/ci-comprehensive.yml
T
2025-11-01 11:12:46 +01:00

561 lines
18 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
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: Comprehensive CI Pipeline
# This workflow runs comprehensive tests on pull requests
#
# Test Strategy:
# - Smoke tests (fast, critical) run first
# - Unit, integration, security, and code quality tests run in parallel
# - Full test suite with PostgreSQL runs for PRs to main/master and RC branches
# - Docker build test ensures the image builds correctly
# - Test summary posted as PR comment
#
# All tests must pass before a PR can be merged
#
# Workflow triggers:
# - PRs to RC branches (from develop) - validates code before RC build
# - PRs to main/master (from RC) - validates code before release
on:
pull_request:
branches: [ main, master, 'rc', 'rc/**' ]
env:
PYTHON_VERSION: '3.11'
POSTGRES_VERSION: '16'
jobs:
# ============================================================================
# Smoke Tests - Fast, critical tests that run first
# ============================================================================
smoke-tests:
name: Smoke Tests (Quick)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run smoke tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
pytest -m smoke -v --tb=short --no-cov
- name: Upload smoke test results
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-test-results
path: |
.pytest_cache/
test-results/
# ============================================================================
# Unit Tests - Fast, isolated tests
# ============================================================================
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
test-group: [models, routes, api, utils]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run unit tests - ${{ matrix.test-group }}
env:
PYTHONPATH: ${{ github.workspace }}
run: |
if [ "${{ matrix.test-group }}" == "api" ]; then
pytest -m "api and integration" -v --cov=app --cov-report=xml --cov-report=html
else
pytest -m "unit and ${{ matrix.test-group }}" -v --cov=app --cov-report=xml --cov-report=html
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: unit-${{ matrix.test-group }}
name: unit-${{ matrix.test-group }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results-${{ matrix.test-group }}
path: |
htmlcov/
coverage.xml
# ============================================================================
# Integration Tests - Medium speed, component interaction tests
# ============================================================================
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 15
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test_password
POSTGRES_USER: test_user
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run integration tests
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
run: |
pytest -m integration -v --cov=app --cov-report=xml --cov-report=html
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: integration
name: integration-tests
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: integration-test-results
path: |
htmlcov/
coverage.xml
# ============================================================================
# Security Tests
# ============================================================================
security-tests:
name: Security Tests
runs-on: ubuntu-latest
needs: smoke-tests
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Run security tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
pytest -m security -v --tb=short
- name: Run Safety dependency check
run: |
safety check --file requirements.txt --json > safety-report.json || true
- name: Upload security reports
if: always()
uses: actions/upload-artifact@v4
with:
name: security-reports
path: |
safety-report.json
# ============================================================================
# Code Quality
# ============================================================================
code-quality:
name: Code Quality Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements-test.txt
- name: Run flake8
run: |
flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 app/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
# ============================================================================
# Docker Build Test
# ============================================================================
docker-build:
name: Docker Build Test
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -t timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }} .
- name: Test Docker container startup
run: |
# Start container
CONTAINER_ID=$(docker run -d --name test-container \
-p 8080:8080 \
-e DATABASE_URL="sqlite:////app/test.db" \
-e SECRET_KEY="test-secret-key-for-ci-only-$(openssl rand -hex 32)" \
-e FLASK_ENV="development" \
timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }})
echo "🐳 Started container: $CONTAINER_ID"
# Wait for container to be ready (increased timeout for migrations)
HEALTH_CHECK_PASSED=false
for i in {1..60}; do
# Check if container is still running
if ! docker ps -q --filter "name=test-container" | grep -q .; then
echo "❌ Container exited unexpectedly!"
echo ""
echo "📋 Container logs:"
docker logs test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Try health check
if curl -f http://localhost:8080/_health >/dev/null 2>&1; then
echo "✅ Container health check passed (attempt $i/60)"
HEALTH_CHECK_PASSED=true
break
fi
# Show progress
if [ $((i % 10)) -eq 0 ]; then
echo "⏳ Still waiting for container... ($i/60)"
echo "📊 Last 10 log lines:"
docker logs --tail 10 test-container
else
echo "⏳ Waiting for container... ($i/60)"
fi
sleep 2
done
# Show full logs for debugging
echo ""
echo "📋 Full container logs:"
docker logs test-container
echo ""
# Check if health check passed
if [ "$HEALTH_CHECK_PASSED" = false ]; then
echo "❌ Health check never passed after 120 seconds"
echo ""
echo "🔍 Container inspect:"
docker inspect test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Final health check with detailed output
echo "🔍 Final health check:"
curl -v http://localhost:8080/_health || {
echo "❌ Final health check failed"
echo "📋 Latest logs:"
docker logs --tail 50 test-container
exit 1
}
echo "✅ Docker container test completed successfully"
# Cleanup
docker stop test-container
docker rm test-container
# ============================================================================
# Full Test Suite (runs on all PRs to main/master/rc)
# ============================================================================
full-test-suite:
name: Full Test Suite with PostgreSQL
runs-on: ubuntu-latest
needs: [smoke-tests, unit-tests, integration-tests]
if: github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'master' || startsWith(github.base_ref, 'rc'))
timeout-minutes: 30
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test_password
POSTGRES_USER: test_user
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
pip install -e .
- name: Validate database migrations
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
run: |
echo "🔍 Validating database migrations..."
# Check if there are migration-related changes
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "(app/models/|migrations/)" > /dev/null; then
echo "📋 Migration-related changes detected"
# Initialize fresh database
flask db upgrade
# Test migration rollback
CURRENT_MIGRATION=$(flask db current)
echo "Current migration: $CURRENT_MIGRATION"
if [ -n "$CURRENT_MIGRATION" ] && [ "$CURRENT_MIGRATION" != "None" ]; then
echo "Testing migration operations..."
flask db upgrade head
echo "✅ Migration validation passed"
fi
# Test with sample data
python -c "
from app import create_app, db
from app.models.user import User
from app.models.project import Project
from app.models.client import Client
app = create_app()
with app.app_context():
user = User(username='test_user', role='user')
db.session.add(user)
db.session.commit()
client = Client(name='Test Client', description='Test client')
db.session.add(client)
db.session.commit()
project = Project(name='Test Project', client_id=client.id, description='Test project')
db.session.add(project)
db.session.commit()
print('✅ Sample data created and validated successfully')
"
else
echo "️ No migration-related changes detected"
fi
- name: Run full test suite
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
run: |
pytest -v --cov=app --cov-report=xml --cov-report=html --cov-report=term \
--junitxml=junit.xml
- name: Upload full coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: full-suite
name: full-test-suite
- name: Upload full test results
if: always()
uses: actions/upload-artifact@v4
with:
name: full-test-results
path: |
htmlcov/
coverage.xml
junit.xml
- name: Publish full test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: junit.xml
check_name: Full Test Suite Results
# ============================================================================
# Test Summary and PR Comment
# ============================================================================
test-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: [smoke-tests, unit-tests, integration-tests, security-tests, code-quality, docker-build, full-test-suite]
if: always() && github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Generate test summary
uses: actions/github-script@v7
with:
script: |
const jobs = [
{ name: 'Smoke Tests', result: '${{ needs.smoke-tests.result }}' },
{ name: 'Unit Tests', result: '${{ needs.unit-tests.result }}' },
{ name: 'Integration Tests', result: '${{ needs.integration-tests.result }}' },
{ name: 'Security Tests', result: '${{ needs.security-tests.result }}' },
{ name: 'Code Quality', result: '${{ needs.code-quality.result }}' },
{ name: 'Docker Build', result: '${{ needs.docker-build.result }}' },
{ name: 'Full Test Suite', result: '${{ needs.full-test-suite.result }}' }
];
const passed = jobs.filter(j => j.result === 'success').length;
const failed = jobs.filter(j => j.result === 'failure').length;
const total = jobs.length;
let emoji = failed === 0 ? '✅' : '❌';
let status = failed === 0 ? 'All tests passed!' : `${failed} test suite(s) failed`;
let commentBody = `## ${emoji} CI Test Results\n\n`;
commentBody += `**Overall Status:** ${status}\n\n`;
commentBody += `**Test Results:** ${passed}/${total} passed\n\n`;
commentBody += `### Test Suites:\n\n`;
for (const job of jobs) {
const icon = job.result === 'success' ? '✅' :
job.result === 'failure' ? '❌' :
job.result === 'skipped' ? '⏭️' : '⏸️';
commentBody += `- ${icon} ${job.name}: **${job.result}**\n`;
}
commentBody += `\n---\n`;
commentBody += `*Commit: ${context.sha.substring(0, 7)}*\n`;
commentBody += `*Workflow: [${context.runId}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('CI Test Results')
);
if (botComment) {
await github.rest.issues.updateComment({
comment_id: botComment.id,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody,
});
}