name: Deploy to Production on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Version tag to deploy (e.g., v1.2.3)' required: false skip_tests: description: 'Skip E2E tests (emergency deploy)' required: false default: 'false' type: boolean env: NODE_VERSION: '22' PROD_SERVER_HOST: ${{ secrets.PROD_SERVER_HOST }} PROD_SERVER_USER: ${{ secrets.PROD_SERVER_USER }} PROD_SERVER_PATH: ${{ secrets.PROD_SERVER_PATH }} PROD_APP_URL: ${{ secrets.PROD_APP_URL }} jobs: # ============================================ # Pre-Deploy Checks # ============================================ pre-deploy-checks: name: Pre-Deploy Checks runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} should_deploy: ${{ steps.check.outputs.should_deploy }} steps: - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 0 - name: Determine version id: version run: | if [ -n "${{ github.event.inputs.version }}" ]; then VERSION="${{ github.event.inputs.version }}" elif [[ "${{ github.ref }}" == refs/tags/* ]]; then VERSION="${GITHUB_REF#refs/tags/}" else VERSION="prod-$(date +'%Y%m%d-%H%M%S')-${GITHUB_SHA::7}" fi echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Deploying version: $VERSION" - name: Check deployment conditions id: check run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [[ "${{ github.ref }}" == refs/tags/* ]]; then echo "should_deploy=true" >> $GITHUB_OUTPUT echo "Deployment approved: manual trigger or tag push" else echo "should_deploy=true" >> $GITHUB_OUTPUT echo "Deployment approved: version tag push" fi # ============================================ # Unit Tests # ============================================ unit-tests: name: Unit Tests runs-on: ubuntu-latest needs: pre-deploy-checks if: needs.pre-deploy-checks.outputs.should_deploy == 'true' services: mariadb: image: mariadb:10.11 env: MYSQL_ROOT_PASSWORD: testpassword MYSQL_DATABASE: tilbudgivern_test MYSQL_USER: testuser MYSQL_PASSWORD: testpassword ports: - 3306:3306 options: >- --health-cmd="mysqladmin ping -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install backend dependencies working-directory: backend run: npm ci - name: Wait for MariaDB run: | while ! mysqladmin ping -h"127.0.0.1" --silent; do sleep 1 done - name: Run backend unit tests working-directory: backend env: DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_USER: testuser DB_PASSWORD: testpassword DB_NAME: tilbudgivern_test NODE_ENV: test run: npm test -- --coverage - name: Upload coverage report uses: actions/upload-artifact@v7 with: name: backend-coverage path: backend/coverage retention-days: 3 # ============================================ # Security Scan # ============================================ security-scan: name: Security Scan runs-on: ubuntu-latest needs: pre-deploy-checks if: needs.pre-deploy-checks.outputs.should_deploy == 'true' steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: | cd frontend && npm ci cd ../backend && npm ci - name: Run npm audit (frontend) working-directory: frontend run: npm audit --audit-level=critical continue-on-error: true - name: Run npm audit (backend) working-directory: backend run: npm audit --audit-level=critical continue-on-error: true - name: Check for secrets in code run: | # Check for potential hardcoded secrets if grep -rE "(sk-[a-zA-Z0-9]{20,}|password\s*=\s*['\"][^'\"]+['\"])" --include="*.js" --include="*.ts" backend/src/ frontend/src/ 2>/dev/null | grep -v "process.env" | grep -v ".test.js" | grep -v "example"; then echo "Warning: Potential hardcoded secrets found" else echo "No hardcoded secrets detected" fi # ============================================ # Build for Production # ============================================ build: name: Build Production Package runs-on: ubuntu-latest needs: [pre-deploy-checks, unit-tests, security-scan] if: needs.pre-deploy-checks.outputs.should_deploy == 'true' steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: | npm ci cd frontend && npm ci cd ../backend && npm ci - name: Build frontend for production working-directory: frontend env: CI: false REACT_APP_ENV: production REACT_APP_VERSION: ${{ needs.pre-deploy-checks.outputs.version }} NODE_ENV: production run: npm run build - name: Create deployment package run: | mkdir -p deploy-package cp -r frontend/build deploy-package/frontend-build cp -r backend deploy-package/backend cp ecosystem.config.js deploy-package/ cp package.json deploy-package/ rm -rf deploy-package/backend/node_modules rm -rf deploy-package/backend/__tests__ rm -rf deploy-package/backend/src/__tests__ rm -rf deploy-package/backend/coverage echo "${{ needs.pre-deploy-checks.outputs.version }}" > deploy-package/VERSION echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" > deploy-package/DEPLOY_TIME - name: Upload deployment package uses: actions/upload-artifact@v7 with: name: deploy-package-prod path: deploy-package retention-days: 3 # ============================================ # Deploy to Production # ============================================ deploy: name: Deploy to Production Server runs-on: ubuntu-latest needs: [pre-deploy-checks, build] environment: name: production url: ${{ env.PROD_APP_URL }} steps: - name: Download deployment package uses: actions/download-artifact@v8 with: name: deploy-package-prod path: deploy-package - name: Setup SSH uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} - name: Add server to known hosts run: | mkdir -p ~/.ssh ssh-keyscan -H ${{ env.PROD_SERVER_HOST }} >> ~/.ssh/known_hosts - name: Create backup on server run: | ssh ${{ env.PROD_SERVER_USER }}@${{ env.PROD_SERVER_HOST }} << 'BACKUP_SCRIPT' set -e DEPLOY_PATH="${{ env.PROD_SERVER_PATH }}" BACKUP_PATH="$DEPLOY_PATH/backups/$(date +'%Y%m%d-%H%M%S')" echo "=== Creating backup ===" mkdir -p $BACKUP_PATH # Backup current release info if [ -L "$DEPLOY_PATH/current" ]; then CURRENT_VERSION=$(readlink $DEPLOY_PATH/current | xargs basename) echo "$CURRENT_VERSION" > $BACKUP_PATH/previous_version # Backup database (if mysqldump available) if command -v mysqldump &> /dev/null; then source $DEPLOY_PATH/shared/.env mysqldump -h${DB_HOST:-localhost} -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} > $BACKUP_PATH/database.sql 2>/dev/null || echo "Database backup skipped" fi fi # Keep only last 5 backups cd $DEPLOY_PATH/backups ls -t | tail -n +6 | xargs -r rm -rf echo "=== Backup complete ===" BACKUP_SCRIPT - name: Deploy to production server run: | VERSION="${{ needs.pre-deploy-checks.outputs.version }}" # Create deployment directory ssh ${{ env.PROD_SERVER_USER }}@${{ env.PROD_SERVER_HOST }} "mkdir -p ${{ env.PROD_SERVER_PATH }}/releases/$VERSION" # Upload deployment package scp -r deploy-package/* ${{ env.PROD_SERVER_USER }}@${{ env.PROD_SERVER_HOST }}:${{ env.PROD_SERVER_PATH }}/releases/$VERSION/ # Run deployment script on server ssh ${{ env.PROD_SERVER_USER }}@${{ env.PROD_SERVER_HOST }} << DEPLOY_SCRIPT set -e DEPLOY_PATH="${{ env.PROD_SERVER_PATH }}" VERSION="$VERSION" RELEASE_PATH="\$DEPLOY_PATH/releases/\$VERSION" echo "=== Deploying version: \$VERSION ===" # Install backend dependencies cd \$RELEASE_PATH/backend npm ci --production --ignore-scripts # Copy environment file cp \$DEPLOY_PATH/shared/.env \$RELEASE_PATH/backend/.env # Setup frontend directory structure mkdir -p \$RELEASE_PATH/frontend mv \$RELEASE_PATH/frontend-build \$RELEASE_PATH/frontend/build # Update symlink atomically ln -sfn \$RELEASE_PATH \$DEPLOY_PATH/current_new mv -Tf \$DEPLOY_PATH/current_new \$DEPLOY_PATH/current # Restart PM2 gracefully cd \$DEPLOY_PATH/current pm2 reload ecosystem.config.js --env production || pm2 start ecosystem.config.js --env production # Wait for server to be ready sleep 5 # Cleanup old releases (keep last 10) cd \$DEPLOY_PATH/releases ls -t | tail -n +11 | xargs -r rm -rf echo "=== Deployment complete ===" DEPLOY_SCRIPT # ============================================ # Post-Deploy Verification # ============================================ verify: name: Verify Production Deployment runs-on: ubuntu-latest needs: [pre-deploy-checks, deploy] steps: - name: Wait for server startup run: sleep 20 - name: Health check with retries run: | MAX_RETRIES=10 RETRY_COUNT=0 WAIT_TIME=10 while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.PROD_APP_URL }}/api/health || echo "000") if [ "$HTTP_STATUS" = "200" ]; then echo "Health check passed!" break fi RETRY_COUNT=$((RETRY_COUNT + 1)) echo "Health check failed (HTTP $HTTP_STATUS), retry $RETRY_COUNT/$MAX_RETRIES..." sleep $WAIT_TIME done if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then echo "Health check failed after $MAX_RETRIES retries" exit 1 fi - name: API endpoint verification run: | echo "Verifying API endpoints..." # Health endpoint echo "Testing /api/health..." curl -sf ${{ env.PROD_APP_URL }}/api/health || exit 1 # Materials endpoint echo "Testing /api/materials..." curl -sf ${{ env.PROD_APP_URL }}/api/materials > /dev/null || echo "Warning: materials endpoint issue" # Smart packages endpoint echo "Testing /api/smart-packages/..." curl -sf "${{ env.PROD_APP_URL }}/api/smart-packages/" > /dev/null || echo "Warning: smart-packages endpoint issue" # Quotes endpoint echo "Testing /api/quotes/completed..." curl -sf "${{ env.PROD_APP_URL }}/api/quotes/completed" > /dev/null || echo "Warning: quotes endpoint issue" echo "API verification complete!" - name: Frontend accessibility check run: | echo "Checking frontend accessibility..." HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.PROD_APP_URL }}/) if [ "$HTTP_STATUS" = "200" ]; then echo "Frontend accessible!" else echo "Warning: Frontend returned HTTP $HTTP_STATUS" fi # ============================================ # E2E Smoke Tests (Production) # ============================================ smoke-tests: name: Production Smoke Tests runs-on: ubuntu-latest needs: verify if: github.event.inputs.skip_tests != 'true' steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install test dependencies working-directory: tests run: npm ci - name: Install Playwright browsers working-directory: tests run: npx playwright install --with-deps chromium - name: Run smoke tests working-directory: tests env: PLAYWRIGHT_BASE_URL: ${{ env.PROD_APP_URL }} run: | # Run only critical smoke tests, not full suite npx playwright test --project=chromium --grep="@smoke|@critical" --reporter=html || \ npx playwright test --project=chromium --reporter=html --max-failures=3 continue-on-error: true - name: Upload test report uses: actions/upload-artifact@v7 if: always() with: name: prod-smoke-test-report path: tests/playwright-report retention-days: 3 - name: Upload test screenshots uses: actions/upload-artifact@v7 if: failure() with: name: prod-test-screenshots path: tests/test-results retention-days: 3 # ============================================ # Rollback Job (On Verification Failure) # ============================================ rollback: name: Rollback Production runs-on: ubuntu-latest if: failure() && needs.verify.result == 'failure' needs: [pre-deploy-checks, deploy, verify] steps: - name: Setup SSH uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} - name: Add server to known hosts run: | mkdir -p ~/.ssh ssh-keyscan -H ${{ env.PROD_SERVER_HOST }} >> ~/.ssh/known_hosts - name: Rollback to previous version run: | ssh ${{ env.PROD_SERVER_USER }}@${{ env.PROD_SERVER_HOST }} << 'ROLLBACK_SCRIPT' set -e DEPLOY_PATH="${{ env.PROD_SERVER_PATH }}" echo "=== Starting rollback ===" # Find previous release CURRENT=$(readlink $DEPLOY_PATH/current | xargs basename) PREVIOUS=$(ls -t $DEPLOY_PATH/releases | grep -v "$CURRENT" | head -1) if [ -z "$PREVIOUS" ]; then echo "No previous release found for rollback!" exit 1 fi echo "Rolling back from $CURRENT to $PREVIOUS" # Update symlink ln -sfn $DEPLOY_PATH/releases/$PREVIOUS $DEPLOY_PATH/current_rollback mv -Tf $DEPLOY_PATH/current_rollback $DEPLOY_PATH/current # Restart PM2 cd $DEPLOY_PATH/current pm2 reload ecosystem.config.js --env production echo "=== Rollback complete ===" ROLLBACK_SCRIPT - name: Notify rollback run: | echo "::error::ROLLBACK EXECUTED!" echo "Production deployment failed verification and was rolled back." echo "Please investigate the issue before redeploying." # ============================================ # Deployment Summary # ============================================ summary: name: Deployment Summary runs-on: ubuntu-latest needs: [pre-deploy-checks, unit-tests, security-scan, build, deploy, verify, smoke-tests] if: always() steps: - name: Create 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-Deploy Checks | ${{ needs.pre-deploy-checks.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Unit Tests | ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Security Scan | ${{ needs.security-scan.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Deploy | ${{ needs.deploy.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Verify | ${{ needs.verify.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Smoke Tests | ${{ needs.smoke-tests.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ needs.pre-deploy-checks.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**URL:** ${{ env.PROD_APP_URL }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Artifacts" >> $GITHUB_STEP_SUMMARY echo "- Backend coverage report" >> $GITHUB_STEP_SUMMARY echo "- Deployment package" >> $GITHUB_STEP_SUMMARY echo "- Smoke test report" >> $GITHUB_STEP_SUMMARY