This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/.gitea/workflows/ci.yaml
2026-02-20 22:24:47 +00:00

470 lines
22 KiB
YAML

# .gitea/workflows/ci.yaml
# ============================================
# Chat Switchboard - CI/CD Pipeline (v0.6.2)
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use).
#
# Pipeline:
# 1. Go test (all PRs and pushes)
# 2. Build + Deploy (depends on test passing)
#
# Deployment mapping (single domain, path-based):
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
# Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only)
# Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only)
# → Unified :latest → Docker Hub (not deployed)
#
# Database lifecycle:
# CI: bootstrap (admin creds) → create DB + role + extensions
# CI: dev wipe (app creds) → drop tables for fresh install test
# BE: auto-migrate on startup → schema_migrations tracking
# BE: bootstrap admin from SWITCHBOARD_ADMIN_* env vars (upsert on every restart)
#
# Shared PG safety:
# - Bootstrap uses IF NOT EXISTS (idempotent)
# - Dev wipe REFUSES to run on databases not ending in _dev
# - Admin creds never reach the backend pods
# - Each env has its own DB name (chat_switchboard_{dev,test,})
#
# Required Gitea Variables:
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
#
# Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_PASSWORD
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
# 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
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
jobs:
# ── Stage 1: Go Build & Test ─────────────────
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.gobha.me/*
GONOSUMCHECK: git.gobha.me/*
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: |
# All environments share one host: switchboard.DOMAIN
# Environments are separated by path prefix (BASE_PATH)
DEPLOY_HOST="switchboard.${DOMAIN}"
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
echo "BASE_PATH=/dev" >> "$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 "BASE_PATH=" >> "$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 "BASE_PATH=/test" >> "$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 (admin creds) ───────
# Creates the database and app role if they don't exist.
# Idempotent — safe to run every build. Uses admin creds
# that the backend pods never see.
- 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
# ── Dev: Upgrade Test (dev only) ───────────
# The dev DB still has the PREVIOUS PR's schema.
# Apply THIS PR's migrations to test the upgrade path.
# If migration fails → CI fails before build.
- name: "Dev: test migration upgrade"
if: steps.env.outputs.DB_WIPE == 'true'
env:
PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
run: |
echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━"
# Ensure tracking table exists (first run or after manual wipe)
psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
);
SQL
APPLIED=0
SKIPPED=0
FAILED=0
for migration in server/database/migrations/*.sql; do
[ -f "${migration}" ] || continue
VERSION=$(basename "${migration}")
ALREADY=$(psql -tAc "SELECT 1 FROM schema_migrations WHERE version = '${VERSION}';" || echo "0")
if [[ "${ALREADY}" == "1" ]]; then
echo " skip: ${VERSION}"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " apply: ${VERSION}..."
if psql -v ON_ERROR_STOP=1 -f "${migration}"; then
psql -c "INSERT INTO schema_migrations (version) VALUES ('${VERSION}');"
echo " ✓ ${VERSION}"
APPLIED=$((APPLIED + 1))
else
echo " ✗ ${VERSION} FAILED"
FAILED=$((FAILED + 1))
fi
done
echo ""
echo "━━━ Upgrade test: ${APPLIED} applied, ${SKIPPED} skipped, ${FAILED} failed ━━━"
if [[ ${FAILED} -gt 0 ]]; then
echo "❌ Migration upgrade test failed — fix before merge"
exit 1
fi
# ── Dev: Validate Schema ───────────────────
# After upgrade, verify all expected tables/columns exist.
# Catches migrations that apply cleanly but produce wrong schema.
- name: "Dev: validate schema"
if: steps.env.outputs.DB_WIPE == 'true'
env:
PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
run: |
chmod +x scripts/db-validate.sh
scripts/db-validate.sh
# ── Dev Wipe (fresh install test) ──────────
# Upgrade test passed. Now wipe so the deploy tests a
# full fresh-install migration via the backend binary.
# SAFETY: hard-refuses on any database not ending in _dev.
- name: "Dev: wipe for fresh install test"
if: steps.env.outputs.DB_WIPE == 'true'
env:
PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
run: |
DB="${{ steps.env.outputs.DB_NAME }}"
# ── SAFETY: only wipe databases ending in _dev ──
if [[ "${DB}" != *_dev ]]; then
echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped"
exit 1
fi
echo "⚠ Dev wipe: dropping all tables in ${DB}"
psql <<'SQL'
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
SQL
echo "✓ Dev wipe complete — backend will rebuild schema on startup"
# Dev exercises BOTH paths:
# 1. Upgrade test above: existing schema → new migrations (psql)
# 2. Fresh install on deploy: empty DB → all migrations (backend binary)
# Test/Prod: backend applies only pending migrations on startup.
# ── 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 ─────────────────────
- 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 to Docker Hub (release only) ──
- name: Build and push unified image
if: steps.env.outputs.is_release == 'true'
run: |
docker build -f Dockerfile \
-t ${DOCKERHUB_IMAGE}:latest \
-t ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \
.
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
docker push ${DOCKERHUB_IMAGE}:latest
docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
else
echo "⚠ Docker Hub credentials not configured, skipping push"
fi
# ── Deploy to Kubernetes ─────────────────────
- name: Ensure namespace
run: |
kubectl create namespace ${NAMESPACE} 2>/dev/null || true
- name: Sync secrets
env:
PG_USER: ${{ secrets.POSTGRES_USER }}
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
run: |
kubectl create secret generic switchboard-db-credentials \
--namespace=${NAMESPACE} \
--from-literal=POSTGRES_USER="${PG_USER}" \
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-admin \
--namespace=${NAMESPACE} \
--from-literal=username="${SW_ADMIN_USER}" \
--from-literal=password="${SW_ADMIN_PASS}" \
--from-literal=email="${SW_ADMIN_EMAIL}" \
--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 }}
BASE_PATH: ${{ steps.env.outputs.BASE_PATH }}
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
env:
SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
ENV: ${{ steps.env.outputs.ENVIRONMENT }}
run: |
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
# ── Post-deploy: verify schema ─────────────
- name: Verify schema migration
run: |
sleep 5
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
BP="${{ steps.env.outputs.BASE_PATH }}"
HEALTH=$(curl -sf "https://${HOST}${BP}/api/v1/health" 2>/dev/null || echo "unreachable")
echo "Health: ${HEALTH}"
# Extract fields (|| true prevents set -e from killing on no-match)
SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true)
DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || true)
if [[ -n "${SCHEMA}" ]] && [[ -n "${DB_OK}" ]]; then
echo "✅ Schema: ${SCHEMA} | DB: connected"
else
echo "⚠ Could not verify schema (backend may still be starting)"
echo " Response: ${HEALTH:0:200}"
fi
- name: Summary
run: |
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
BP="${{ steps.env.outputs.BASE_PATH }}"
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}${BP}/ |" >> $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
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $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