# .gitea/workflows/ci.yaml # ============================================ # Chat Switchboard - CI/CD Pipeline # ============================================ # Cluster deployments use SEPARATE FE + BE images. # Unified image is for Docker Hub only (Docker Desktop use). # # Deployment mapping: # PR → FE + BE :dev → wipe/migrate → dev.DOMAIN # Push to main → FE + BE :test → migrate → test.DOMAIN # Push to main → FE-only :lite → (no DB) → lite.DOMAIN # Tag v* → FE + BE :latest → migrate → www.DOMAIN # → Unified :latest → Docker Hub only (not deployed) # # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST # # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # # Global Variables (Gitea org-level): # CERT_ISSUER_PROD # ============================================ name: CI/CD on: pull_request: types: [opened, synchronize, reopened] push: branches: [main] tags: ['v*'] concurrency: group: ${{ gitea.workflow }}-${{ gitea.event.pull_request.number || gitea.ref }} cancel-in-progress: true env: REGISTRY: ${{ vars.REGISTRY }} NAMESPACE: ${{ vars.NAMESPACE || 'gobha-ai' }} DOMAIN: ${{ vars.DOMAIN || 'gobha.ai' }} CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }} POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }} POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }} FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be UNIFIED_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }} jobs: # ── Stage 1: Go Build & Test ───────────────── test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: '1.22' - name: Download and tidy working-directory: server run: | go mod download go mod tidy - name: Run tests working-directory: server run: go test -v -race ./... - name: Build check working-directory: server run: CGO_ENABLED=0 go build -o /dev/null . # ── Stage 2: Build, Database, Deploy ───────── build-and-deploy: runs-on: ubuntu-latest needs: test steps: - name: Checkout uses: actions/checkout@v4 - name: Add /tools to PATH run: echo "/tools" >> $GITHUB_PATH - name: Install tools run: | apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null # ── Determine environment ────────────────── - name: Determine environment id: env run: | if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT" echo "DEPLOY_HOST=dev.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT" echo "DB_WIPE=true" >> "$GITHUB_OUTPUT" echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT" echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT" elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then VERSION="${{ gitea.ref_name }}" echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT" echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT" echo "DEPLOY_HOST=www.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "FE_REPLICAS=2" >> "$GITHUB_OUTPUT" echo "BE_REPLICAS=2" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" echo "FE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "FE_CPU_LIMIT=200m" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "BE_CPU_REQUEST=100m" >> "$GITHUB_OUTPUT" echo "BE_CPU_LIMIT=500m" >> "$GITHUB_OUTPUT" echo "is_release=true" >> "$GITHUB_OUTPUT" echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT" else echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT" echo "DEPLOY_HOST=test.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "FE_REPLICAS=1" >> "$GITHUB_OUTPUT" echo "BE_REPLICAS=1" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_REQUEST=64Mi" >> "$GITHUB_OUTPUT" echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "env_label=test (main)" >> "$GITHUB_OUTPUT" fi # ── Database Bootstrap ─────────────────────── - name: Bootstrap database env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }} PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} APP_USER: ${{ secrets.POSTGRES_USER }} APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} DB_NAME: ${{ steps.env.outputs.DB_NAME }} run: | chmod +x scripts/db-bootstrap.sh scripts/db-bootstrap.sh # ── Database Migration ─────────────────────── - name: Run migrations env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} DB_NAME: ${{ steps.env.outputs.DB_NAME }} DB_WIPE: ${{ steps.env.outputs.DB_WIPE }} run: | chmod +x scripts/db-migrate.sh scripts/db-migrate.sh # ── Build Backend Image ────────────────────── - name: Build backend image run: | docker build -f server/Dockerfile \ -t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ ./server # ── Build Frontend Image (cluster mode) ────── - name: Build frontend image run: | docker build -f Dockerfile.frontend \ -t ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ . # ── Tag release versions ───────────────────── - name: Tag release versions if: steps.env.outputs.EXTRA_TAG != '' run: | docker tag ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} docker tag ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} # ── Push to Internal Registry ──────────────── - name: Push images run: | docker push ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} docker push ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} if [[ -n "${{ steps.env.outputs.EXTRA_TAG }}" ]]; then docker push ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} fi # ── Build + Push Unified (release only) ────── - name: Build unified image if: steps.env.outputs.is_release == 'true' run: | docker build -f Dockerfile.unified \ -t ${UNIFIED_IMAGE}:latest \ -t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \ . docker push ${UNIFIED_IMAGE}:latest docker push ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} - name: Push unified to Docker Hub if: steps.env.outputs.is_release == 'true' && secrets.DOCKERHUB_TOKEN != '' continue-on-error: true run: | echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin docker tag ${UNIFIED_IMAGE}:latest ${DOCKERHUB_IMAGE}:latest docker tag ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} docker push ${DOCKERHUB_IMAGE}:latest docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} # ── Deploy to Kubernetes ───────────────────── - name: Ensure namespace run: | kubectl create namespace ${NAMESPACE} 2>/dev/null || true - name: Sync secrets run: | kubectl create secret generic switchboard-db-credentials \ --namespace=${NAMESPACE} \ --from-literal=POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ --from-literal=POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ --dry-run=client -o yaml | kubectl apply -f - - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} DB_NAME: ${{ steps.env.outputs.DB_NAME }} FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }} BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }} FE_MEMORY_REQUEST: ${{ steps.env.outputs.FE_MEMORY_REQUEST }} FE_MEMORY_LIMIT: ${{ steps.env.outputs.FE_MEMORY_LIMIT }} FE_CPU_REQUEST: ${{ steps.env.outputs.FE_CPU_REQUEST }} FE_CPU_LIMIT: ${{ steps.env.outputs.FE_CPU_LIMIT }} BE_MEMORY_REQUEST: ${{ steps.env.outputs.BE_MEMORY_REQUEST }} BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }} BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }} BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }} run: | envsubst < k8s/backend.yaml > /tmp/backend.yaml envsubst < k8s/frontend.yaml > /tmp/frontend.yaml envsubst < k8s/ingress.yaml > /tmp/ingress.yaml echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━" kubectl apply -f /tmp/backend.yaml kubectl apply -f /tmp/frontend.yaml kubectl apply -f /tmp/ingress.yaml - name: Rollout and verify run: | SUFFIX="${{ steps.env.outputs.DEPLOY_SUFFIX }}" ENV="${{ steps.env.outputs.ENVIRONMENT }}" kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} kubectl rollout status deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} --timeout=180s kubectl rollout status deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} --timeout=180s echo "" echo "━━━ Pod Status ━━━" kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV} BE_READY=$(kubectl get deployment switchboard-be${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}') FE_READY=$(kubectl get deployment switchboard-fe${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}') if [[ "${BE_READY:-0}" -gt 0 ]] && [[ "${FE_READY:-0}" -gt 0 ]]; then echo "✅ Healthy: BE=${BE_READY} FE=${FE_READY} pods ready" else echo "❌ Unhealthy: BE=${BE_READY:-0} FE=${FE_READY:-0}" kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30 exit 1 fi - name: Summary run: | HOST="${{ steps.env.outputs.DEPLOY_HOST }}" echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| | |" >> $GITHUB_STEP_SUMMARY echo "|---|---|" >> $GITHUB_STEP_SUMMARY echo "| **URL** | https://${HOST} |" >> $GITHUB_STEP_SUMMARY echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY fi # ── Stage 3: FE-Only Lite Deploy (main only) ─ deploy-lite: runs-on: ubuntu-latest if: gitea.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 - name: Add /tools to PATH run: echo "/tools" >> $GITHUB_PATH - name: Install tools run: | apt-get update -qq && apt-get install -y -qq gettext-base > /dev/null - name: Build lite image run: | docker build -f Dockerfile.frontend \ -t ${FE_IMAGE}:lite \ . - name: Push lite image run: docker push ${FE_IMAGE}:lite - name: Deploy lite env: ENVIRONMENT: lite IMAGE_TAG: lite DEPLOY_HOST: "lite.${{ vars.DOMAIN || 'gobha.ai' }}" run: | export NAMESPACE="${NAMESPACE}" export FE_IMAGE="${FE_IMAGE}" export CERT_ISSUER="${CERT_ISSUER}" envsubst < k8s/lite.yaml > /tmp/lite.yaml kubectl create namespace ${NAMESPACE} 2>/dev/null || true kubectl apply -f /tmp/lite.yaml kubectl rollout restart deployment/switchboard-lite -n ${NAMESPACE} kubectl rollout status deployment/switchboard-lite -n ${NAMESPACE} --timeout=120s echo "✅ Lite deployed to https://${DEPLOY_HOST}"