mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-04 10:40:23 -06:00
463 lines
16 KiB
YAML
463 lines
16 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)'
|
|
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
|
|
|
|
- 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 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 bandit safety
|
|
|
|
- name: Run Bandit
|
|
run: |
|
|
bandit -r app/ -f json -o bandit-report.json
|
|
bandit -r app/ -f txt
|
|
|
|
- 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: |
|
|
bandit-report.json
|
|
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: Determine version
|
|
id: version
|
|
run: |
|
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
VERSION="${{ github.event.inputs.version }}"
|
|
IS_PRERELEASE="false"
|
|
elif [[ "${{ github.event_name }}" == "release" ]]; then
|
|
VERSION="${{ github.event.release.tag_name }}"
|
|
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
|
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
|
VERSION=${GITHUB_REF#refs/tags/}
|
|
IS_PRERELEASE="false"
|
|
else
|
|
VERSION="v1.0.${{ github.run_number }}"
|
|
IS_PRERELEASE="false"
|
|
fi
|
|
|
|
# Ensure version starts with 'v'
|
|
if [[ ! $VERSION =~ ^v ]]; then
|
|
VERSION="v${VERSION}"
|
|
fi
|
|
|
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
|
|
|
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
|
|
|