chore: retire deploy-prod.yml, document the real PM2 deploy path

deploy-prod.yml targeted a remote SSH deploy server via
PROD_SERVER_HOST/PROD_SERVER_USER/PROD_SERVER_PATH/PROD_SSH_PRIVATE_KEY,
but none of those secrets were ever configured (repo and production
environment secret lists are both empty) and every historical run of
the workflow failed as a result.

The actual production server is this machine: unified-server.js runs
directly out of this working directory via PM2. Removed the dead
pipeline and documented the real manual deploy steps (pull, rebuild
frontend, sync backend deps, pm2 restart, verify) in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-20 14:14:57 +02:00
parent 896d90a383
commit 288346c21e
2 changed files with 17 additions and 539 deletions

View File

@@ -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

View File

@@ -123,14 +123,29 @@ The application uses a unified server (`backend/unified-server.js`) that:
- Admin endpoints: `/api/admin/logs` - Admin endpoints: `/api/admin/logs`
## Deployment ## 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 ```bash
pm2 restart tilbudgivern-unified # Restart after code changes
pm2 logs tilbudgivern-unified # View live logs
pm2 status # Check process status pm2 status # Check process status
pm2 logs tilbudgivern-unified # Tail live logs
``` ```
Daily database backups configured via cron at 02:00, retention: 7 days. 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 ## Danish Terminology
- Tilbud = Quote/Offer - Tilbud = Quote/Offer
- Tømrer = Carpenter - Tømrer = Carpenter