Files
TimeTracker/.github/workflows/cd-release.yml
2025-10-10 14:43:02 +02:00

557 lines
19 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: CD - Release Build
on:
push:
branches: [ main, master ]
tags: [ 'v*.*.*' ]
release:
types: [ published ]
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.2.3) - must match version in setup.py'
required: true
type: string
skip_tests:
description: 'Skip tests (not recommended)'
required: false
type: boolean
default: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PYTHON_VERSION: '3.11'
jobs:
# ============================================================================
# Full Test Suite
# ============================================================================
full-test-suite:
name: Full Test Suite
runs-on: ubuntu-latest
if: github.event.inputs.skip_tests != 'true'
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
with:
fetch-depth: 0
- 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 HEAD~1 2>/dev/null | 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 complete 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 coverage reports
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: release
name: release-tests
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-release
path: |
htmlcov/
coverage.xml
junit.xml
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: junit.xml
check_name: Release Test Results
# ============================================================================
# Security Audit
# ============================================================================
security-audit:
name: Security Audit
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 security tools
run: |
pip install safety
- name: Run Safety
run: |
safety check --file requirements.txt --json > safety-report.json || true
safety check --file requirements.txt || true
- name: Upload security reports
if: always()
uses: actions/upload-artifact@v4
with:
name: security-audit-reports
path: |
safety-report.json
# ============================================================================
# Determine Version
# ============================================================================
determine-version:
name: Determine Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from setup.py
id: extract_version
run: |
# Extract version from setup.py
VERSION=$(grep -oP "version='\K[^']+" setup.py)
if [ -z "$VERSION" ]; then
echo "❌ ERROR: Could not extract version from setup.py"
exit 1
fi
echo "extracted_version=$VERSION" >> $GITHUB_OUTPUT
echo "📝 Extracted version from setup.py: $VERSION"
- name: Determine version and validate
id: version
run: |
SETUP_VERSION="${{ steps.extract_version.outputs.extracted_version }}"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# For manual triggers, use the input version
VERSION="${{ github.event.inputs.version }}"
IS_PRERELEASE="false"
elif [[ "${{ github.event_name }}" == "release" ]]; then
# For release events, use the release tag
VERSION="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
# For tag pushes, use the tag name
VERSION=${GITHUB_REF#refs/tags/}
IS_PRERELEASE="false"
else
# For push to main/master, use setup.py version
VERSION="v${SETUP_VERSION}"
IS_PRERELEASE="false"
fi
# Ensure version starts with 'v'
if [[ ! $VERSION =~ ^v ]]; then
VERSION="v${VERSION}"
fi
# Extract version without 'v' prefix for comparison
VERSION_NO_V="${VERSION#v}"
# Validate that the version matches setup.py
if [[ "$VERSION_NO_V" != "$SETUP_VERSION" ]]; then
echo "❌ ERROR: Version mismatch!"
echo " Tag version: $VERSION_NO_V"
echo " setup.py version: $SETUP_VERSION"
echo ""
echo "Please update setup.py to version '$VERSION_NO_V' or use version 'v$SETUP_VERSION'"
exit 1
fi
# Check if tag already exists (prevent duplicates)
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "❌ ERROR: Tag '$VERSION' already exists!"
echo " This version has already been released."
echo " Please update the version in setup.py to a new version number."
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "✅ Version validation passed"
echo "📦 Version: $VERSION"
echo "🏷️ Prerelease: $IS_PRERELEASE"
# ============================================================================
# Build and Push Release Image
# ============================================================================
build-and-push:
name: Build and Push Release Image
runs-on: ubuntu-latest
needs: [full-test-suite, security-audit, determine-version]
if: always() && (needs.full-test-suite.result == 'success' || needs.full-test-suite.result == 'skipped')
permissions:
contents: read
packages: write
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=${{ needs.determine-version.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.determine-version.outputs.version }}
type=semver,pattern={{major}},value=${{ needs.determine-version.outputs.version }}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ needs.determine-version.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ needs.determine-version.outputs.version }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
- name: Generate deployment manifests
run: |
VERSION="${{ needs.determine-version.outputs.version }}"
# Docker Compose deployment
cat > docker-compose.production.yml << EOF
# TimeTracker Production Deployment
# Generated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')
# Version: ${VERSION}
# Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}
version: '3.8'
services:
app:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}
container_name: timetracker-prod
ports:
- "8080:8080"
environment:
- TZ=\${TZ:-Europe/Brussels}
- CURRENCY=\${CURRENCY:-EUR}
- DATABASE_URL=postgresql://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@db:5432/\${POSTGRES_DB}
- SECRET_KEY=\${SECRET_KEY}
- FLASK_ENV=production
- APP_VERSION=${VERSION}
- SESSION_COOKIE_SECURE=true
- REMEMBER_COOKIE_SECURE=true
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: timetracker-prod-db
environment:
- POSTGRES_DB=\${POSTGRES_DB:-timetracker}
- POSTGRES_USER=\${POSTGRES_USER:-timetracker}
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
- TZ=\${TZ:-Europe/Brussels}
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \$\$POSTGRES_USER -d \$\$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
db_data:
driver: local
EOF
# Kubernetes deployment (basic example)
cat > k8s-deployment.yml << EOF
# Kubernetes Deployment for TimeTracker ${VERSION}
apiVersion: apps/v1
kind: Deployment
metadata:
name: timetracker
labels:
app: timetracker
version: ${VERSION}
spec:
replicas: 2
selector:
matchLabels:
app: timetracker
template:
metadata:
labels:
app: timetracker
version: ${VERSION}
spec:
containers:
- name: timetracker
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}
ports:
- containerPort: 8080
name: http
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: timetracker-secrets
key: database-url
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: timetracker-secrets
key: secret-key
livenessProbe:
httpGet:
path: /_health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /_health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: timetracker
spec:
selector:
app: timetracker
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: LoadBalancer
EOF
echo "📄 Deployment manifests created"
- name: Upload deployment manifests
uses: actions/upload-artifact@v4
with:
name: deployment-manifests-${{ needs.determine-version.outputs.version }}
path: |
docker-compose.production.yml
k8s-deployment.yml
# ============================================================================
# Create GitHub Release
# ============================================================================
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build-and-push, determine-version]
if: github.event_name != 'release'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download deployment manifests
uses: actions/download-artifact@v4
with:
name: deployment-manifests-${{ needs.determine-version.outputs.version }}
- name: Generate changelog
id: changelog
run: |
VERSION="${{ needs.determine-version.outputs.version }}"
# Try to get previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%an)" --no-merges)
else
CHANGELOG="Initial release"
fi
# Save to file
echo "$CHANGELOG" > CHANGELOG.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.determine-version.outputs.version }}
name: Release ${{ needs.determine-version.outputs.version }}
body_path: CHANGELOG.md
draft: false
prerelease: ${{ needs.determine-version.outputs.is_prerelease }}
files: |
docker-compose.production.yml
k8s-deployment.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ============================================================================
# Post-Release Summary
# ============================================================================
release-summary:
name: Release Summary
runs-on: ubuntu-latest
needs: [full-test-suite, security-audit, build-and-push, determine-version, create-release]
if: always()
steps:
- name: Create release summary
run: |
echo "## 🚀 Release ${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Status" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Tests: ${{ needs.full-test-suite.result }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Security: ${{ needs.security-audit.result }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Build: ${{ needs.build-and-push.result }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Release: ${{ needs.create-release.result }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🐳 Docker Images" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Quick Deploy" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "# Pull the image" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "# Deploy with docker-compose" >> $GITHUB_STEP_SUMMARY
echo "docker-compose -f docker-compose.production.yml up -d" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY