mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
3f56a06ef0
Add trigger-demo-deploy job to cd-release workflow that POSTs to Render deploy hook when TimeTrackerDemoRender org secret is set. Runs after build-and-push; skips gracefully if secret is not configured. Include demo deploy status in release summary. Document in RENDER.md, CI_CD_DOCUMENTATION.md, and GITHUB_ACTIONS_SETUP.md.
1327 lines
53 KiB
YAML
1327 lines
53 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 }}
|
||
# 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
|
||
|