mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 15:49:44 -06:00
557 lines
19 KiB
YAML
557 lines
19 KiB
YAML
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
|
||
|