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