mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-04 10:40:23 -06:00
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
882 lines
36 KiB
YAML
882 lines
36 KiB
YAML
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
|
||
|