Files
TimeTracker/.github/workflows/cd-release.yml
Dries Peeters 3e100ac4a0 fix(ci): use lowercase image names for Docker registry compatibility
Changes:

- Add step to convert image names to lowercase in CD workflow

- Update docker-compose.production.yml generation to use lowercase image names

- Enhance production deployment with nginx reverse proxy and certgen service

- Update CSRF configuration documentation with IP access guidance

- Improve deployment manifest generation with better service orchestration
2025-11-29 09:25:39 +01:00

882 lines
36 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }}
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: 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 }}
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: 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
# ============================================================================
# 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: [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 "- ✅ 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 " *Full test suite already ran on PR before merge*" >> $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
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