Changeset 0.5.0 (#35)
This commit is contained in:
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user