From 2db9018477224377c73d5c601ae7da52ca001a59 Mon Sep 17 00:00:00 2001 From: denshooter Date: Wed, 4 Mar 2026 15:13:47 +0100 Subject: [PATCH] refactor: combine CI and dev-deploy into single workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job 1 (test-build): lint, test, build — runs on all branches/PRs Job 2 (deploy-dev): Docker build + deploy — only on dev, after tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/ci.yml | 132 ++++++++++++++- .gitea/workflows/dev-deploy.yml | 278 -------------------------------- 2 files changed, 129 insertions(+), 281 deletions(-) delete mode 100644 .gitea/workflows/dev-deploy.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dce0be2..069a0b2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Gitea CI +name: CI / CD on: push: @@ -6,7 +6,12 @@ on: pull_request: branches: [main, dev, production] +env: + NODE_VERSION: '25' + DOCKER_IMAGE: portfolio-app + jobs: + # ── Job 1: Lint, Test, Build (runs on every push/PR) ── test-build: runs-on: ubuntu-latest steps: @@ -16,10 +21,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - name: Install deps + - name: Install dependencies run: npm ci - name: Lint @@ -30,3 +35,124 @@ jobs: - name: Build run: npm run build + + # ── Job 2: Deploy to dev (only on dev branch, after tests pass) ── + deploy-dev: + needs: test-build + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + echo "🏗️ Building dev Docker image..." + DOCKER_BUILDKIT=1 docker build \ + --cache-from ${{ env.DOCKER_IMAGE }}:dev \ + --cache-from ${{ env.DOCKER_IMAGE }}:latest \ + -t ${{ env.DOCKER_IMAGE }}:dev \ + . + echo "✅ Docker image built successfully" + + - name: Deploy dev container + run: | + echo "🚀 Starting dev deployment..." + + CONTAINER_NAME="portfolio-app-dev" + HEALTH_PORT="3001" + IMAGE_NAME="${{ env.DOCKER_IMAGE }}:dev" + + # Check for existing container + EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") + + # Ensure networks exist + echo "🌐 Ensuring networks exist..." + docker network create portfolio_net 2>/dev/null || true + docker network create proxy 2>/dev/null || true + + # Verify production DB is reachable + if docker exec portfolio-postgres pg_isready -U portfolio_user -d portfolio_db >/dev/null 2>&1; then + echo "✅ Production database is ready!" + else + echo "⚠️ Production database not reachable, app will use fallbacks" + fi + + # Stop and remove existing container + if [ ! -z "$EXISTING_CONTAINER" ]; then + echo "🛑 Stopping existing container..." + docker stop $EXISTING_CONTAINER 2>/dev/null || true + docker rm $EXISTING_CONTAINER 2>/dev/null || true + sleep 3 + fi + + # Ensure port is free + PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->)" | awk '{print $1}' | head -1 || echo "") + if [ ! -z "$PORT_CONTAINER" ]; then + echo "⚠️ Port ${HEALTH_PORT} still in use, freeing..." + docker stop $PORT_CONTAINER 2>/dev/null || true + docker rm $PORT_CONTAINER 2>/dev/null || true + sleep 3 + fi + + # Start new container + echo "🆕 Starting new dev container..." + docker run -d \ + --name $CONTAINER_NAME \ + --restart unless-stopped \ + --network portfolio_net \ + -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 to proxy network + docker network connect proxy $CONTAINER_NAME 2>/dev/null || true + + # Wait for health + echo "⏳ Waiting for container to be healthy..." + for i in {1..60}; do + if curl -f -s http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then + echo "✅ Dev container is healthy!" + break + fi + HEALTH=$(docker inspect $CONTAINER_NAME --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") + if [ "$HEALTH" == "healthy" ]; then + echo "✅ Docker health check passed!" + break + fi + if [ $i -eq 60 ]; then + echo "⚠️ Health check timed out, showing logs:" + docker logs $CONTAINER_NAME --tail=30 + fi + sleep 2 + done + + echo "✅ Dev deployment completed!" + env: + 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_pass@portfolio-postgres:5432/portfolio_db?schema=public + REDIS_URL: redis://portfolio-redis: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: Cleanup + run: docker image prune -f diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml deleted file mode 100644 index 06d3d26..0000000 --- a/.gitea/workflows/dev-deploy.yml +++ /dev/null @@ -1,278 +0,0 @@ -name: Dev Deployment (Zero Downtime) - -on: - push: - branches: [ dev ] - -env: - NODE_VERSION: '25' - 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 "") - - # Reuse production PostgreSQL and Redis (no separate dev instances needed) - echo "🗄️ Verifying production database and Redis are running..." - - # Ensure networks exist - echo "🌐 Ensuring networks exist..." - docker network create portfolio_net 2>/dev/null || true - docker network create proxy 2>/dev/null || true - - # Verify production DB is reachable - if docker exec portfolio-postgres pg_isready -U portfolio_user -d portfolio_db >/dev/null 2>&1; then - echo "✅ Production database is ready!" - else - echo "⚠️ Production database not reachable, app will use fallbacks" - fi - - # Export environment variables (pointing to production DB/Redis) - 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_pass@portfolio-postgres:5432/portfolio_db?schema=public" - export REDIS_URL="redis://portfolio-redis: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 - - # Start new container with updated image - echo "🆕 Starting new dev container..." - docker run -d \ - --name $CONTAINER_NAME \ - --restart unless-stopped \ - --network portfolio_net \ - -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_pass@portfolio-postgres:5432/portfolio_db?schema=public - REDIS_URL: redis://portfolio-redis: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"