name: Deploy to Staging on: repository_dispatch: types: [deploy-staging] workflow_dispatch: inputs: api_ref: description: "barazo-api branch/tag (default: main)" required: false default: "main" web_ref: description: "barazo-web branch/tag (default: main)" required: false default: "main" push: branches: [main] paths: - "docker-compose*.yml" - "Caddyfile" - ".env.example" env: REGISTRY: ghcr.io DEPLOY_PATH: /opt/barazo COMPOSE_FILES: "-f docker-compose.yml -f docker-compose.staging.yml" concurrency: group: staging-deploy cancel-in-progress: false jobs: # ====================================================================== # Job 1: Build and push Docker images to GHCR # ====================================================================== build: name: Build Images runs-on: ubuntu-latest permissions: contents: read packages: write timeout-minutes: 15 outputs: api_ref: ${{ steps.refs.outputs.api_ref }} web_ref: ${{ steps.refs.outputs.web_ref }} trigger: ${{ steps.refs.outputs.trigger }} steps: - name: Determine checkout refs id: refs run: | if [ "${{ github.event_name }}" = "repository_dispatch" ]; then API_REF="${{ github.event.client_payload.api_ref || 'main' }}" WEB_REF="${{ github.event.client_payload.web_ref || 'main' }}" TRIGGER="${{ github.event.client_payload.trigger_repo || 'manual' }}" elif [ "${{ github.event_name }}" = "push" ]; then API_REF="main" WEB_REF="main" TRIGGER="barazo-deploy" else API_REF="${{ inputs.api_ref }}" WEB_REF="${{ inputs.web_ref }}" TRIGGER="workflow_dispatch" fi echo "api_ref=$API_REF" >> "$GITHUB_OUTPUT" echo "web_ref=$WEB_REF" >> "$GITHUB_OUTPUT" echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT" echo "::notice::Building api@$API_REF web@$WEB_REF (trigger: $TRIGGER)" # ------------------------------------------------------------------ # Checkout all repos (public -- no PAT needed) # ------------------------------------------------------------------ - name: Checkout barazo-deploy uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Checkout barazo-workspace uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: singi-labs/barazo-workspace path: _workspace # Copy workspace root files (lockfile, catalogs) over deploy repo root. # barazo-workspace is the single source of truth for dependency resolution. - name: Sync workspace root files run: | cp _workspace/package.json . cp _workspace/pnpm-lock.yaml . cp _workspace/pnpm-workspace.yaml . cp _workspace/.npmrc . rm -rf _workspace - name: Checkout barazo-lexicons uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: singi-labs/barazo-lexicons path: barazo-lexicons - name: Checkout barazo-plugins uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: singi-labs/barazo-plugins path: barazo-plugins - name: Checkout barazo-api uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: singi-labs/barazo-api ref: ${{ steps.refs.outputs.api_ref }} path: barazo-api - name: Checkout barazo-web uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: singi-labs/barazo-web ref: ${{ steps.refs.outputs.web_ref }} path: barazo-web # ------------------------------------------------------------------ # Docker setup # ------------------------------------------------------------------ - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Container Registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # ------------------------------------------------------------------ # Build and push images (amd64 only for staging) # ------------------------------------------------------------------ - name: Build and push barazo-api uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: barazo-api/Dockerfile push: true tags: | ${{ env.REGISTRY }}/singi-labs/barazo-api:edge ${{ env.REGISTRY }}/singi-labs/barazo-api:staging-${{ github.run_number }} cache-from: type=gha,scope=api cache-to: type=gha,mode=max,scope=api platforms: linux/amd64 - name: Build and push barazo-web uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: barazo-web/Dockerfile push: true tags: | ${{ env.REGISTRY }}/singi-labs/barazo-web:edge ${{ env.REGISTRY }}/singi-labs/barazo-web:staging-${{ github.run_number }} cache-from: type=gha,scope=web cache-to: type=gha,mode=max,scope=web platforms: linux/amd64 # ====================================================================== # Job 2: Smoke test -- verify images start and respond to health checks # ====================================================================== smoke-test: name: Smoke Test needs: build runs-on: ubuntu-latest permissions: contents: read packages: read timeout-minutes: 5 env: POSTGRES_USER: barazo POSTGRES_PASSWORD: ci_smoke_test POSTGRES_DB: barazo VALKEY_PASSWORD: ci_smoke_test DATABASE_URL: postgresql://barazo:ci_smoke_test@postgres:5432/barazo TAP_ADMIN_PASSWORD: ci_smoke_test SESSION_SECRET: ci_session_secret_not_real_extend_to_32 RELAY_URL: wss://bsky.network COMMUNITY_DID: did:plc:ci-smoke-test COMMUNITY_NAME: CI Smoke Test COMMUNITY_DOMAIN: ":80" COMMUNITY_MODE: single OAUTH_CLIENT_ID: https://ci-smoke.barazo.forum/client-metadata.json OAUTH_REDIRECT_URI: http://127.0.0.1/api/auth/callback NEXT_PUBLIC_SITE_URL: http://127.0.0.1 BARAZO_API_VERSION: edge BARAZO_WEB_VERSION: edge steps: - name: Checkout barazo-deploy uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Container Registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create smoke test fixtures run: | # Empty plugin manifest (no plugins needed for smoke test) echo '[]' > plugins.json # No-op plugin install script printf '#!/bin/sh\nexit 0\n' > scripts/install-plugins.sh chmod +x scripts/install-plugins.sh - name: Start full stack run: docker compose -f docker-compose.yml up -d - name: Wait for services to be healthy run: | echo "Waiting for services to become healthy..." TIMEOUT=120 ELAPSED=0 INTERVAL=5 while [ $ELAPSED -lt $TIMEOUT ]; do HEALTHY=$(docker compose -f docker-compose.yml ps --format json 2>/dev/null \ | grep -c '"Health":"healthy"' || echo "0") # postgres, valkey, barazo-api, barazo-web, caddy = 5 services with healthchecks if [ "$HEALTHY" -ge 5 ]; then echo "All services healthy after ${ELAPSED}s" break fi echo " ${HEALTHY}/5 healthy (${ELAPSED}s elapsed)..." sleep $INTERVAL ELAPSED=$((ELAPSED + INTERVAL)) done if [ $ELAPSED -ge $TIMEOUT ]; then echo "::error::Timed out waiting for services to be healthy" docker compose -f docker-compose.yml ps docker compose -f docker-compose.yml logs --tail=50 exit 1 fi - name: Run smoke tests run: ./scripts/smoke-test.sh - name: Dump logs on failure if: failure() run: | echo "=== Service Status ===" docker compose -f docker-compose.yml ps echo "" echo "=== Service Logs ===" docker compose -f docker-compose.yml logs --tail=100 - name: Tear down if: always() run: docker compose -f docker-compose.yml down -v # ====================================================================== # Job 3: Deploy to staging VPS (only if smoke test passes) # ====================================================================== deploy: name: Deploy to Staging needs: [build, smoke-test] runs-on: ubuntu-latest permissions: contents: read timeout-minutes: 10 steps: - name: Sync configuration files to VPS uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | set -e cd ${{ env.DEPLOY_PATH }} echo "Syncing configuration files from repo (commit ${{ github.sha }})..." BASE="https://raw.githubusercontent.com/singi-labs/barazo-deploy/${{ github.sha }}" for f in docker-compose.yml docker-compose.staging.yml Caddyfile .env.example; do wget -qO "$f.tmp" "$BASE/$f" mv "$f.tmp" "$f" echo " Updated $f" done echo "Configuration files synced" - name: Save current image digests (for rollback) uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 id: save-digests with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | cd ${{ env.DEPLOY_PATH }} echo "Saving current digests for rollback..." docker inspect --format='{{index .RepoDigests 0}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-api 2>/dev/null) 2>/dev/null > /tmp/rollback-api-digest || echo "no-previous-api" docker inspect --format='{{index .RepoDigests 0}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-web 2>/dev/null) 2>/dev/null > /tmp/rollback-web-digest || echo "no-previous-web" echo "API digest: $(cat /tmp/rollback-api-digest)" echo "Web digest: $(cat /tmp/rollback-web-digest)" - name: Check for new required env vars uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | cd ${{ env.DEPLOY_PATH }} echo "Checking for new required env vars..." EXAMPLE_VARS=$(grep -v '^#' .env.example | grep -v '^\s*$' | grep '=' | cut -d= -f1 | sort) CURRENT_VARS=$(grep -v '^#' .env | grep -v '^\s*$' | grep '=' | cut -d= -f1 | sort) MISSING=$(comm -23 <(echo "$EXAMPLE_VARS") <(echo "$CURRENT_VARS")) if [ -n "$MISSING" ]; then echo "::warning::New env vars in .env.example not in .env:" echo "$MISSING" echo "" echo "Deploy continues -- these may be optional. Check .env.example for details." else echo "All .env.example vars present in .env" fi - name: Pull new images and deploy id: deploy uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | set -e cd ${{ env.DEPLOY_PATH }} echo "Pulling latest images..." docker compose ${{ env.COMPOSE_FILES }} pull barazo-api barazo-web echo "Starting containers..." docker compose ${{ env.COMPOSE_FILES }} up -d echo "Pruning unused images..." docker image prune -f echo "Deploy complete at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" # ------------------------------------------------------------------ # Post-deploy health checks # ------------------------------------------------------------------ - name: Wait for containers to start run: sleep 15 - name: Check container logs for crash patterns id: log-check uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | cd ${{ env.DEPLOY_PATH }} echo "Checking logs for crash patterns (last 30s)..." LOGS=$(docker compose ${{ env.COMPOSE_FILES }} logs --since 30s barazo-api barazo-web 2>&1) echo "$LOGS" CRASH_PATTERNS="FATAL|ECONNREFUSED|missing env|ERR_MODULE_NOT_FOUND|Cannot find module|segfault|OOMKilled" if echo "$LOGS" | grep -iE "$CRASH_PATTERNS"; then echo "" echo "CRASH_DETECTED=true" echo "::error::Crash pattern detected in container logs" exit 1 fi # Check for containers that exited EXITED=$(docker compose ${{ env.COMPOSE_FILES }} ps --format json 2>/dev/null | grep -i '"exited"' || true) if [ -n "$EXITED" ]; then echo "::error::One or more containers exited unexpectedly" echo "$EXITED" exit 1 fi echo "No crash patterns detected" - name: Verify health endpoints id: health-check uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | cd ${{ env.DEPLOY_PATH }} echo "Checking health endpoints..." # Wait up to 90s for API to become healthy (Docker healthcheck: start-period 30s + interval 30s) # Ports are not exposed to host, so we check Docker's own healthcheck status for i in $(seq 1 18); do API_HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-api 2>/dev/null) 2>/dev/null || echo "missing") if [ "$API_HEALTH" = "healthy" ]; then echo "API health: OK (healthy)" break fi echo "API health: $API_HEALTH (attempt $i/18)" sleep 5 done if [ "$API_HEALTH" != "healthy" ]; then echo "::error::API health check failed after 90s (status: $API_HEALTH)" # Show recent logs for debugging docker compose ${{ env.COMPOSE_FILES }} logs --tail=20 barazo-api 2>&1 || true exit 1 fi # Check web health for i in $(seq 1 12); do WEB_HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-web 2>/dev/null) 2>/dev/null || echo "missing") if [ "$WEB_HEALTH" = "healthy" ]; then echo "Web health: OK (healthy)" break fi echo "Web health: $WEB_HEALTH (attempt $i/12)" sleep 5 done if [ "$WEB_HEALTH" != "healthy" ]; then echo "::error::Web health check failed after 60s (status: $WEB_HEALTH)" docker compose ${{ env.COMPOSE_FILES }} logs --tail=20 barazo-web 2>&1 || true exit 1 fi echo "All health checks passed" - name: Verify login endpoint id: login-check uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | set -e cd ${{ env.DEPLOY_PATH }} echo "Verifying login endpoint returns valid redirect URL..." # Retry up to 6 times (30s total) -- API may still be starting after deploy # Use 127.0.0.1 (not localhost) -- Alpine may not resolve localhost to IPv4 # Use a real Bluesky handle -- the API resolves the DID via the AT Protocol for i in $(seq 1 6); do RESPONSE=$(docker compose ${{ env.COMPOSE_FILES }} exec -T barazo-api \ wget -qO- "http://127.0.0.1:3000/api/auth/login?handle=bsky.app" 2>&1 || true) if echo "$RESPONSE" | grep -q '"url"'; then echo "Login endpoint OK: response contains redirect URL (attempt $i/6)" exit 0 fi echo "Login check attempt $i/6 failed, retrying in 5s..." sleep 5 done echo "::warning::Login endpoint did not return expected {url} field after 6 attempts" echo "Response: $RESPONSE" echo "This may indicate an OAuth configuration issue" # ------------------------------------------------------------------ # Rollback on failure # ------------------------------------------------------------------ - name: Rollback on failure if: failure() && (steps.deploy.outcome == 'failure' || steps.log-check.outcome == 'failure' || steps.health-check.outcome == 'failure') uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1 with: host: staging.barazo.forum username: deploy key: ${{ secrets.STAGING_SSH_KEY }} script: | cd ${{ env.DEPLOY_PATH }} echo "::warning::Rolling back to previous images..." API_DIGEST=$(cat /tmp/rollback-api-digest 2>/dev/null || echo "") WEB_DIGEST=$(cat /tmp/rollback-web-digest 2>/dev/null || echo "") if [ -n "$API_DIGEST" ] && [ "$API_DIGEST" != "no-previous-api" ]; then echo "Rolling back API to: $API_DIGEST" docker pull "$API_DIGEST" docker tag "$API_DIGEST" ghcr.io/singi-labs/barazo-api:edge fi if [ -n "$WEB_DIGEST" ] && [ "$WEB_DIGEST" != "no-previous-web" ]; then echo "Rolling back Web to: $WEB_DIGEST" docker pull "$WEB_DIGEST" docker tag "$WEB_DIGEST" ghcr.io/singi-labs/barazo-web:edge fi docker compose ${{ env.COMPOSE_FILES }} up -d echo "Rollback complete. Waiting for health..." sleep 15 API_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/health 2>/dev/null || echo "000") echo "Post-rollback API health: $API_STATUS" - name: Deploy summary if: success() run: | echo "## Staging Deploy Complete" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "- **Trigger:** ${{ needs.build.outputs.trigger }}" >> "$GITHUB_STEP_SUMMARY" echo "- **API ref:** ${{ needs.build.outputs.api_ref }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Web ref:** ${{ needs.build.outputs.web_ref }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Run:** #${{ github.run_number }}" >> "$GITHUB_STEP_SUMMARY" echo "- **URL:** https://staging.barazo.forum" >> "$GITHUB_STEP_SUMMARY"