Files
TimeTracker/.github/workflows/ci-comprehensive.yml
T
Dries Peeters 81532fcd55 ci: implement RC branch-based release workflow
Refactor GitHub Actions workflows to support a release candidate (RC)
branch workflow instead of direct develop->main flow.

Changes:
- cd-development.yml: Trigger on PRs to RC branches (not push to develop)
  * Updated summary to show PR context (source/target branches)
  * Build development images when code is promoted to RC

- cd-release.yml: Trigger on PRs from RC to main/master
  * Added path filters for code changes only
  * Enables release validation before merge to main

- ci-comprehensive.yml: Run tests on PRs to RC branches
  * Full test suite now runs for PRs to main, master, and RC branches
  * Ensures code quality before RC promotion

New workflow:
  develop (push) -> no actions
  develop -> rc (PR) -> run tests + development build
  rc -> main (PR) -> run tests + release build

Supports both single RC branch (rc) and versioned RC branches (rc/*)

Breaking change: Development builds no longer trigger on push to develop.
They now require a PR to an RC branch.
2025-10-22 10:17:15 +02:00

511 lines
17 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: |
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' }}
# Wait for container to be ready
for i in {1..30}; do
if curl -f http://localhost:8080/_health >/dev/null 2>&1; then
echo "✅ Container health check passed"
break
fi
echo "⏳ Waiting for container... ($i/30)"
sleep 2
done
# Show logs
docker logs test-container
# Final health check
curl -f http://localhost:8080/_health || exit 1
# 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,
});
}