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') 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."