cms.c2sgmbh/.github/workflows/deploy-production.yml
Martin Porwoll c505f29ebf fix: optimize GitHub Actions workflows to reduce costs
- Remove CodeQL Analysis (requires paid GHAS for private repos)
- Replace with ESLint + pnpm audit for security scanning
- CI: Run full tests only on PRs, not on every push to develop
- CI: Skip CI for markdown-only changes
- Security: Run only on PRs to main and weekly schedule
- Add deploy-production.yml workflow with rollback support
- Add deploy-production.sh script for manual deployments
- Document GitHub Actions cost optimization in DEPLOYMENT_STRATEGY.md

Estimated savings: ~68% of GitHub Actions minutes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:52:45 +00:00

441 lines
16 KiB
YAML

name: Deploy to Production
on:
# Manual trigger with approval - required for production
workflow_dispatch:
inputs:
skip_tests:
description: 'Skip pre-deployment tests'
required: false
default: 'false'
type: boolean
skip_backup:
description: 'Skip database backup (not recommended)'
required: false
default: 'false'
type: boolean
skip_migrations:
description: 'Skip database migrations'
required: false
default: 'false'
type: boolean
deploy_tag:
description: 'Git tag or commit to deploy (leave empty for latest main)'
required: false
type: string
concurrency:
group: production-deployment
cancel-in-progress: false
env:
NODE_VERSION: '22'
PNPM_VERSION: '9'
PRODUCTION_HOST: '162.55.85.18'
PRODUCTION_USER: 'payload'
PRODUCTION_PATH: '/home/payload/payload-cms'
PRODUCTION_URL: 'https://cms.c2sgmbh.de'
jobs:
# ===========================================================================
# Pre-flight Checks
# ===========================================================================
preflight:
name: Pre-flight Checks
runs-on: ubuntu-latest
outputs:
deploy_sha: ${{ steps.get-sha.outputs.sha }}
deploy_tag: ${{ steps.get-sha.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.deploy_tag || 'main' }}
fetch-depth: 0
- name: Get deployment SHA
id: get-sha
run: |
SHA=$(git rev-parse HEAD)
TAG="${{ inputs.deploy_tag }}"
if [ -z "$TAG" ]; then
TAG="main ($(git rev-parse --short HEAD))"
fi
echo "sha=$SHA" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Deploying: $TAG"
echo "SHA: $SHA"
- name: Verify branch is up to date with develop
run: |
git fetch origin develop
MAIN_SHA=$(git rev-parse HEAD)
DEVELOP_SHA=$(git rev-parse origin/develop)
# Check if main contains the latest develop commits
if ! git merge-base --is-ancestor origin/develop HEAD 2>/dev/null; then
echo "::warning::main is not up to date with develop. Consider merging develop first."
fi
- name: Check for pending migrations
run: |
# List migration files that might need to be run
if [ -d "src/migrations" ]; then
MIGRATION_COUNT=$(find src/migrations -name "*.ts" -type f | wc -l)
echo "Found $MIGRATION_COUNT migration files"
fi
# ===========================================================================
# Pre-deployment Tests (optional, recommended)
# ===========================================================================
pre-tests:
name: Pre-deployment Tests
runs-on: ubuntu-latest
needs: [preflight]
if: ${{ inputs.skip_tests != true }}
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: payload
POSTGRES_PASSWORD: payload_test_password
POSTGRES_DB: payload_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.deploy_sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm lint
- name: Run Unit Tests
run: pnpm test:unit
timeout-minutes: 10
env:
CSRF_SECRET: test-csrf-secret
PAYLOAD_SECRET: test-payload-secret
PAYLOAD_PUBLIC_SERVER_URL: https://test.example.com
NEXT_PUBLIC_SERVER_URL: https://test.example.com
EMAIL_DELIVERY_DISABLED: 'true'
DATABASE_URI: postgresql://payload:payload_test_password@localhost:5432/payload_test
- name: Build Test
run: pnpm build
env:
PAYLOAD_SECRET: build-secret-placeholder
DATABASE_URI: postgresql://placeholder:placeholder@localhost:5432/placeholder
NEXT_PUBLIC_SERVER_URL: https://build.example.com
PAYLOAD_PUBLIC_SERVER_URL: https://build.example.com
CONSENT_LOGGING_API_KEY: ci-consent-api-key-placeholder
IP_ANONYMIZATION_PEPPER: ci-anonymization-pepper-placeholder
# ===========================================================================
# Database Backup (before deployment)
# ===========================================================================
backup:
name: Pre-deployment Backup
runs-on: ubuntu-latest
needs: [preflight, pre-tests]
if: always() && (needs.pre-tests.result == 'success' || needs.pre-tests.result == 'skipped') && inputs.skip_backup != true
steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PRODUCTION_SSH_KEY }}" > ~/.ssh/production_key
chmod 600 ~/.ssh/production_key
cat >> ~/.ssh/config << EOF
Host production
HostName ${{ env.PRODUCTION_HOST }}
User ${{ env.PRODUCTION_USER }}
IdentityFile ~/.ssh/production_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
EOF
- name: Create database backup
run: |
ssh production << 'ENDSSH'
set -e
echo "=== Creating pre-deployment backup ==="
BACKUP_DIR="$HOME/backups/pre-deploy"
BACKUP_FILE="$BACKUP_DIR/payload_db_$(date +%Y-%m-%d_%H-%M-%S).sql.gz"
mkdir -p "$BACKUP_DIR"
# Create backup using pg_dump
PGPASSWORD=$(grep 'payload_db' ~/.pgpass | cut -d: -f5) \
pg_dump -h localhost -U payload payload_db | gzip > "$BACKUP_FILE"
# Keep only last 5 pre-deploy backups
ls -t "$BACKUP_DIR"/payload_db_*.sql.gz 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
echo "Backup created: $BACKUP_FILE"
ls -lh "$BACKUP_FILE"
ENDSSH
# ===========================================================================
# Deploy to Production
# ===========================================================================
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [preflight, pre-tests, backup]
if: always() && (needs.pre-tests.result == 'success' || needs.pre-tests.result == 'skipped') && (needs.backup.result == 'success' || needs.backup.result == 'skipped')
environment:
name: production
url: ${{ env.PRODUCTION_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.deploy_sha }}
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PRODUCTION_SSH_KEY }}" > ~/.ssh/production_key
chmod 600 ~/.ssh/production_key
cat >> ~/.ssh/config << EOF
Host production
HostName ${{ env.PRODUCTION_HOST }}
User ${{ env.PRODUCTION_USER }}
IdentityFile ~/.ssh/production_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
EOF
- name: Deploy to Production
id: deploy
run: |
DEPLOY_SHA="${{ needs.preflight.outputs.deploy_sha }}"
SKIP_MIGRATIONS="${{ inputs.skip_migrations }}"
ssh production << ENDSSH
set -e
echo "=============================================="
echo " PRODUCTION DEPLOYMENT"
echo "=============================================="
echo "Time: \$(date)"
echo "SHA: $DEPLOY_SHA"
echo "Skip Migrations: $SKIP_MIGRATIONS"
echo ""
cd ${{ env.PRODUCTION_PATH }}
# Save current commit for rollback
PREVIOUS_SHA=\$(git rev-parse HEAD)
echo "\$PREVIOUS_SHA" > /tmp/previous_deploy_sha
echo "Previous commit: \$PREVIOUS_SHA"
# Stash any local changes
git stash --include-untracked 2>/dev/null || true
# Fetch and checkout
echo ""
echo "=== Fetching code ==="
git fetch origin main
git checkout main
git reset --hard $DEPLOY_SHA
# Install dependencies
echo ""
echo "=== Installing dependencies ==="
pnpm install --frozen-lockfile
# Run migrations (unless skipped)
if [ "$SKIP_MIGRATIONS" != "true" ]; then
echo ""
echo "=== Running migrations ==="
pnpm payload migrate || echo "No migrations to run"
else
echo ""
echo "=== Skipping migrations (as requested) ==="
fi
# Stop services before build to free memory
echo ""
echo "=== Stopping services for build ==="
pm2 stop payload 2>/dev/null || true
pm2 stop queue-worker 2>/dev/null || true
# Build
echo ""
echo "=== Building application ==="
NODE_OPTIONS="--max-old-space-size=2048" pnpm build
# Restart services
echo ""
echo "=== Starting services ==="
pm2 restart payload --update-env 2>/dev/null || pm2 start ecosystem.config.cjs --only payload
pm2 restart queue-worker --update-env 2>/dev/null || pm2 start ecosystem.config.cjs --only queue-worker
# Wait for startup
echo ""
echo "=== Waiting for services to start ==="
sleep 10
# Show status
pm2 status
echo ""
echo "=== Deployment Complete ==="
echo "Time: \$(date)"
ENDSSH
- name: Health Check
id: health-check
run: |
echo "Waiting for service to be fully ready..."
sleep 10
MAX_RETRIES=5
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.PRODUCTION_URL }}/admin" 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 400 ]; then
echo "Health check passed (HTTP $HTTP_STATUS)"
echo "status=success" >> $GITHUB_OUTPUT
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Health check attempt $RETRY_COUNT failed (HTTP $HTTP_STATUS), retrying in 10s..."
sleep 10
done
echo "Health check failed after $MAX_RETRIES attempts"
echo "status=failed" >> $GITHUB_OUTPUT
exit 1
- name: Rollback on failure
if: failure() && steps.health-check.outputs.status == 'failed'
run: |
echo "Deployment failed, initiating rollback..."
ssh production << 'ENDSSH'
set -e
cd ${{ env.PRODUCTION_PATH }}
if [ -f /tmp/previous_deploy_sha ]; then
PREVIOUS_SHA=$(cat /tmp/previous_deploy_sha)
echo "Rolling back to: $PREVIOUS_SHA"
git checkout main
git reset --hard $PREVIOUS_SHA
pnpm install --frozen-lockfile
pm2 stop payload 2>/dev/null || true
NODE_OPTIONS="--max-old-space-size=2048" pnpm build
pm2 restart payload --update-env
pm2 restart queue-worker --update-env
echo "Rollback complete"
pm2 status
else
echo "No previous SHA found, manual intervention required"
exit 1
fi
ENDSSH
# ===========================================================================
# Post-deployment Verification
# ===========================================================================
verify:
name: Post-deployment Verification
runs-on: ubuntu-latest
needs: [deploy]
if: success()
steps:
- name: Full Health Check
run: |
echo "Running comprehensive health checks..."
# Check Admin Panel
ADMIN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.PRODUCTION_URL }}/admin")
echo "Admin Panel: HTTP $ADMIN_STATUS"
# Check API
API_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.PRODUCTION_URL }}/api")
echo "API: HTTP $API_STATUS"
# Summary
if [ "$ADMIN_STATUS" -ge 200 ] && [ "$ADMIN_STATUS" -lt 400 ] && \
[ "$API_STATUS" -ge 200 ] && [ "$API_STATUS" -lt 400 ]; then
echo "All health checks passed!"
else
echo "Some health checks failed"
exit 1
fi
# ===========================================================================
# Deployment Summary
# ===========================================================================
summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [preflight, pre-tests, backup, deploy, verify]
if: always()
steps:
- name: Create deployment summary
run: |
echo "## Production Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Pre-flight | ${{ needs.preflight.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Pre-tests | ${{ needs.pre-tests.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Backup | ${{ needs.backup.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Deploy | ${{ needs.deploy.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Verify | ${{ needs.verify.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Details" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Environment | Production |" >> $GITHUB_STEP_SUMMARY
echo "| URL | ${{ env.PRODUCTION_URL }} |" >> $GITHUB_STEP_SUMMARY
echo "| Deployed | \`${{ needs.preflight.outputs.deploy_tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA | \`${{ needs.preflight.outputs.deploy_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY
echo "| Time | $(date -u '+%Y-%m-%d %H:%M:%S UTC') |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Overall status
if [ "${{ needs.verify.result }}" == "success" ]; then
echo "### Result: Deployment Successful" >> $GITHUB_STEP_SUMMARY
else
echo "### Result: Deployment Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Check the logs above for details. If automatic rollback occurred, the previous version should be running." >> $GITHUB_STEP_SUMMARY
fi
- name: Notify on failure
if: needs.deploy.result == 'failure' || needs.verify.result == 'failure'
run: |
echo "::error::Production deployment failed! Check logs for details."