Files
TimeTracker/.github/workflows/cd-release.yml
Dries Peeters 7dd39ef55a feat(ci): enhance PostHog credential injection visibility in release builds
Improved the Release Build workflow to clearly show that PostHog and Sentry
credentials are being injected from the GitHub Secret Store, providing better
transparency and auditability.

Changes:
- Enhanced workflow step name to explicitly mention "GitHub Secrets"
- Added comprehensive logging with visual separators and clear sections
- Added before/after file content display showing placeholder replacement
- Added secret availability verification with format validation
- Added detailed error messages with step-by-step fix instructions
- Enhanced release summary to highlight successful credential injection
- Updated build configuration documentation with cross-references

Benefits:
- Developers can immediately see credentials come from GitHub Secret Store
- Security teams have clear audit trail of credential injection process
- Better troubleshooting with detailed error messages
- Secrets remain protected with proper redaction (first 8 + last 4 chars)
- Multiple validation steps ensure correct injection

The workflow now outputs 50+ lines of structured logging showing:
- Secret store location (Settings → Secrets and variables → Actions)
- Target file being modified (app/config/analytics_defaults.py)
- Verification that secrets are available
- Format validation (phc_* pattern for PostHog)
- Confirmation of successful placeholder replacement
- Summary with redacted credential previews

Workflow: .github/workflows/cd-release.yml
Documentation: docs/cicd/README_BUILD_CONFIGURATION.md

Fully backward compatible - no breaking changes.
2025-10-23 15:32:57 +02:00

729 lines
29 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 --cov=app --cov-report=xml --cov-report=html --cov-report=term \
--junitxml=junit.xml
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
flags: release
name: release-tests
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-release
path: |
htmlcov/
coverage.xml
junit.xml
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: junit.xml
check_name: Release Test Results
# ============================================================================
# Security Audit (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: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=${{ needs.determine-version.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.determine-version.outputs.version }}
type=semver,pattern={{major}},value=${{ needs.determine-version.outputs.version }}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ needs.determine-version.outputs.is_prerelease == 'false' }}
- name: 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=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}: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}"
# Docker Compose deployment
cat > docker-compose.production.yml << EOF
# TimeTracker Production Deployment
# Generated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')
# Version: ${VERSION}
# Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}
version: '3.8'
services:
app:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION_NO_V}
container_name: timetracker-prod
ports:
- "8080:8080"
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))"
#
# 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)
# For details: docs/CSRF_CONFIGURATION.md
- SECRET_KEY=\${SECRET_KEY:-your-secret-key-change-this}
- DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
- LOG_FILE=/app/logs/timetracker.log
# CSRF Protection (enabled by default for security)
- WTF_CSRF_ENABLED=\${WTF_CSRF_ENABLED:-true}
- WTF_CSRF_TIME_LIMIT=\${WTF_CSRF_TIME_LIMIT:-3600}
# Ensure cookies work over HTTP (disable Secure for local/dev or non-TLS proxies)
- SESSION_COOKIE_SECURE=\${SESSION_COOKIE_SECURE:-false}
- REMEMBER_COOKIE_SECURE=\${REMEMBER_COOKIE_SECURE:-false}
volumes:
- app_data:/data
- ./logs:/app/logs
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: timetracker-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
volumes:
app_data:
driver: local
db_data:
driver: local
EOF
# Kubernetes deployment (basic example)
cat > k8s-deployment.yml << EOF
# Kubernetes Deployment for TimeTracker ${VERSION}
apiVersion: apps/v1
kind: Deployment
metadata:
name: timetracker
labels:
app: timetracker
version: ${VERSION}
spec:
replicas: 2
selector:
matchLabels:
app: timetracker
template:
metadata:
labels:
app: timetracker
version: ${VERSION}
spec:
containers:
- name: timetracker
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}
ports:
- containerPort: 8080
name: http
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: timetracker-secrets
key: database-url
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: timetracker-secrets
key: secret-key
livenessProbe:
httpGet:
path: /_health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /_health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: timetracker
spec:
selector:
app: timetracker
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: LoadBalancer
EOF
echo "📄 Deployment manifests created"
- name: Upload deployment manifests
uses: actions/upload-artifact@v4
with:
name: deployment-manifests-${{ needs.determine-version.outputs.version }}
path: |
docker-compose.production.yml
k8s-deployment.yml
# ============================================================================
# Create GitHub Release
# ============================================================================
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build-and-push, determine-version]
if: github.event_name != 'release'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download deployment manifests
uses: actions/download-artifact@v4
with:
name: deployment-manifests-${{ needs.determine-version.outputs.version }}
- name: Generate changelog
id: changelog
run: |
VERSION="${{ needs.determine-version.outputs.version }}"
# Try to get previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%an)" --no-merges)
else
CHANGELOG="Initial release"
fi
# Save to file
echo "$CHANGELOG" > CHANGELOG.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.determine-version.outputs.version }}
name: Release ${{ needs.determine-version.outputs.version }}
body_path: CHANGELOG.md
draft: false
prerelease: ${{ needs.determine-version.outputs.is_prerelease }}
files: |
docker-compose.production.yml
k8s-deployment.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ============================================================================
# Post-Release Summary
# ============================================================================
release-summary:
name: Release Summary
runs-on: ubuntu-latest
needs: [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