Files
TimeTracker/.github/workflows/cd-release.yml
Dries Peeters 6f4c8c8c21 Updated for Ci-testing
Updated for Ci-testing
2025-10-09 13:13:28 +02:00

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