# .gitea/workflows/ci.yaml # ============================================ # Switchboard Core - CI/CD Pipeline (v0.17.3) # ============================================ # Single unified image (Go backend + nginx frontend). # v0.1.0: Dropped FE/BE image split per ROADMAP design decision. # # Pipeline: # 0. Detect changes (path-based gating for all downstream jobs) # 1a. Frontend tests — skipped if only BE/docs changed # 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled) # 1c. Go test (PG) — PG store + handlers against Postgres (race-enabled) # 2. Build + Deploy — skipped if docs-only change # # Test coverage mapping (no package tested by zero jobs): # Unit packages (auto-discovered) → test-sqlite (race) # ./handlers/... → test-sqlite (SQLite driver, race) # + test-go-pg (PG driver, race) # ./store/sqlite/... → test-sqlite (race) # ./store/postgres/... → test-go-pg (race) # CGO_ENABLED=0 build check → test-sqlite # # Path gating rules: # src/, src/editor/ → frontend tests # server/, scripts/db-* → backend tests (PG + SQLite) # Dockerfile*, k8s/, .gitea/ → all tests (infra change) # docs/, *.md → skip all tests + deploy # VERSION, scripts/* → frontend + backend tests # Tags (v*) → always full pipeline # # 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 (switchboard_core_{dev,test,}) # # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST # PROVIDER — LLM provider type for live tests: "venice", "openai", "anthropic" # PROVIDER_URL — provider endpoint (optional, uses default for known providers) # SEED_USERS — (optional) CSV: "user:pass:role,..." for dev/test seed accounts # STORAGE_CLASS — StorageClass for PVC (e.g. "cephfs", required for file storage) # STORAGE_SIZE — PVC capacity (e.g. "10Gi", default: "10Gi") # STORAGE_BACKEND — "pvc" or "s3" (default: "pvc") # # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD # SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL # ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32) # PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing) # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY (only when STORAGE_BACKEND=s3) # # 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' }} IMAGE: ${{ vars.REGISTRY }}/switchboard/core DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/switchboard-core' }} jobs: # ── Stage 0: Detect Changed Paths ────────────── # Sets output flags so downstream jobs can skip when irrelevant. # Tags always set all flags true (full pipeline for releases). detect-changes: runs-on: ubuntu-latest outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} infra: ${{ steps.filter.outputs.infra }} docs_only: ${{ steps.filter.outputs.docs_only }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # need history for diff - name: Detect changed paths id: filter run: | # Tags = release → run everything if [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then echo "frontend=true" >> "$GITHUB_OUTPUT" echo "backend=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "🏷️ Tag build — running full pipeline" exit 0 fi # Determine diff base if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then BASE="${{ gitea.event.pull_request.base.sha }}" HEAD="${{ gitea.event.pull_request.head.sha }}" else # Push to main — compare with previous commit BASE="${{ gitea.event.before }}" HEAD="${{ gitea.sha }}" fi echo "Comparing ${BASE:0:8}..${HEAD:0:8}" CHANGED=$(git diff --name-only "${BASE}" "${HEAD}" 2>/dev/null || git diff --name-only HEAD~1 HEAD) echo "Changed files:" echo "${CHANGED}" | sed 's/^/ /' # Classify FE=false; BE=false; INFRA=false; DOCS=false; OTHER=false while IFS= read -r file; do [[ -z "$file" ]] && continue case "$file" in src/js/*|src/css/*|src/editor/*|src/index.html|src/sw.js|src/manifest.json|src/vendor/*) FE=true ;; server/*|scripts/db-*) BE=true ;; .gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf) INFRA=true ;; docs/*|*.md|CHANGELOG.md|LICENSE) DOCS=true ;; VERSION|scripts/*) FE=true; BE=true ;; *) OTHER=true ;; esac done <<< "${CHANGED}" # Docs-only: only docs changed, nothing else if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then DOCS_ONLY=true else DOCS_ONLY=false fi echo "frontend=${FE}" >> "$GITHUB_OUTPUT" echo "backend=${BE}" >> "$GITHUB_OUTPUT" echo "infra=${INFRA}" >> "$GITHUB_OUTPUT" echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT" echo "" echo "━━━ Change Detection ━━━" echo " frontend: ${FE}" echo " backend: ${BE}" echo " infra: ${INFRA}" echo " docs_only: ${DOCS_ONLY}" # ── Stage 1a: Frontend Tests ───────────────── # API contract tests, model processing, policy wiring audits. # Uses Node.js built-in test runner (node --test), zero npm deps. # # Runs when: frontend files changed, infra changed, or not docs-only. # Skipped when: only backend or docs changed. test-frontend: runs-on: ubuntu-latest needs: [detect-changes] if: needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.infra == 'true' steps: - name: Checkout uses: actions/checkout@v4 - name: Verify Node.js run: | echo "Node $(node --version)" NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1) if [ "$NODE_MAJOR" -lt 21 ]; then echo "❌ Node >= 21 required for built-in test runner (have v${NODE_MAJOR})" exit 1 fi - name: Syntax check (all JS) run: | echo "Checking JS syntax..." err=0 while IFS= read -r f; do # Feed file as module to node --check via stdin node --input-type=module --check < "$f" 2>/dev/null || { echo " ✗ $f" err=1 } done < <(find src/js -name '*.js' -not -path '*/vendor/*' -not -path '*node_modules*') [ "$err" -eq 0 ] && echo " ✓ All JS files passed syntax check" exit $err - name: Run frontend tests run: node --test src/js/__tests__/*.test.js # ── Stage 1b: Go Unit Tests + SQLite Integration ───── # Covers ALL non-Postgres packages via auto-discovery, plus # SQLite handler and store integration tests. Race-enabled. # # Coverage: unit packages (dynamic), ./handlers/... (SQLite), # ./store/sqlite/..., CGO_ENABLED=0 build check. # # Runs when: backend files changed or infra changed. # Skipped when: only frontend or docs changed. test-sqlite: runs-on: ubuntu-latest needs: [detect-changes] if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.infra == 'true' 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: Build check (CGO_ENABLED=0) working-directory: server run: | echo "━━━ SQLite Backend Build Check ━━━" CGO_ENABLED=0 go build -o /dev/null . echo "✓ Binary compiles with SQLite backend (pure Go, no CGO)" - name: Run unit tests (auto-discovered, no DB) working-directory: server run: | echo "━━━ Unit Tests (all non-DB packages) ━━━" # Auto-discover: everything except store/* and handlers/* # This ensures new packages are never silently untested UNIT_PKGS=$(go list ./... | grep -v -E '/(store|handlers)(/|$)') echo "Packages under test:" echo "${UNIT_PKGS}" | sed 's/^/ /' echo "" go test -v -race -count=1 -p 1 -timeout 8m ${UNIT_PKGS} echo "✓ Unit tests complete" - name: Run SQLite handler integration tests working-directory: server env: DB_DRIVER: sqlite run: | echo "━━━ SQLite Integration Tests (handlers + stores) ━━━" go test -v -race -count=1 -p 1 -timeout 8m \ ./handlers/... \ ./store/sqlite/... echo "✓ SQLite integration tests complete" # ── Stage 1c: Go Test (Postgres) ───────────── # Tests ONLY Postgres-dependent packages: store/postgres and # handlers (with PG driver). Runs in parallel with test-sqlite. # # Coverage: ./store/postgres/..., ./handlers/... (PG driver). # Handlers are intentionally tested against BOTH drivers. # # Runs when: backend files changed or infra changed. # Skipped when: only frontend or docs changed. test-go-pg: runs-on: ubuntu-latest needs: [detect-changes] if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.infra == 'true' 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: Bootstrap CI test 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: switchboard_core_ci run: | echo "━━━ CI Test Database Setup ━━━" # Create DB for Go integration tests (admin creds) DB_EXISTS=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}';" postgres || echo "0") if [[ "${DB_EXISTS}" != "1" ]]; then psql -c "CREATE DATABASE ${DB_NAME} OWNER ${APP_USER};" postgres echo "✓ Created ${DB_NAME}" else echo "✓ ${DB_NAME} already exists" fi # Extensions psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "pgcrypto";' psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "vector";' # Grant psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${APP_USER};" psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${APP_USER};" psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER};" psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER};" echo "✓ CI test database ready" - name: Run Postgres integration tests working-directory: server env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} run: | echo "━━━ Postgres Integration Tests ━━━" go test -v -race -count=1 -p 1 -timeout 12m \ ./store/postgres/... \ ./handlers/... echo "✓ Postgres integration tests complete" - name: Drop CI test database if: always() env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }} PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} run: | psql -c "DROP DATABASE IF EXISTS switchboard_core_ci;" postgres echo "✓ Dropped CI test database" # ── Stage 2: Build, Database, Deploy ───────── # # Depends on all test jobs. Skipped jobs (due to path gating) # are treated as successful — no blocking. # # Skipped entirely for docs-only changes (nothing to build). build-and-deploy: runs-on: ubuntu-latest needs: [detect-changes, test-go-pg, test-frontend, test-sqlite] # Run unless: a needed job failed, the workflow was cancelled, or it's docs-only. # Skipped test jobs (path-gated) are fine — they don't block. if: | !cancelled() && !failure() && needs.detect-changes.outputs.docs_only != 'true' 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: setup 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 "GIN_MODE=debug" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT" echo "DB_NAME=switchboard_core_dev" >> "$GITHUB_OUTPUT" echo "DB_WIPE=true" >> "$GITHUB_OUTPUT" echo "REPLICAS=1" >> "$GITHUB_OUTPUT" echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "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 "GIN_MODE=release" >> "$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=switchboard_core" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "REPLICAS=2" >> "$GITHUB_OUTPUT" echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT" echo "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 "GIN_MODE=debug" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT" echo "DB_NAME=switchboard_core_test" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "REPLICAS=1" >> "$GITHUB_OUTPUT" echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "env_label=test (main)" >> "$GITHUB_OUTPUT" fi # ── Database Bootstrap (admin creds) ─────── - 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.setup.outputs.DB_NAME }} run: | chmod +x scripts/db-bootstrap.sh scripts/db-bootstrap.sh # ── Dev: Upgrade Test (dev only) ─────────── - name: "Dev: test migration upgrade" if: steps.setup.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} run: | echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━" 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 ─────────────────── # - name: "Dev: validate schema" # if: steps.setup.outputs.DB_WIPE == 'true' # env: # PGHOST: ${{ env.POSTGRES_HOST }} # PGPORT: ${{ env.POSTGRES_PORT }} # PGUSER: ${{ secrets.POSTGRES_USER }} # PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} # PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} # run: | # chmod +x scripts/db-validate.sh # scripts/db-validate.sh # ── Dev Wipe (fresh install test) ────────── - name: "Dev: wipe for fresh install test" if: steps.setup.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} run: | DB="${{ steps.setup.outputs.DB_NAME }}" 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" # ── Build Unified Image ────────────────────── - name: Build image run: | docker build -f Dockerfile \ -t ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} \ . # ── Tag release versions ───────────────────── - name: Tag release versions if: steps.setup.outputs.EXTRA_TAG != '' run: | docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} # ── Push to Internal Registry ──────────────── - name: Push image run: | docker push ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} if [[ -n "${{ steps.setup.outputs.EXTRA_TAG }}" ]]; then docker push ${IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} fi # ── Push to Docker Hub (release only) ──────── - name: Push to Docker Hub if: steps.setup.outputs.is_release == 'true' run: | docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:latest docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.setup.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.setup.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 }} SEED_USERS: ${{ vars.SEED_USERS }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} 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 - kubectl create secret generic switchboard-seed-users \ --namespace=${NAMESPACE} \ --from-literal=users="${SEED_USERS}" \ --dry-run=client -o yaml | kubectl apply -f - kubectl create secret generic switchboard-encryption \ --namespace=${NAMESPACE} \ --from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \ --dry-run=client -o yaml | kubectl apply -f - - name: Sync S3 secrets if: vars.STORAGE_BACKEND == 's3' env: S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} S3_BUCKET: ${{ vars.S3_BUCKET }} S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }} S3_PREFIX: ${{ vars.S3_PREFIX }} run: | kubectl create secret generic switchboard-s3 \ --namespace=${NAMESPACE} \ --from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \ --from-literal=S3_BUCKET="${S3_BUCKET}" \ --from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \ --from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \ --from-literal=S3_REGION="${S3_REGION}" \ --from-literal=S3_PREFIX="${S3_PREFIX}" \ --dry-run=client -o yaml | kubectl apply -f - - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.setup.outputs.ENVIRONMENT }} GIN_MODE: ${{ steps.setup.outputs.GIN_MODE }} IMAGE_TAG: ${{ steps.setup.outputs.IMAGE_TAG }} DEPLOY_SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }} DEPLOY_HOST: ${{ steps.setup.outputs.DEPLOY_HOST }} BASE_PATH: ${{ steps.setup.outputs.BASE_PATH }} DB_NAME: ${{ steps.setup.outputs.DB_NAME }} REPLICAS: ${{ steps.setup.outputs.REPLICAS }} MEMORY_REQUEST: ${{ steps.setup.outputs.MEMORY_REQUEST }} MEMORY_LIMIT: ${{ steps.setup.outputs.MEMORY_LIMIT }} CPU_REQUEST: ${{ steps.setup.outputs.CPU_REQUEST }} CPU_LIMIT: ${{ steps.setup.outputs.CPU_LIMIT }} # Storage (v0.12.0+) STORAGE_CLASS: ${{ vars.STORAGE_CLASS }} STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }} STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }} run: | # Render PVC first (must exist before backend references it) if [[ -n "${STORAGE_CLASS}" ]]; then envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml fi envsubst < k8s/backend.yaml > /tmp/backend.yaml envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml envsubst < k8s/ingress.yaml > /tmp/ingress.yaml echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━" # Apply PVC (idempotent — won't recreate if exists) if [[ -n "${STORAGE_CLASS}" ]]; then kubectl apply -f /tmp/storage-pvc.yaml echo " ✓ Storage PVC (class: ${STORAGE_CLASS}, size: ${STORAGE_SIZE})" else echo " ⚠ STORAGE_CLASS not set — file storage disabled" fi kubectl apply -f /tmp/backend.yaml # Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs. # First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin) if kubectl apply -f /tmp/middleware-retry.yaml 2>/tmp/mw-err.txt; then echo " ✓ Traefik retry middleware" MW_READY=true else echo " ⚠ Traefik middleware skipped ($(head -1 /tmp/mw-err.txt))" echo " First-time setup: kubectl apply -f k8s/rbac-traefik.yaml" MW_READY=false fi kubectl apply -f /tmp/ingress.yaml # Annotate ingress with middleware reference ONLY if middleware exists. # Traefik invalidates the entire router if it references a missing middleware, # which kills all routes for this path prefix. if [[ "${MW_READY}" == "true" ]]; then MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd" kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \ "traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \ --namespace="${NAMESPACE}" --overwrite echo " ✓ Ingress annotated with retry middleware" fi - name: Rollout and verify env: SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }} ENV: ${{ steps.setup.outputs.ENVIRONMENT }} run: | kubectl rollout restart deployment/switchboard${SUFFIX} -n ${NAMESPACE} kubectl rollout status deployment/switchboard${SUFFIX} -n ${NAMESPACE} --timeout=180s echo "" echo "━━━ Pod Status ━━━" kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV} READY=$(kubectl get deployment switchboard${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}') if [[ "${READY:-0}" -gt 0 ]]; then echo "✅ Healthy: ${READY} pods ready" else echo "❌ Unhealthy: ${READY:-0} pods ready" kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30 exit 1 fi # ── Post-deploy: verify schema ───────────── - name: Verify schema migration env: EXPECTED_DB: ${{ steps.setup.outputs.DB_NAME }} run: | sleep 5 HOST="${{ steps.setup.outputs.DEPLOY_HOST }}" BP="${{ steps.setup.outputs.BASE_PATH }}" HEALTH=$(curl -sf "https://${HOST}${BP}/health" 2>/dev/null || echo "unreachable") echo "Health: ${HEALTH}" SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true) DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || true) ACTUAL_DB=$(echo "${HEALTH}" | grep -o '"database_name":"[^"]*"' | cut -d'"' -f4 || true) if [[ -n "${SCHEMA}" ]] && [[ -n "${DB_OK}" ]]; then echo "✅ Schema: ${SCHEMA} | DB: ${ACTUAL_DB}" else echo "⚠ Could not verify schema (backend may still be starting)" echo " Response: ${HEALTH:0:200}" fi # Verify the backend connected to the correct database if [[ -n "${ACTUAL_DB}" ]] && [[ "${ACTUAL_DB}" != "${EXPECTED_DB}" ]]; then echo "❌ DATABASE MISMATCH: expected=${EXPECTED_DB} actual=${ACTUAL_DB}" echo " The backend is connected to the WRONG database!" exit 1 fi - name: Summary run: | HOST="${{ steps.setup.outputs.DEPLOY_HOST }}" BP="${{ steps.setup.outputs.BASE_PATH }}" echo "## ✅ Deployed to ${{ steps.setup.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.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Database** | \`${{ steps.setup.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **DB Wipe** | ${{ steps.setup.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY else echo "| **Storage** | PVC (class: ${{ vars.STORAGE_CLASS }}, size: ${{ vars.STORAGE_SIZE || '10Gi' }}) |" >> $GITHUB_STEP_SUMMARY fi else echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY fi if [[ "${{ steps.setup.outputs.is_release }}" == "true" ]]; then echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY fi