diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml deleted file mode 100644 index 7887c23..0000000 --- a/.github/workflows/deploy-prod.yml +++ /dev/null @@ -1,537 +0,0 @@ -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 - continue-on-error: true - 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() - # Artifact upload must never fail the job (storage quota can be full) - continue-on-error: true - with: - name: prod-smoke-test-report - path: tests/playwright-report - retention-days: 3 - - - name: Upload test screenshots - uses: actions/upload-artifact@v7 - if: failure() - # Artifact upload must never fail the job (storage quota can be full) - continue-on-error: true - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 456c4d5..d4fcc00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,14 +123,29 @@ The application uses a unified server (`backend/unified-server.js`) that: - Admin endpoints: `/api/admin/logs` ## Deployment + +**This machine is the production server.** There is no separate deploy target: `backend/unified-server.js` runs directly out of this git working directory via PM2, serving the built frontend from `frontend/build/`. There is no CI/CD pipeline that deploys automatically on merge — deploying means running these steps here, on this host, after `main` has the commit you want live. + +```bash +git checkout main && git pull # Get the commit you want live +cd frontend && npm run build # Rebuild the served static frontend +cd ../backend && npm ci --omit=dev # Sync backend deps (skip if unchanged) +pm2 restart tilbudgivern-unified # Restart the running process +pm2 logs tilbudgivern-unified --lines 30 --nostream # Confirm a clean startup (no errors) +curl -sf http://localhost:4032/api/health # Should return {"status":"ok",...} +curl -sI https://tilbudsgiveren.alw.dk # Should return HTTP/2 200 +``` + +Other useful commands: ```bash -pm2 restart tilbudgivern-unified # Restart after code changes -pm2 logs tilbudgivern-unified # View live logs pm2 status # Check process status +pm2 logs tilbudgivern-unified # Tail live logs ``` Daily database backups configured via cron at 02:00, retention: 7 days. +Note: `.github/workflows/deploy-prod.yml` (an SSH/rsync-to-a-remote-server pipeline) was removed — it targeted a `PROD_SERVER_HOST`/`PROD_SERVER_USER`/`PROD_SERVER_PATH`/`PROD_SSH_PRIVATE_KEY` setup that was never configured (no repo or `production`-environment secrets ever existed), and every historical run of it failed. If a real separate deploy target is ever set up, that pipeline's steps (versioned releases dir, atomic symlink swap, pre-deploy DB backup, health-check retries, smoke tests) are a reasonable starting point to resurrect from git history. + ## Danish Terminology - Tilbud = Quote/Offer - Tømrer = Carpenter