Changeset 0.5.0 (#35)

This commit is contained in:
2026-02-19 15:03:20 +00:00
parent a93a6b9635
commit 30d0c11219
65 changed files with 5345 additions and 8070 deletions

View File

@@ -1,16 +1,30 @@
# .gitea/workflows/ci.yaml
# ============================================
# Chat Switchboard - CI/CD Pipeline
# Chat Switchboard - CI/CD Pipeline (v0.5.4)
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (Docker Desktop use).
# 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:
# 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)
# PR → FE + BE :dev → dev.DOMAIN (DB wipe + fresh schema)
# Push to main → FE + BE :test → test.DOMAIN (migrate only)
# Tag v* → FE + BE :latest → www.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
#
# 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
@@ -46,7 +60,6 @@ env:
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:
@@ -157,7 +170,10 @@ jobs:
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
fi
# ── Database Bootstrap ───────────────────────
# ── 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 }}
@@ -171,18 +187,114 @@ jobs:
chmod +x scripts/db-bootstrap.sh
scripts/db-bootstrap.sh
# ── Database Migration ───────────────────────
- name: Run migrations
# ── 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 }}
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
DB_WIPE: ${{ steps.env.outputs.DB_WIPE }}
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
run: |
chmod +x scripts/db-migrate.sh
scripts/db-migrate.sh
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
@@ -191,7 +303,7 @@ jobs:
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
./server
# ── Build Frontend Image (cluster mode) ──────
# ── Build Frontend Image ─────────────────────
- name: Build frontend image
run: |
docker build -f Dockerfile.frontend \
@@ -215,27 +327,22 @@ jobs:
docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
fi
# ── Build + Push Unified (release only) ──────
- name: Build unified image
# ── 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.unified \
-t ${UNIFIED_IMAGE}:latest \
-t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \
docker build -f Dockerfile \
-t ${DOCKERHUB_IMAGE}:latest \
-t ${DOCKERHUB_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 }}
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
@@ -278,10 +385,10 @@ jobs:
kubectl apply -f /tmp/ingress.yaml
- name: Rollout and verify
env:
SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
ENV: ${{ steps.env.outputs.ENVIRONMENT }}
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}
@@ -303,6 +410,25 @@ jobs:
exit 1
fi
# ── Post-deploy: verify schema ─────────────
- name: Verify schema migration
run: |
sleep 5
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
HEALTH=$(curl -sf "https://${HOST}/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 }}"
@@ -314,46 +440,7 @@ jobs:
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
# ── 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}"