name: Dev Deployment (Zero Downtime) on: push: branches: [ dev ] env: NODE_VERSION: '20' DOCKER_IMAGE: portfolio-app IMAGE_TAG: dev jobs: deploy-dev: 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 run: npm run lint continue-on-error: true # Don't block dev deployments on lint errors - name: Run tests run: npm run test continue-on-error: true # Don't block dev deployments on test failures - name: Build application run: npm run build - name: Build Docker image run: | echo "๐Ÿ—๏ธ Building dev 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 }} \ . echo "โœ… Docker image built successfully" - name: Zero-Downtime Dev Deployment run: | echo "๐Ÿš€ Starting zero-downtime dev deployment..." CONTAINER_NAME="portfolio-app-dev" HEALTH_PORT="3001" IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}" # Check for existing container (running or stopped) EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") # Start DB and Redis if not running echo "๐Ÿ—„๏ธ Starting database and Redis..." COMPOSE_FILE="docker-compose.dev.minimal.yml" # Check if DB container exists if ! docker ps -a --format "{{.Names}}" | grep -q "portfolio_postgres_dev"; then echo "๐Ÿ“ฆ Starting PostgreSQL container..." docker compose -f $COMPOSE_FILE up -d postgres else echo "โœ… PostgreSQL container exists, ensuring it's running..." docker start portfolio_postgres_dev 2>/dev/null || docker compose -f $COMPOSE_FILE up -d postgres fi # Check if Redis container exists if ! docker ps -a --format "{{.Names}}" | grep -q "portfolio_redis_dev"; then echo "๐Ÿ“ฆ Starting Redis container..." docker compose -f $COMPOSE_FILE up -d redis else echo "โœ… Redis container exists, ensuring it's running..." docker start portfolio_redis_dev 2>/dev/null || docker compose -f $COMPOSE_FILE up -d redis fi # Wait for DB to be ready echo "โณ Waiting for database to be ready..." for i in {1..30}; do if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then echo "โœ… Database is ready!" break fi echo "โณ Waiting for database... ($i/30)" sleep 1 done # Export environment variables export NODE_ENV=production export LOG_LEVEL=${LOG_LEVEL:-debug} export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public" export REDIS_URL="redis://portfolio_redis_dev:6379" 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 N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} export PORT=${HEALTH_PORT} # Stop and remove existing container if it exists (running or stopped) if [ ! -z "$EXISTING_CONTAINER" ]; then echo "๐Ÿ›‘ Stopping and removing existing container..." docker stop $EXISTING_CONTAINER 2>/dev/null || true docker rm $EXISTING_CONTAINER 2>/dev/null || true echo "โœ… Old container removed" # Wait for Docker to release the port echo "โณ Waiting for Docker to release port ${HEALTH_PORT}..." sleep 3 fi # Check if port is still in use by Docker containers (check all containers, not just running) PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") if [ ! -z "$PORT_CONTAINER" ]; then echo "โš ๏ธ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER" echo "๐Ÿ›‘ Stopping and removing container using port..." docker stop $PORT_CONTAINER 2>/dev/null || true docker rm $PORT_CONTAINER 2>/dev/null || true sleep 3 fi # Also check for any containers with the same name that might be using the port SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "") if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then echo "โš ๏ธ Found another container with same name: $SAME_NAME_CONTAINER" docker stop $SAME_NAME_CONTAINER 2>/dev/null || true docker rm $SAME_NAME_CONTAINER 2>/dev/null || true sleep 2 fi # Also check if port is in use by another process (non-Docker) PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "") if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then echo "โš ๏ธ Port ${HEALTH_PORT} is in use by process" echo "Attempting to free the port..." # Try to find and kill the process if command -v lsof >/dev/null 2>&1; then PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") if [ ! -z "$PID" ]; then kill -9 $PID 2>/dev/null || true sleep 2 fi fi fi # Final check: verify port is free and wait if needed echo "๐Ÿ” Verifying port ${HEALTH_PORT} is free..." MAX_WAIT=10 WAIT_COUNT=0 while [ $WAIT_COUNT -lt $MAX_WAIT ]; do PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") if [ -z "$PORT_CHECK" ]; then # Also check with lsof/ss if available if command -v lsof >/dev/null 2>&1; then PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") elif command -v ss >/dev/null 2>&1; then PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "") fi fi if [ -z "$PORT_CHECK" ]; then echo "โœ… Port ${HEALTH_PORT} is free!" break fi WAIT_COUNT=$((WAIT_COUNT + 1)) echo "โณ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)" sleep 1 done # If port is still in use, try alternative port if [ $WAIT_COUNT -ge $MAX_WAIT ]; then echo "โš ๏ธ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..." HEALTH_PORT="3002" echo "๐Ÿ”„ Using alternative port: ${HEALTH_PORT}" # Quick check if alternative port is also in use ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") if [ ! -z "$ALT_PORT_CHECK" ]; then echo "โŒ Alternative port ${HEALTH_PORT} is also in use!" echo "Attempting to free alternative port..." ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") if [ ! -z "$ALT_CONTAINER" ]; then docker stop $ALT_CONTAINER 2>/dev/null || true docker rm $ALT_CONTAINER 2>/dev/null || true sleep 2 fi fi fi # Ensure networks exist echo "๐ŸŒ Checking for networks..." if ! docker network inspect proxy >/dev/null 2>&1; then echo "โš ๏ธ Proxy network not found, creating it..." docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed" else echo "โœ… Proxy network exists" fi if ! docker network inspect portfolio_dev >/dev/null 2>&1; then echo "โš ๏ธ Portfolio dev network not found, creating it..." docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed" else echo "โœ… Portfolio dev network exists" fi # Connect proxy network to portfolio_dev network if needed # (This allows the app to access both proxy and DB/Redis) # Start new container with updated image echo "๐Ÿ†• Starting new dev container..." docker run -d \ --name $CONTAINER_NAME \ --restart unless-stopped \ --network portfolio_dev \ -p ${HEALTH_PORT}:3000 \ -e NODE_ENV=production \ -e LOG_LEVEL=${LOG_LEVEL:-debug} \ -e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \ -e DATABASE_URL=${DATABASE_URL} \ -e REDIS_URL=${REDIS_URL} \ -e MY_EMAIL=${MY_EMAIL} \ -e MY_INFO_EMAIL=${MY_INFO_EMAIL} \ -e MY_PASSWORD=${MY_PASSWORD} \ -e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \ -e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \ -e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \ -e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \ -e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \ $IMAGE_NAME # Connect container to proxy network as well (for external access) echo "๐Ÿ”— Connecting container to proxy network..." docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network" # Wait for new container to be healthy echo "โณ Waiting for new container to be healthy..." HEALTH_CHECK_PASSED=false for i in {1..60}; do NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME) if [ ! -z "$NEW_CONTAINER" ]; then # Check Docker health status HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") if [ "$HEALTH" == "healthy" ]; then echo "โœ… New container is healthy!" HEALTH_CHECK_PASSED=true break fi # Also check HTTP health endpoint if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then echo "โœ… New container is responding!" HEALTH_CHECK_PASSED=true break fi fi echo "โณ Waiting... ($i/60)" sleep 2 done # Verify new container is working if [ "$HEALTH_CHECK_PASSED" != "true" ]; then echo "โš ๏ธ New dev container health check failed, but continuing (non-blocking)..." docker logs $CONTAINER_NAME --tail=50 fi # Remove old container if it exists and is different if [ ! -z "$OLD_CONTAINER" ]; then NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME) if [ "$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 "โœ… Dev deployment completed!" env: NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public REDIS_URL: redis://portfolio_redis_dev:6379 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 || '' }} - name: Dev Health Check run: | echo "๐Ÿ” Running dev health checks..." for i in {1..20}; do if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then echo "โœ… Dev is fully operational!" exit 0 fi echo "โณ Waiting for dev... ($i/20)" sleep 3 done echo "โš ๏ธ Dev health check failed, but continuing (non-blocking)..." docker logs portfolio-app-dev --tail=50 - name: Cleanup run: | echo "๐Ÿงน Cleaning up old images..." docker image prune -f echo "โœ… Cleanup completed"