diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 069a0b2..481f270 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -156,3 +156,118 @@ jobs: - name: Cleanup run: docker image prune -f + + # โ”€โ”€ Job 3: Deploy to production (only on production branch, after tests pass) โ”€โ”€ + deploy-production: + needs: test-build + if: github.ref == 'refs/heads/production' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + echo "๐Ÿ—๏ธ Building production Docker image..." + DOCKER_BUILDKIT=1 docker build \ + --cache-from ${{ env.DOCKER_IMAGE }}:production \ + --cache-from ${{ env.DOCKER_IMAGE }}:latest \ + -t ${{ env.DOCKER_IMAGE }}:production \ + -t ${{ env.DOCKER_IMAGE }}:latest \ + . + echo "โœ… Docker image built successfully" + + - name: Deploy production container + run: | + echo "๐Ÿš€ Starting production deployment..." + + COMPOSE_FILE="docker-compose.production.yml" + CONTAINER_NAME="portfolio-app" + HEALTH_PORT="3000" + + # Backup current container ID + OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "") + + # Ensure network exists + docker network create portfolio_net 2>/dev/null || true + + # Export variables for docker-compose + export N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL}" + export N8N_SECRET_TOKEN="${N8N_SECRET_TOKEN}" + export N8N_API_KEY="${N8N_API_KEY}" + export MY_EMAIL="${MY_EMAIL}" + export MY_INFO_EMAIL="${MY_INFO_EMAIL}" + export MY_PASSWORD="${MY_PASSWORD}" + export MY_INFO_PASSWORD="${MY_INFO_PASSWORD}" + export ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}" + export ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}" + export DIRECTUS_URL="${DIRECTUS_URL}" + export DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}" + + # Start new container via compose + echo "๐Ÿ†• Starting new production container..." + docker compose -f $COMPOSE_FILE up -d portfolio + + # Wait for health + echo "โณ Waiting for container to be healthy..." + HEALTH_CHECK_PASSED=false + for i in {1..90}; do + NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) + if [ -z "$NEW_CONTAINER" ]; then + NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") + fi + if [ ! -z "$NEW_CONTAINER" ]; then + HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") + if [ "$HEALTH" == "healthy" ]; then + echo "โœ… Production container is healthy!" + HEALTH_CHECK_PASSED=true + break + fi + if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then + echo "โœ… Production HTTP health check passed!" + HEALTH_CHECK_PASSED=true + break + fi + fi + if [ $((i % 15)) -eq 0 ]; then + echo "๐Ÿ“Š Health: ${HEALTH:-unknown} (attempt $i/90)" + docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true + fi + sleep 2 + done + + if [ "$HEALTH_CHECK_PASSED" != "true" ]; then + echo "โŒ Production health check failed!" + docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || true + exit 1 + fi + + # Remove old container if different + if [ ! -z "$OLD_CONTAINER" ]; then + NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") + if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then + echo "๐Ÿงน Removing old container..." + docker stop $OLD_CONTAINER 2>/dev/null || true + docker rm $OLD_CONTAINER 2>/dev/null || true + fi + fi + + echo "โœ… Production deployment completed!" + env: + NODE_ENV: production + LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} + MY_EMAIL: ${{ vars.MY_EMAIL }} + MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} + MY_PASSWORD: ${{ secrets.MY_PASSWORD }} + MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} + ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} + N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} + N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} + N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} + DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }} + DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }} + + - name: Cleanup + run: docker image prune -f diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml deleted file mode 100644 index 487a518..0000000 --- a/.gitea/workflows/production-deploy.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Production Deployment (Zero Downtime) - -on: - push: - branches: [ production ] - -env: - NODE_VERSION: '25' - DOCKER_IMAGE: portfolio-app - IMAGE_TAG: production - -jobs: - deploy-production: - runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run linting and tests in parallel - run: | - npm run lint & - LINT_PID=$! - npm run test:production & - TEST_PID=$! - wait $LINT_PID $TEST_PID - - - name: Build application - run: npm run build - - - name: Build Docker image - run: | - echo "๐Ÿ—๏ธ Building production Docker image with BuildKit cache..." - DOCKER_BUILDKIT=1 docker build \ - --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - --cache-from ${{ env.DOCKER_IMAGE }}:latest \ - -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - -t ${{ env.DOCKER_IMAGE }}:latest \ - . - echo "โœ… Docker image built successfully" - - - name: Zero-Downtime Production Deployment - run: | - echo "๐Ÿš€ Starting zero-downtime production deployment..." - - COMPOSE_FILE="docker-compose.production.yml" - CONTAINER_NAME="portfolio-app" - HEALTH_PORT="3000" - - # Backup current container ID if running (exact name match to avoid staging) - OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "") - - # Export environment variables for docker-compose - export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}" - export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}" - export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}" - - # Also export other variables that docker-compose needs - export MY_EMAIL="${{ vars.MY_EMAIL }}" - export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" - export MY_PASSWORD="${{ secrets.MY_PASSWORD }}" - export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" - export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" - export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" - export DIRECTUS_URL="${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}" - export DIRECTUS_STATIC_TOKEN="${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}" - - # Ensure the shared network exists before compose tries to use it - docker network create portfolio_net 2>/dev/null || true - - # Start new container with updated image (docker-compose will handle this) - echo "๐Ÿ†• Starting new production container..." - echo "๐Ÿ“ Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}" - docker compose -f $COMPOSE_FILE up -d portfolio - - # Wait for new container to be healthy - echo "โณ Waiting for new container to be healthy..." - HEALTH_CHECK_PASSED=false - for i in {1..90}; do - # Get the production container ID (exact name match, exclude staging) - # Use compose project to ensure we get the right container - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - # Fallback: try exact name match with leading slash - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ]; then - # Verify it's actually the production container by checking compose project label - CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "") - CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "") - if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then - # Check Docker health status first (most reliable) - HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") - if [ "$HEALTH" == "healthy" ]; then - echo "โœ… New container is healthy (Docker health check)!" - # Also verify HTTP endpoint from inside container - if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then - echo "โœ… Container HTTP endpoint is also responding!" - HEALTH_CHECK_PASSED=true - break - else - echo "โš ๏ธ Docker health check passed, but HTTP endpoint test failed. Continuing..." - fi - fi - # Try HTTP health endpoint from host (may not work if port not mapped yet) - if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then - echo "โœ… New container is responding to HTTP health check from host!" - HEALTH_CHECK_PASSED=true - break - fi - # Show container status for debugging - if [ $((i % 10)) -eq 0 ]; then - echo "๐Ÿ“Š Container ID: $NEW_CONTAINER" - echo "๐Ÿ“Š Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')" - echo "๐Ÿ“Š Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')" - echo "๐Ÿ“Š Health status: $HEALTH" - echo "๐Ÿ“Š Testing from inside container:" - docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed" - docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true - fi - else - echo "โš ๏ธ Found container but it's not from production compose file (skipping): $NEW_CONTAINER" - fi - fi - echo "โณ Waiting... ($i/90)" - sleep 2 - done - - # Final verification: Check Docker health status (most reliable) - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ]; then - FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") - if [ "$FINAL_HEALTH" == "healthy" ]; then - echo "โœ… Final verification: Container is healthy!" - HEALTH_CHECK_PASSED=true - fi - fi - - # Verify new container is working - if [ "$HEALTH_CHECK_PASSED" != "true" ]; then - echo "โŒ New container failed health check!" - echo "๐Ÿ“‹ All running containers with 'portfolio' in name:" - docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}" - echo "๐Ÿ“‹ Production container from compose:" - docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose" - echo "๐Ÿ“‹ Container logs:" - docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs" - - # Get the correct container ID - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - - if [ ! -z "$NEW_CONTAINER" ]; then - echo "๐Ÿ“‹ Container inspect (ID: $NEW_CONTAINER):" - docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found" - echo "๐Ÿ“‹ Testing health endpoint from inside container:" - docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" - - # Check Docker health status - if it's healthy, accept it - FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") - if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then - echo "โœ… Docker health check reports healthy - accepting deployment!" - HEALTH_CHECK_PASSED=true - else - echo "โŒ Docker health check also reports: $FINAL_HEALTH_CHECK" - exit 1 - fi - else - echo "โš ๏ธ Could not find production container!" - exit 1 - fi - fi - - # Remove old container if it exists and is different - if [ ! -z "$OLD_CONTAINER" ]; then - # Get the new production container ID - NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then - echo "๐Ÿงน Removing old container..." - docker stop $OLD_CONTAINER 2>/dev/null || true - docker rm $OLD_CONTAINER 2>/dev/null || true - fi - fi - - echo "โœ… Production deployment completed with zero downtime!" - env: - NODE_ENV: production - LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} - MY_EMAIL: ${{ vars.MY_EMAIL }} - MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} - MY_PASSWORD: ${{ secrets.MY_PASSWORD }} - MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} - ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} - ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} - N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} - N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} - N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} - - - name: Production Health Check - run: | - echo "๐Ÿ” Running production health checks..." - COMPOSE_FILE="docker-compose.production.yml" - CONTAINER_NAME="portfolio-app" - - # Get the production container ID - CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$CONTAINER_ID" ]; then - CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - - if [ -z "$CONTAINER_ID" ]; then - echo "โŒ Production container not found!" - docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" - exit 1 - fi - - echo "๐Ÿ“ฆ Found container: $CONTAINER_ID" - - # Wait for container to be healthy (using Docker's health check) - HEALTH_CHECK_PASSED=false - for i in {1..30}; do - HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") - STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown") - - if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then - echo "โœ… Container is healthy and running!" - - # Test from inside the container (most reliable) - if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then - echo "โœ… Health endpoint responds from inside container!" - HEALTH_CHECK_PASSED=true - break - else - echo "โš ๏ธ Container is healthy but HTTP endpoint test failed. Retrying..." - fi - fi - - if [ $((i % 5)) -eq 0 ]; then - echo "๐Ÿ“Š Status: $STATUS, Health: $HEALTH (attempt $i/30)" - fi - - echo "โณ Waiting for production... ($i/30)" - sleep 2 - done - - if [ "$HEALTH_CHECK_PASSED" != "true" ]; then - echo "โŒ Production health check failed!" - echo "๐Ÿ“‹ Container status:" - docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container" - echo "๐Ÿ“‹ Container logs:" - docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs" - echo "๐Ÿ“‹ Testing from inside container:" - docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" - exit 1 - fi - - echo "โœ… Production is fully operational!" - - - name: Cleanup - run: | - echo "๐Ÿงน Cleaning up old images..." - docker image prune -f - echo "โœ… Cleanup completed"