name: CD - Release Build # This workflow builds and publishes official releases # # Testing Strategy: # - Full test suite runs on PRs via ci-comprehensive.yml # - This workflow focuses on building and publishing # - Security audit still runs to catch any last-minute issues # - Tests can optionally be run via workflow_dispatch for manual releases # # Workflow is triggered by: # - Push to main/master (after PR merge from RC) # - Git tags (v*.*.*) # - Release events # - Manual workflow_dispatch 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 (tests already ran on PR, only for workflow_dispatch)' required: false type: boolean default: true env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # Docker Hub repo to also publish release images to. # Requires GitHub Actions secrets: # - DOCKERHUB_USERNAME # - DOCKERHUB_TOKEN (recommended) or DOCKERHUB_PASSWORD DOCKERHUB_IMAGE: driesp/timetracker PYTHON_VERSION: '3.11' jobs: # ============================================================================ # Full Test Suite (Optional - tests already ran on PR) # ============================================================================ full-test-suite: name: Full Test Suite (Optional) runs-on: ubuntu-latest # Skip by default since tests already ran on PR # Only run if explicitly requested via workflow_dispatch if: github.event_name == 'workflow_dispatch' && 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: Initialize database schema for tests env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db FLASK_APP: app.py FLASK_ENV: testing run: | echo "🔧 Applying migrations to ensure test schema exists..." flask db upgrade echo "Current migration: $(flask db current)" - 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 -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing \ --junitxml=junit.xml --maxfail=5 - 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 (always runs for releases) # ============================================================================ 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: [security-audit, determine-version] # Note: full-test-suite is optional, so we don't depend on it # Tests already ran on PR before merge 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: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN || secrets.DOCKERHUB_PASSWORD }} - name: Set lowercase image name id: image-name run: | # Convert repository name to lowercase for Docker image compatibility IMAGE_NAME_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') echo "IMAGE_NAME_LOWER=${IMAGE_NAME_LOWER}" >> $GITHUB_OUTPUT echo "✅ Image name set to: ${IMAGE_NAME_LOWER}" - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: # Use lowercase image name for compatibility with Docker registries images: | ${{ steps.image-name.outputs.IMAGE_NAME_LOWER }} ${{ env.DOCKERHUB_IMAGE }} 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: Inject analytics configuration from GitHub Secrets env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} run: | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔐 INJECTING ANALYTICS CREDENTIALS FROM GITHUB SECRET STORE" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "📍 Location: Settings → Secrets and variables → Actions" echo "📝 Target File: app/config/analytics_defaults.py" echo "" # Show file before injection echo "📄 File content BEFORE injection (showing placeholders):" echo "──────────────────────────────────────────────────────────────────" grep -E "(POSTHOG_API_KEY_DEFAULT|SENTRY_DSN_DEFAULT)" app/config/analytics_defaults.py || true echo "──────────────────────────────────────────────────────────────────" echo "" # Verify secrets are available echo "🔍 Verifying GitHub Secrets availability..." if [ -z "$POSTHOG_API_KEY" ]; then echo "❌ ERROR: POSTHOG_API_KEY secret is NOT available from GitHub Secret Store!" echo "" echo "To fix this:" echo " 1. Go to: Repository → Settings → Secrets and variables → Actions" echo " 2. Click 'New repository secret'" echo " 3. Name: POSTHOG_API_KEY" echo " 4. Value: Your PostHog API key (format: phc_xxxxx)" echo "" exit 1 else echo "✅ POSTHOG_API_KEY secret found in GitHub Secret Store" echo " → Format: ${POSTHOG_API_KEY:0:8}***${POSTHOG_API_KEY: -4} (${#POSTHOG_API_KEY} characters)" fi if [ -z "$SENTRY_DSN" ]; then echo "âš ī¸ SENTRY_DSN secret not set (optional)" echo " → Sentry error tracking will be disabled" else echo "✅ SENTRY_DSN secret found in GitHub Secret Store" echo " → Format: ${SENTRY_DSN:0:25}***${SENTRY_DSN: -10} (${#SENTRY_DSN} characters)" fi echo "" # Perform replacement echo "🔧 Injecting secrets into application configuration..." sed -i "s|%%POSTHOG_API_KEY_PLACEHOLDER%%|${POSTHOG_API_KEY}|g" app/config/analytics_defaults.py sed -i "s|%%SENTRY_DSN_PLACEHOLDER%%|${SENTRY_DSN}|g" app/config/analytics_defaults.py echo " → Placeholders replaced with actual secret values" echo "" # Show file after injection (redacted) echo "📄 File content AFTER injection (secrets redacted):" echo "──────────────────────────────────────────────────────────────────" grep -E "(POSTHOG_API_KEY_DEFAULT|SENTRY_DSN_DEFAULT)" app/config/analytics_defaults.py | \ sed 's/\(phc_[a-zA-Z0-9]\{8\}\)[a-zA-Z0-9]*\([a-zA-Z0-9]\{4\}\)/\1***\2/g' | \ sed 's|\(https://[^@]*@[^/]*\)|***REDACTED***|g' || true echo "──────────────────────────────────────────────────────────────────" echo "" # Verify placeholders were replaced echo "🔍 Verifying injection was successful..." if grep -q "%%POSTHOG_API_KEY_PLACEHOLDER%%" app/config/analytics_defaults.py; then echo "❌ ERROR: PostHog API key placeholder was NOT replaced!" echo " The placeholder '%%POSTHOG_API_KEY_PLACEHOLDER%%' is still present in the file." exit 1 else echo "✅ PostHog API key placeholder successfully replaced" fi if grep -q "%%SENTRY_DSN_PLACEHOLDER%%" app/config/analytics_defaults.py; then echo "❌ ERROR: Sentry DSN placeholder was NOT replaced!" echo " The placeholder '%%SENTRY_DSN_PLACEHOLDER%%' is still present in the file." exit 1 else echo "✅ Sentry DSN placeholder successfully replaced" fi # Verify the actual key format (should start with 'phc_') if ! grep -q 'POSTHOG_API_KEY_DEFAULT = "phc_' app/config/analytics_defaults.py; then echo "❌ ERROR: PostHog API key format validation FAILED!" echo " Expected format: phc_* (PostHog Cloud key)" echo " Please verify the secret value in GitHub Settings." exit 1 else echo "✅ PostHog API key format validated (phc_* pattern confirmed)" fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "✅ SUCCESS: Analytics credentials injected from GitHub Secret Store" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "📊 Injected Credentials Summary:" echo " â€ĸ PostHog API Key: phc_***${POSTHOG_API_KEY: -4} ✓" if [ -n "$SENTRY_DSN" ]; then echo " â€ĸ Sentry DSN: ${SENTRY_DSN:0:20}*** ✓" else echo " â€ĸ Sentry DSN: [Not configured] âš ī¸" fi echo "" echo "🔒 Security Notes:" echo " â€ĸ Secrets are injected at build time from GitHub Secret Store" echo " â€ĸ Secrets are never exposed in logs or build artifacts" echo " â€ĸ Users can still opt-in/opt-out of telemetry via admin dashboard" echo "" - name: Inject donate-hide public key (optional) env: DONATE_HIDE_PUBLIC_KEY_PEM: ${{ secrets.DONATE_HIDE_PUBLIC_KEY_PEM }} run: | if [ -n "$DONATE_HIDE_PUBLIC_KEY_PEM" ]; then echo "✅ DONATE_HIDE_PUBLIC_KEY_PEM secret set — writing donate_hide_public.pem for Docker build" echo "$DONATE_HIDE_PUBLIC_KEY_PEM" > donate_hide_public.pem echo " → File will be copied into image at /app/donate_hide_public.pem (DONATE_HIDE_PUBLIC_KEY_FILE)" else echo "âš ī¸ DONATE_HIDE_PUBLIC_KEY_PEM not set — Support visibility code verification will be disabled in image" fi - 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=${{ steps.image-name.outputs.IMAGE_NAME_LOWER }}:latest cache-to: type=inline - name: Generate deployment manifests run: | VERSION="${{ needs.determine-version.outputs.version }}" # Remove 'v' prefix for image tag VERSION_NO_V="${VERSION#v}" # Convert repository name to lowercase for image name IMAGE_NAME_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') PRODUCTION_IMAGE="${IMAGE_NAME_LOWER}:${VERSION_NO_V}" # Docker Compose deployment - includes all services from docker-compose.yml cat > docker-compose.production.yml << EOF # TimeTracker Production Deployment # Generated: $(date -u +'%Y-%m-%d %H:%M:%S UTC') # Version: ${VERSION} # Image: ${PRODUCTION_IMAGE} services: # Certificate generator - runs once to create self-signed certs with SANs certgen: image: alpine:latest container_name: timetracker-certgen volumes: - ./nginx/ssl:/certs - ./scripts:/scripts:ro command: sh /scripts/generate-certs.sh restart: "no" # HTTPS reverse proxy (TLS terminates here) nginx: image: nginx:alpine container_name: timetracker-nginx ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/ssl:/etc/nginx/ssl:ro depends_on: certgen: condition: service_completed_successfully app: condition: service_started restart: unless-stopped app: image: ${PRODUCTION_IMAGE} container_name: timetracker-app environment: - TZ=\${TZ:-Europe/Brussels} - CURRENCY=\${CURRENCY:-EUR} - ROUNDING_MINUTES=\${ROUNDING_MINUTES:-1} - SINGLE_ACTIVE_TIMER=\${SINGLE_ACTIVE_TIMER:-true} - ALLOW_SELF_REGISTER=\${ALLOW_SELF_REGISTER:-true} - IDLE_TIMEOUT_MINUTES=\${IDLE_TIMEOUT_MINUTES:-30} - ADMIN_USERNAMES=\${ADMIN_USERNAMES:-admin} # IMPORTANT: Change SECRET_KEY in production! Used for sessions and CSRF tokens. # Generate a secure key: python -c "import secrets; print(secrets.token_hex(32))" # # CSRF CONFIGURATION: # - WTF_CSRF_SSL_STRICT: Set to 'false' for HTTP access (localhost or IP address) # Set to 'true' only when using HTTPS in production # - If accessing via IP address (e.g., 192.168.1.100), also set: # SESSION_COOKIE_SECURE=false and CSRF_COOKIE_SECURE=false # # TROUBLESHOOTING: If forms fail with "CSRF token missing or invalid": # 1. Verify SECRET_KEY is set and doesn't change between restarts # 2. Check CSRF is enabled: WTF_CSRF_ENABLED=true # 3. Ensure cookies are enabled in your browser # 4. If behind a reverse proxy, ensure it forwards cookies correctly # 5. Check the token hasn't expired (increase WTF_CSRF_TIME_LIMIT if needed) # 6. If accessing via IP (not localhost): WTF_CSRF_SSL_STRICT=false # For details: docs/CSRF_CONFIGURATION.md and docs/CSRF_IP_ACCESS_GUIDE.md - SECRET_KEY=\${SECRET_KEY:-your-secret-key-change-this} # Disable strict Referer check by default to avoid privacy/port issues - WTF_CSRF_SSL_STRICT=\${WTF_CSRF_SSL_STRICT:-true} - WTF_CSRF_ENABLED=\${WTF_CSRF_ENABLED:-true} - WTF_CSRF_TIME_LIMIT=\${WTF_CSRF_TIME_LIMIT:-3600} - SESSION_COOKIE_SECURE=\${SESSION_COOKIE_SECURE:-true} - SESSION_COOKIE_SAMESITE=\${SESSION_COOKIE_SAMESITE:-Lax} - REMEMBER_COOKIE_SECURE=\${REMEMBER_COOKIE_SECURE:-true} - CSRF_COOKIE_SECURE=\${CSRF_COOKIE_SECURE:-true} - CSRF_COOKIE_HTTPONLY=\${CSRF_COOKIE_HTTPONLY:-false} - CSRF_COOKIE_SAMESITE=\${CSRF_COOKIE_SAMESITE:-Lax} - CSRF_COOKIE_NAME=\${CSRF_COOKIE_NAME:-XSRF-TOKEN} - PREFERRED_URL_SCHEME=\${PREFERRED_URL_SCHEME:-https} - WTF_CSRF_TRUSTED_ORIGINS=\${WTF_CSRF_TRUSTED_ORIGINS:-https://localhost} - DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker - REDIS_URL=redis://:\${REDIS_PASSWORD:-timetracker}@redis:6379/0 - REDIS_ENABLED=\${REDIS_ENABLED:-true} - LOG_FILE=/app/logs/timetracker.log # Analytics & Monitoring (optional) # See docs/analytics.md for configuration details - SENTRY_DSN=\${SENTRY_DSN:-} - SENTRY_TRACES_RATE=\${SENTRY_TRACES_RATE:-0.0} - POSTHOG_API_KEY=\${POSTHOG_API_KEY:-} - POSTHOG_HOST=\${POSTHOG_HOST:-https://app.posthog.com} - ENABLE_TELEMETRY=\${ENABLE_TELEMETRY:-false} - TELE_URL=\${TELE_URL:-} - TELE_SALT=\${TELE_SALT:-8f4a7b2e9c1d6f3a5e8b4c7d2a9f6e3b1c8d5a7f2e9b4c6d3a8f5e1b7c4d9a2f} # Expose only internally; nginx publishes ports ports: [] volumes: - app_data:/data - app_logs:/app/logs - app_uploads:/app/app/static/uploads depends_on: db: condition: service_healthy redis: 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-db environment: - POSTGRES_DB=\${POSTGRES_DB:-timetracker} - POSTGRES_USER=\${POSTGRES_USER:-timetracker} - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD:-timetracker} - TZ=\${TZ:-Europe/Brussels} volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U \$\$POSTGRES_USER -d \$\$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 start_period: 30s restart: unless-stopped # Redis - Caching and session storage redis: image: redis:7-alpine container_name: timetracker-redis command: redis-server --appendonly yes --requirepass \${REDIS_PASSWORD:-timetracker} volumes: - redis_data:/data ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "--raw", "incr", "ping"] interval: 10s timeout: 3s retries: 5 restart: unless-stopped # Analytics & Monitoring Services # All services start by default for complete monitoring # See docs/analytics.md and ANALYTICS_QUICK_START.md for details # Prometheus - Metrics collection and storage prometheus: image: prom/prometheus:latest container_name: timetracker-prometheus volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus_data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' ports: - "9090:9090" restart: unless-stopped # Grafana - Metrics visualization and dashboards grafana: image: grafana/grafana:latest container_name: timetracker-grafana environment: - GF_SECURITY_ADMIN_PASSWORD=\${GRAFANA_ADMIN_PASSWORD:-admin} - GF_USERS_ALLOW_SIGN_UP=false - GF_SERVER_ROOT_URL=\${GF_SERVER_ROOT_URL:-http://localhost:3000} volumes: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning ports: - "3000:3000" depends_on: - prometheus restart: unless-stopped # Loki - Log aggregation loki: image: grafana/loki:latest container_name: timetracker-loki volumes: - ./loki/loki-config.yml:/etc/loki/local-config.yaml - loki_data:/loki ports: - "3100:3100" command: -config.file=/etc/loki/local-config.yaml restart: unless-stopped # Promtail - Log shipping to Loki promtail: image: grafana/promtail:latest container_name: timetracker-promtail volumes: - ./logs:/var/log/timetracker:ro - ./promtail/promtail-config.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml depends_on: - loki restart: unless-stopped volumes: app_data: driver: local app_logs: driver: local app_uploads: driver: local db_data: driver: local prometheus_data: driver: local grafana_data: driver: local loki_data: driver: local redis_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 # ============================================================================ # Build Desktop Applications # ============================================================================ build-desktop-windows: name: Build Desktop - Windows runs-on: windows-latest needs: [determine-version] continue-on-error: true timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' - name: Install dependencies working-directory: desktop run: npm ci - name: Build Windows working-directory: desktop run: npm run build:win - name: Upload Windows installer if: success() uses: actions/upload-artifact@v4 with: name: desktop-windows-${{ needs.determine-version.outputs.version }} path: desktop/dist/*.exe retention-days: 90 build-desktop-linux: name: Build Desktop - Linux runs-on: ubuntu-latest needs: [determine-version] continue-on-error: true timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' - name: Install dependencies working-directory: desktop run: npm ci - name: Build Linux working-directory: desktop run: npm run build:linux - name: Upload Linux packages if: success() uses: actions/upload-artifact@v4 with: name: desktop-linux-${{ needs.determine-version.outputs.version }} path: | desktop/dist/*.AppImage desktop/dist/*.deb retention-days: 90 build-desktop-macos: name: Build Desktop - macOS runs-on: macos-latest needs: [determine-version] continue-on-error: true timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' - name: Install dependencies working-directory: desktop run: npm ci - name: Build macOS working-directory: desktop run: npm run build:mac - name: Upload macOS DMG if: success() uses: actions/upload-artifact@v4 with: name: desktop-macos-${{ needs.determine-version.outputs.version }} path: desktop/dist/*.dmg retention-days: 90 # ============================================================================ # Build Mobile Applications # ============================================================================ build-mobile-android: name: Build Mobile - Android runs-on: ubuntu-latest needs: [determine-version] continue-on-error: true timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Java uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: '17' - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.35.4' channel: 'stable' - name: Install dependencies working-directory: mobile run: flutter pub get - name: Build APK working-directory: mobile run: flutter build apk --release - name: Build App Bundle working-directory: mobile run: flutter build appbundle --release continue-on-error: true - name: Upload Android APK if: success() uses: actions/upload-artifact@v4 with: name: mobile-android-apk-${{ needs.determine-version.outputs.version }} path: mobile/build/app/outputs/flutter-apk/app-release.apk retention-days: 90 - name: Upload Android App Bundle if: always() uses: actions/upload-artifact@v4 with: name: mobile-android-aab-${{ needs.determine-version.outputs.version }} path: mobile/build/app/outputs/bundle/release/app-release.aab if-no-files-found: ignore retention-days: 90 build-mobile-ios: name: Build Mobile - iOS runs-on: macos-latest needs: [determine-version] continue-on-error: true timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.35.4' channel: 'stable' - name: Install dependencies working-directory: mobile run: flutter pub get - name: Generate iOS platform files working-directory: mobile run: flutter create --platforms=ios . - name: Configure iOS project for device build without code signing working-directory: mobile run: | # Disable code signing requirements in Xcode project sed -i '' 's/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = "";/g' ios/Runner.xcodeproj/project.pbxproj sed -i '' 's/CODE_SIGN_IDENTITY = .*;/CODE_SIGN_IDENTITY = "";/g' ios/Runner.xcodeproj/project.pbxproj sed -i '' 's/CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = .*;/CODE_SIGN_IDENTITY[sdk=iphoneos*] = "";/g' ios/Runner.xcodeproj/project.pbxproj sed -i '' 's/CODE_SIGNING_REQUIRED = .*;/CODE_SIGNING_REQUIRED = NO;/g' ios/Runner.xcodeproj/project.pbxproj sed -i '' 's/CODE_SIGNING_ALLOWED = .*;/CODE_SIGNING_ALLOWED = NO;/g' ios/Runner.xcodeproj/project.pbxproj # Also set for all configurations sed -i '' 's/ProvisioningStyle = Automatic;/ProvisioningStyle = Manual;/g' ios/Runner.xcodeproj/project.pbxproj - name: Build iOS (no codesign) working-directory: mobile run: flutter build ios --release --no-codesign - name: Create iOS archive if: success() working-directory: mobile run: | mkdir -p dist # Package the built iOS app (IPA would require codesigning, so we'll package the .app) if [ -d "build/ios/iphoneos/Runner.app" ]; then cd build/ios/iphoneos zip -r ../../../dist/TimeTracker-iOS-${{ needs.determine-version.outputs.version }}.zip Runner.app cd ../../.. echo "✅ iOS archive created successfully" ls -lh dist/ else echo "❌ ERROR: Runner.app not found at build/ios/iphoneos/Runner.app" echo "Listing build/ios directory:" ls -la build/ios/ || echo "build/ios directory does not exist" echo "Listing build directory:" ls -la build/ || echo "build directory does not exist" echo "Searching for Runner.app:" find build -name "Runner.app" -type d 2>/dev/null || echo "Runner.app not found anywhere" exit 1 fi - name: Upload iOS build if: success() uses: actions/upload-artifact@v4 with: name: mobile-ios-${{ needs.determine-version.outputs.version }} path: mobile/dist/*.zip if-no-files-found: error retention-days: 90 # ============================================================================ # Create GitHub Release # ============================================================================ create-release: name: Create GitHub Release runs-on: ubuntu-latest needs: [build-and-push, determine-version, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios] 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 continue-on-error: true with: name: deployment-manifests-${{ needs.determine-version.outputs.version }} - name: Download desktop artifacts uses: actions/download-artifact@v4 continue-on-error: true with: pattern: 'desktop-*' merge-multiple: false - name: Download mobile artifacts uses: actions/download-artifact@v4 continue-on-error: true with: pattern: 'mobile-*' merge-multiple: false - name: Prepare release files run: | VERSION="${{ needs.determine-version.outputs.version }}" RELEASE_DIR="release-files" mkdir -p "$RELEASE_DIR" # Move deployment manifests to release directory if [ -f "docker-compose.production.yml" ]; then cp docker-compose.production.yml "$RELEASE_DIR/" || true fi if [ -f "k8s-deployment.yml" ]; then cp k8s-deployment.yml "$RELEASE_DIR/" || true fi # Organize desktop files DESKTOP_DIR="$RELEASE_DIR/desktop" mkdir -p "$DESKTOP_DIR" # Windows if [ -d "desktop-windows-$VERSION" ]; then mkdir -p "$DESKTOP_DIR/windows" cp desktop-windows-$VERSION/* "$DESKTOP_DIR/windows/" 2>/dev/null || true fi # Linux if [ -d "desktop-linux-$VERSION" ]; then mkdir -p "$DESKTOP_DIR/linux" cp desktop-linux-$VERSION/* "$DESKTOP_DIR/linux/" 2>/dev/null || true fi # macOS if [ -d "desktop-macos-$VERSION" ]; then mkdir -p "$DESKTOP_DIR/macos" cp desktop-macos-$VERSION/* "$DESKTOP_DIR/macos/" 2>/dev/null || true fi # Organize mobile files MOBILE_DIR="$RELEASE_DIR/mobile" mkdir -p "$MOBILE_DIR" # Android APK if [ -d "mobile-android-apk-$VERSION" ]; then mkdir -p "$MOBILE_DIR/android" cp mobile-android-apk-$VERSION/* "$MOBILE_DIR/android/" 2>/dev/null || true fi # Android AAB if [ -d "mobile-android-aab-$VERSION" ]; then mkdir -p "$MOBILE_DIR/android" cp mobile-android-aab-$VERSION/* "$MOBILE_DIR/android/" 2>/dev/null || true fi # iOS if [ -d "mobile-ios-$VERSION" ]; then mkdir -p "$MOBILE_DIR/ios" cp mobile-ios-$VERSION/* "$MOBILE_DIR/ios/" 2>/dev/null || true fi # Create file list for release (for debugging) find "$RELEASE_DIR" -type f > release-files-list.txt || true echo "Files to attach to release:" cat release-files-list.txt || echo "No files found" # Count files for summary FILE_COUNT=$(find "$RELEASE_DIR" -type f | wc -l || echo "0") echo "Total files to attach: $FILE_COUNT" echo "file_count=$FILE_COUNT" >> $GITHUB_OUTPUT - 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 # Write changelog to file first echo "$CHANGELOG" > CHANGELOG.md # Add build status to changelog echo "" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "## đŸ“Ļ Build Status" >> CHANGELOG.md echo "" >> CHANGELOG.md # Desktop builds echo "### Desktop Applications" >> CHANGELOG.md if [ "${{ needs.build-desktop-windows.result }}" == "success" ]; then echo "✅ Windows build: Success" >> CHANGELOG.md else echo "âš ī¸ Windows build: Failed or skipped" >> CHANGELOG.md fi if [ "${{ needs.build-desktop-linux.result }}" == "success" ]; then echo "✅ Linux build: Success" >> CHANGELOG.md else echo "âš ī¸ Linux build: Failed or skipped" >> CHANGELOG.md fi if [ "${{ needs.build-desktop-macos.result }}" == "success" ]; then echo "✅ macOS build: Success" >> CHANGELOG.md else echo "âš ī¸ macOS build: Failed or skipped" >> CHANGELOG.md fi # Mobile builds echo "" >> CHANGELOG.md echo "### Mobile Applications" >> CHANGELOG.md if [ "${{ needs.build-mobile-android.result }}" == "success" ]; then echo "✅ Android build: Success" >> CHANGELOG.md else echo "âš ī¸ Android build: Failed or skipped" >> CHANGELOG.md fi if [ "${{ needs.build-mobile-ios.result }}" == "success" ]; then echo "✅ iOS build: Success" >> CHANGELOG.md else echo "âš ī¸ iOS build: Failed or skipped" >> CHANGELOG.md fi - 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: | release-files/docker-compose.production.yml release-files/k8s-deployment.yml release-files/desktop/**/* release-files/mobile/**/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} continue-on-error: false # ============================================================================ # Trigger Demo Site Deploy (Render) # ============================================================================ trigger-demo-deploy: name: Trigger Demo Deploy runs-on: ubuntu-latest needs: [build-and-push] continue-on-error: true timeout-minutes: 2 steps: - name: Trigger Render deploy hook env: RENDER_DEPLOY_HOOK_URL: ${{ secrets.TimeTrackerDemoRender }} run: | if [ -z "$RENDER_DEPLOY_HOOK_URL" ]; then echo "âš ī¸ TimeTrackerDemoRender secret not configured - skipping demo deploy" exit 0 fi echo "🚀 Triggering Render deploy hook for demo site..." HTTP_CODE=$(curl -s -o /tmp/render-response.txt -w "%{http_code}" -X POST "$RENDER_DEPLOY_HOOK_URL") if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then echo "✅ Render deploy triggered successfully (HTTP $HTTP_CODE)" else echo "❌ Render deploy hook returned HTTP $HTTP_CODE" cat /tmp/render-response.txt || true exit 1 fi # ============================================================================ # Post-Release Summary # ============================================================================ release-summary: name: Release Summary runs-on: ubuntu-latest needs: [security-audit, build-and-push, determine-version, create-release, trigger-demo-deploy, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios] if: always() steps: - name: Create release summary run: | echo "## 🚀 Release ${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Core Build Status" >> $GITHUB_STEP_SUMMARY echo "- ✅ Security: ${{ needs.security-audit.result }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Docker Build: ${{ needs.build-and-push.result }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Release: ${{ needs.create-release.result }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Demo Deploy: ${{ needs.trigger-demo-deploy.result }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Desktop Applications" >> $GITHUB_STEP_SUMMARY if [ "${{ needs.build-desktop-windows.result }}" == "success" ]; then echo "- ✅ Windows: Success" >> $GITHUB_STEP_SUMMARY else echo "- âš ī¸ Windows: ${{ needs.build-desktop-windows.result }}" >> $GITHUB_STEP_SUMMARY fi if [ "${{ needs.build-desktop-linux.result }}" == "success" ]; then echo "- ✅ Linux: Success" >> $GITHUB_STEP_SUMMARY else echo "- âš ī¸ Linux: ${{ needs.build-desktop-linux.result }}" >> $GITHUB_STEP_SUMMARY fi if [ "${{ needs.build-desktop-macos.result }}" == "success" ]; then echo "- ✅ macOS: Success" >> $GITHUB_STEP_SUMMARY else echo "- âš ī¸ macOS: ${{ needs.build-desktop-macos.result }}" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY echo "### Mobile Applications" >> $GITHUB_STEP_SUMMARY if [ "${{ needs.build-mobile-android.result }}" == "success" ]; then echo "- ✅ Android: Success" >> $GITHUB_STEP_SUMMARY else echo "- âš ī¸ Android: ${{ needs.build-mobile-android.result }}" >> $GITHUB_STEP_SUMMARY fi if [ "${{ needs.build-mobile-ios.result }}" == "success" ]; then echo "- ✅ iOS: Success" >> $GITHUB_STEP_SUMMARY else echo "- âš ī¸ iOS: ${{ needs.build-mobile-ios.result }}" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY echo "â„šī¸ *Full test suite already ran on PR before merge*" >> $GITHUB_STEP_SUMMARY echo "â„šī¸ *Desktop and mobile builds are optional - release continues even if they fail*" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### 🔐 Analytics Configuration" >> $GITHUB_STEP_SUMMARY echo "Analytics credentials were **successfully injected** from GitHub Secret Store:" >> $GITHUB_STEP_SUMMARY echo "- ✅ **PostHog API Key**: Injected from \`POSTHOG_API_KEY\` secret" >> $GITHUB_STEP_SUMMARY echo "- ✅ **Sentry DSN**: Injected from \`SENTRY_DSN\` secret" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "> 📍 **Secret Location**: Repository Settings → Secrets and variables → Actions" >> $GITHUB_STEP_SUMMARY echo "> 🔒 **Security**: Secrets are embedded at build time and never exposed in logs" >> $GITHUB_STEP_SUMMARY echo "> đŸ‘Ĩ **Privacy**: Users maintain full control via opt-in/opt-out in admin dashboard" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY VERSION="${{ needs.determine-version.outputs.version }}" VERSION_NO_V="${VERSION#v}" echo "### đŸŗ Docker Images" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION_NO_V}" >> $GITHUB_STEP_SUMMARY echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY echo "${{ env.DOCKERHUB_IMAGE }}:${VERSION_NO_V}" >> $GITHUB_STEP_SUMMARY echo "${{ env.DOCKERHUB_IMAGE }}: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 }}:${VERSION_NO_V}" >> $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