The prod deploy just failed at Unit Tests even though all 226 tests passed - the job died on `Upload coverage report` hitting the GitHub Actions artifact storage quota. ci.yml already treats artifact uploads as best-effort for this exact reason (comment: "Artifact upload must never fail the job (storage quota can be full)"), but deploy-prod.yml and deploy-test.yml were missing the same continue-on-error on their diagnostic report/screenshot/coverage uploads. Leaves the actual deployable build artifact uploads (deploy-package-*) blocking, since a failed deploy really should stop there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
538 lines
18 KiB
YAML
538 lines
18 KiB
YAML
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
|