From 26287b722d6cfd0572899dd11402984075a216ed Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 03:58:24 +0100 Subject: [PATCH 1/6] feat: implement ci/cd pipeline with docker --- .github/workflows/ci-cd.yml | 141 ++++++++++++++++++++++++++++++++++++ backend/Dockerfile | 53 ++++++++++++++ docker-compose.yml | 64 ++++++++++++++++ frontend/Dockerfile | 31 ++++++++ frontend/nginx.conf | 40 ++++++++++ 5 files changed, 329 insertions(+) create mode 100644 .github/workflows/ci-cd.yml create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 000000000..d064059aa --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,141 @@ +name: CI/CD Pipeline + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'staging' + type: choice + options: [staging, production] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE: ${{ github.repository }}/backend + FRONTEND_IMAGE: ${{ github.repository }}/frontend + +jobs: + # 1. Quality Gate (Lint & Test) + quality-gate: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Backend Lint + working-directory: backend + run: | + pip install ruff + ruff check . + + - name: Backend Tests + working-directory: backend + run: | + pip install -r requirements.txt + pip install pytest pytest-asyncio + pytest tests/ + env: + DATABASE_URL: sqlite+aiosqlite:///./test.db + + - name: Frontend Lint & Typecheck + working-directory: frontend + run: | + npm ci + npm run lint --if-present + npx tsc --noEmit + + # 2. Build & Push Images + build-and-push: + name: Build & Push Docker Images + needs: quality-gate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }} + ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }} + tags: | + type=sha,prefix= + type=ref,event=branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push Backend + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push Frontend + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + # 3. Deploy to Staging + deploy-staging: + name: Deploy to Staging + needs: build-and-push + if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'staging') + runs-on: ubuntu-latest + environment: staging + steps: + - name: Deploy to Railway (or similar) + run: echo "Deploying to staging environment..." + # Example for Railway/Fly/DigitalOcean would go here + + # 4. Deploy to Production (Manual Approval) + deploy-production: + name: Deploy to Production + needs: deploy-staging + if: github.event_name == 'workflow_dispatch' && inputs.environment == 'production' + runs-on: ubuntu-latest + environment: + name: production + url: https://solfoundry.org + steps: + - name: Deploy to Production + run: echo "Deploying to production environment..." diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..c924de8a3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,53 @@ +# Stage 1: Build dependencies +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Install system dependencies for building +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install python dependencies into a virtualenv +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Stage 2: Runtime +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Install runtime system dependencies (libpq for postgres) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtualenv from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code +COPY . . + +# Create logs directory +RUN mkdir -p logs && chmod 777 logs + +# Expose port +EXPOSE 8000 + +# Environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV LOG_DIR=/app/logs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the app +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..f260b8f83 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + db: + image: postgres:16-alpine + container_name: solfoundry-db + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-solfoundry} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-solfoundry}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: solfoundry-redis + restart: always + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: solfoundry-backend + restart: always + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-solfoundry} + REDIS_URL: redis://redis:6379/0 + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + JWT_SECRET: ${JWT_SECRET} + ports: + - "8000:8000" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: solfoundry-frontend + restart: always + depends_on: + - backend + ports: + - "8081:80" + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..2aee5e45c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# Stage 1: Build +FROM node:20-slim AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source and build +COPY . . +RUN npm run build + +# Stage 2: Serve +FROM nginx:alpine + +# Copy build artifacts +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:80/health || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 000000000..a05292072 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + + # Root directory for static files + root /usr/share/nginx/html; + index index.html; + + # SPA routing - redirect all to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API Proxy + location /api { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Internal health check for container + location /health { + access_log off; + return 200 'ok'; + } + + # Cache Control for static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} From 893d93a2ce1dbce70ecea2ef1002627d585dd29b Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 04:20:30 +0100 Subject: [PATCH 2/6] chore: update docker config for non root user --- .github/workflows/ci-cd.yml | 19 ++++++++++++++++++- backend/Dockerfile | 15 +++++++++++---- docker-compose.yml | 2 +- frontend/Dockerfile | 12 ++++++------ frontend/nginx.conf | 2 +- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index d064059aa..de2ad5178 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -13,6 +13,10 @@ on: default: 'staging' type: choice options: [staging, production] + image_tag: + description: 'Specific image tag to deploy (e.g. for rollback)' + required: false + type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -138,4 +142,17 @@ jobs: url: https://solfoundry.org steps: - name: Deploy to Production - run: echo "Deploying to production environment..." + run: | + TAG=${{ inputs.image_tag || github.sha }} + echo "Deploying image tag $TAG to production environment..." + + # 5. Rollback / Direct Tag Deploy + deploy-tag: + name: Deploy Specific Tag + if: github.event_name == 'workflow_dispatch' && inputs.image_tag != '' + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Deploy Tag + run: | + echo "Executing direct deployment/rollback of tag ${{ inputs.image_tag }} to ${{ inputs.environment }}..." diff --git a/backend/Dockerfile b/backend/Dockerfile index c924de8a3..9fb6733c9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,6 +21,10 @@ FROM python:3.12-slim AS runtime WORKDIR /app +# Create a non-root user +RUN groupadd -g 10001 appgroup && \ + useradd -u 10001 -g appgroup -s /bin/sh -m appuser + # Install runtime system dependencies (libpq for postgres) RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ @@ -31,11 +35,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# Copy application code -COPY . . +# Copy application code with correct ownership +COPY --chown=appuser:appgroup . . + +# Create logs directory with restricted permissions +RUN mkdir -p logs && chown -R appuser:appgroup logs && chmod 755 logs -# Create logs directory -RUN mkdir -p logs && chmod 777 logs +# Switch to non-root user +USER appuser # Expose port EXPOSE 8000 diff --git a/docker-compose.yml b/docker-compose.yml index f260b8f83..d555769b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,7 @@ services: depends_on: - backend ports: - - "8081:80" + - "8081:8080" volumes: postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2aee5e45c..865725057 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,16 +3,16 @@ FROM node:20-slim AS builder WORKDIR /app -# Install dependencies +# Install dependencies using npm ci for reproducible builds COPY package*.json ./ -RUN npm install +RUN npm ci # Copy source and build COPY . . RUN npm run build # Stage 2: Serve -FROM nginx:alpine +FROM nginxinc/nginx-unprivileged:alpine # Copy build artifacts COPY --from=builder /app/dist /usr/share/nginx/html @@ -20,12 +20,12 @@ COPY --from=builder /app/dist /usr/share/nginx/html # Copy nginx config COPY nginx.conf /etc/nginx/conf.d/default.conf -# Expose port -EXPOSE 80 +# Expose port (Nginx unprivileged uses 8080 by default internally) +EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget -qO- http://localhost:80/health || exit 1 + CMD wget -qO- http://localhost:8080/health || exit 1 # Start nginx CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a05292072..0e2e6bf12 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; server_name localhost; # Root directory for static files From de395737a6f7ef05acdbca7cdfbcd4c34f9f2583 Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 04:28:50 +0100 Subject: [PATCH 3/6] chore: add .env.example files --- .env.example | 3 +++ .gitignore | 1 - backend/.env.example | 16 ++++++++++++++++ frontend/.env.example | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 backend/.env.example create mode 100644 frontend/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..cadecd7b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +GITHUB_TOKEN= + +REDIS_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 67dccd23b..d977f837c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Environment & Secrets .env -.env.* *.env *.key *.pem diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..416c23b95 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,16 @@ +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=solfoundry +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/solfoundry + +# Cache +REDIS_URL=redis://localhost:6379/0 + +# Auth +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +JWT_SECRET=your_jwt_secret_change_me + +# CORS +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..5e92c9150 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Port configuration +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 From 204747a5d7003b212e23600a15c25b9e8e30c28c Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 04:54:23 +0100 Subject: [PATCH 4/6] chore: effect review changes --- .env.example | 60 +++++++++++++++++++- .github/workflows/ci.yml | 105 ++++++++++++++++++++++++++++++++++- .github/workflows/deploy.yml | 61 ++++++++++++++++---- backend/tests/conftest.py | 8 ++- docker-compose.yml | 9 ++- frontend/nginx.conf | 15 +++++ 6 files changed, 240 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index cadecd7b8..4e690501b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,59 @@ -GITHUB_TOKEN= +# ---------------------------- +# Backend (FastAPI) - runtime +# ---------------------------- +# Used by backend OAuth + GitHub sync on startup (GitHub OAuth endpoints). +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret -REDIS_URL= \ No newline at end of file +# Used to sign/verify JWT access tokens issued by the API. +JWT_SECRET=your_jwt_secret_change_me + +# Database URL for SQLAlchemy async engine. +# Examples: +# Postgres: +# postgresql+asyncpg://USER:PASSWORD@HOST:5432/DB +# SQLite: +# sqlite+aiosqlite:///./local.db +# +# Used by: +# - `docker-compose.yml` via `DATABASE_URL` +# - `deploy.yml` database migration job (`alembic upgrade head`) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/solfoundry + +# Redis connection string. +# Used by: +# - `backend/app/services/websocket_manager.py` (Redis pub/sub for websockets) +REDIS_URL=redis://localhost:6379/0 + +# ---------------------------- +# Frontend (Vite) - build-time hints +# ---------------------------- +# Used by some pages to construct absolute API URLs. +# Many API calls use relative paths (`/api/...`), so this may be optional for some deployments. +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 + +# ---------------------------- +# Docker Compose (local) - DB +# ---------------------------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=solfoundry + +# ---------------------------- +# CI/CD (GitHub Actions) - deployment secrets +# ---------------------------- +# These values must be set as GitHub repository secrets. + +# deploy.yml -> Vercel +VERCEL_TOKEN=your_vercel_token +VERCEL_ORG_ID=your_vercel_org_id +VERCEL_PROJECT_ID=your_vercel_project_id + +# deploy.yml -> DigitalOcean Kubernetes +DIGITALOCEAN_ACCESS_TOKEN=your_digitalocean_access_token +DIGITALOCEAN_CLUSTER_NAME=your_digitalocean_cluster_name + +# deploy.yml -> frontend build injection +# Used by the `deploy.yml` frontend build step (currently wired into the build environment). +API_URL=https://api.solfoundry.org \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4362a3af..e49d1788e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,11 +256,46 @@ jobs: run: cargo fmt -- --check continue-on-error: true + # ==================== DOCKER COMPOSE SMOKE TEST ==================== + # Spins up the full local stack and verifies basic readiness. + compose-smoke-test: + name: Compose Smoke Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Start stack (db + redis + backend + frontend) + env: + JWT_SECRET: smoke-test-jwt + GITHUB_CLIENT_ID: smoke-test-client-id + GITHUB_CLIENT_SECRET: smoke-test-client-secret + run: | + docker compose up -d --build db redis backend frontend + + - name: Wait for health endpoints + basic API connectivity + run: | + for i in {1..30}; do + if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then + break + fi + sleep 2 + done + + curl -fsS http://localhost:8000/health + curl -fsS http://localhost:8081/health + # Verify that nginx -> backend proxying works. + curl -fsS "http://localhost:8081/api/leaderboard?limit=1" >/dev/null + + - name: Tear down + if: always() + run: docker compose down -v + # ==================== SUMMARY ==================== ci-status: name: CI Status Summary runs-on: ubuntu-latest - needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint] + needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint, compose-smoke-test, backend-integration-postgres] if: always() steps: - name: Check CI Results @@ -277,7 +312,73 @@ jobs: echo "| Frontend Build | ${{ needs.frontend-build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Contracts Check | ${{ needs.contracts-check.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Rust Lint | ${{ needs.rust-lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Compose Smoke Test | ${{ needs.compose-smoke-test.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Backend Integration (Postgres) | ${{ needs.backend-integration-postgres.result }} |" >> $GITHUB_STEP_SUMMARY - name: Fail if any job failed if: contains(needs.*.result, 'failure') - run: exit 1 \ No newline at end of file + run: exit 1 + + # ==================== BACKEND POSTGRES INTEGRATION ==================== + backend-integration-postgres: + name: Backend Integration (Postgres) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + options: >- + --health-cmd="pg_isready -U test -d test" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + PYTHONPATH: . + TEST_DATABASE_URL: postgresql+asyncpg://test:test@postgres:5432/test + SECRET_KEY: test-secret-key-for-ci + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio + + - name: Wait for Postgres + run: | + for i in {1..30}; do + if python - <<'PY' +import os, sys, time +import asyncpg, asyncio + +async def main(): + url = os.environ["TEST_DATABASE_URL"] + try: + conn = await asyncpg.connect(url) + await conn.close() + return True + except Exception: + return False + +print(asyncio.run(main())) +PY + then + break + fi + sleep 2 + done + + - name: Run DB-backed health test + working-directory: backend + run: pytest tests/test_logging_and_errors.py::test_health_check_format -v \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4cdadfc54..2cf7c936c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,11 @@ on: options: - production - staging + image_tag: + description: 'Specific backend image tag / git sha to deploy (rollback). Leave empty for latest.' + required: false + default: '' + type: string concurrency: group: deploy-${{ github.ref }} @@ -26,6 +31,8 @@ env: NODE_VERSION: '20' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}/backend + DEPLOY_ENV: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }} + DEPLOY_TAG: ${{ inputs.image_tag || github.sha }} jobs: # ==================== BUILD FRONTEND ==================== @@ -37,6 +44,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_TAG }} - name: Check for package.json id: check @@ -81,13 +90,20 @@ jobs: deploy-frontend: name: Deploy Frontend (Vercel) runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} needs: build-frontend - if: needs.build-frontend.outputs.has_frontend == 'true' && github.event_name != 'workflow_dispatch' + if: needs.build-frontend.outputs.has_frontend == 'true' + outputs: + vercel_preview_url: ${{ steps.vercel_deploy_preview.outputs['preview-url'] }} steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_TAG }} - - name: Deploy to Vercel + - name: Deploy to Vercel (production) + id: vercel_deploy + if: env.DEPLOY_ENV == 'production' uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} @@ -96,6 +112,22 @@ jobs: vercel-args: '--prod' working-directory: frontend + - name: Deploy to Vercel (staging/preview) + id: vercel_deploy_preview + if: env.DEPLOY_ENV != 'production' + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + # No --prod flag => preview deployment; action exposes `preview-url`. + working-directory: frontend + + - name: Publish staging URL + if: env.DEPLOY_ENV != 'production' + run: | + echo "- [Staging Site](${{ steps.vercel_deploy_preview.outputs['preview-url'] }})" >> $GITHUB_STEP_SUMMARY + # ==================== BUILD BACKEND DOCKER IMAGE ==================== build-backend: name: Build Backend Docker Image @@ -145,8 +177,9 @@ jobs: deploy-backend: name: Deploy Backend (DigitalOcean) runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} needs: build-backend - if: github.event_name != 'workflow_dispatch' + if: always() steps: - name: Checkout uses: actions/checkout@v4 @@ -163,12 +196,12 @@ jobs: run: | # Update deployment image kubectl set image deployment/solfoundry-backend \ - backend=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ - --namespace production + backend=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEPLOY_TAG }} \ + --namespace ${{ env.DEPLOY_ENV }} # Wait for rollout kubectl rollout status deployment/solfoundry-backend \ - --namespace production \ + --namespace ${{ env.DEPLOY_ENV }} \ --timeout=300s continue-on-error: true @@ -176,8 +209,9 @@ jobs: migrate-database: name: Run Database Migrations runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} needs: deploy-backend - if: github.event_name != 'workflow_dispatch' + if: env.DEPLOY_ENV == 'production' && (github.event_name != 'workflow_dispatch' || inputs.image_tag == '') steps: - name: Checkout uses: actions/checkout@v4 @@ -211,8 +245,8 @@ jobs: run: | echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** Production" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ env.DEPLOY_ENV }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit/Image Tag:** ${{ env.DEPLOY_TAG }}" >> $GITHUB_STEP_SUMMARY echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status |" >> $GITHUB_STEP_SUMMARY @@ -224,8 +258,13 @@ jobs: echo "| Database Migration | ${{ needs.migrate-database.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Links" >> $GITHUB_STEP_SUMMARY - echo "- [Production Site](https://solfoundry.org)" >> $GITHUB_STEP_SUMMARY - echo "- [API Health](https://api.solfoundry.org/health)" >> $GITHUB_STEP_SUMMARY + if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + echo "- [Production Site](https://solfoundry.org)" >> $GITHUB_STEP_SUMMARY + echo "- [API Health](https://api.solfoundry.org/health)" >> $GITHUB_STEP_SUMMARY + else + echo "- [Staging Site](${{ needs.deploy-frontend.outputs.vercel_preview_url }})" >> $GITHUB_STEP_SUMMARY + echo "- [Staging /health](${{ needs.deploy-frontend.outputs.vercel_preview_url }}/health)" >> $GITHUB_STEP_SUMMARY + fi - name: Notify on Success if: success() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7024cdb44..e1f26992e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,8 +8,12 @@ import pytest # Set test database URL before importing app modules -# This must be done before any app imports -os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +# This must be done before any app imports. +# +# Default behavior (unit tests): use in-memory SQLite. +# Integration behavior (CI): if `TEST_DATABASE_URL` is set, use it instead +# so the app + ORM exercise a real backend (e.g. Postgres). +os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:") os.environ["SECRET_KEY"] = "test-secret-key-for-ci" # Configure asyncio mode for pytest diff --git a/docker-compose.yml b/docker-compose.yml index d555769b7..49b871410 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,12 @@ services: dockerfile: Dockerfile container_name: solfoundry-backend restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s depends_on: db: condition: service_healthy @@ -56,7 +62,8 @@ services: container_name: solfoundry-frontend restart: always depends_on: - - backend + backend: + condition: service_healthy ports: - "8081:8080" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0e2e6bf12..bf065bfcd 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,6 +2,14 @@ server { listen 8080; server_name localhost; + # Security headers (defense in depth; CSP should match the app's needs) + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + # Root directory for static files root /usr/share/nginx/html; index index.html; @@ -14,10 +22,17 @@ server { # API Proxy location /api { proxy_pass http://backend:8000; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts to improve resilience under slow/unresponsive upstreams + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + send_timeout 60s; } # Internal health check for container From 1c26ac1078d4b368d49593e7159a54c31d0ddf46 Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 05:08:07 +0100 Subject: [PATCH 5/6] chore: implement review changes --- .env.example | 4 +- .github/workflows/ci-cd.yml | 97 ++++++------------------------------ .github/workflows/deploy.yml | 62 +++++++++++++++++++++-- backend/.env.example | 4 +- frontend/public/health | 2 + 5 files changed, 81 insertions(+), 88 deletions(-) create mode 100644 frontend/public/health diff --git a/.env.example b/.env.example index 4e690501b..4fe2d1356 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,9 @@ GITHUB_CLIENT_ID=your_client_id GITHUB_CLIENT_SECRET=your_client_secret # Used to sign/verify JWT access tokens issued by the API. -JWT_SECRET=your_jwt_secret_change_me +# WARNING: Replace this with a long, random secret (>= 32 chars / 256 bits). +# Example only (DO NOT use in production). +JWT_SECRET=unsafe-example-jwt-secret-please-change # Database URL for SQLAlchemy async engine. # Examples: diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index de2ad5178..1733dc97a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -3,8 +3,6 @@ name: CI/CD Pipeline on: pull_request: branches: [main] - push: - branches: [main] workflow_dispatch: inputs: environment: @@ -30,6 +28,7 @@ env: jobs: # 1. Quality Gate (Lint & Test) quality-gate: + if: github.event_name == 'pull_request' name: Lint & Test runs-on: ubuntu-latest steps: @@ -70,89 +69,21 @@ jobs: npm run lint --if-present npx tsc --noEmit - # 2. Build & Push Images - build-and-push: - name: Build & Push Docker Images - needs: quality-gate - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + # 2. Deployment Trigger (delegates to `.github/workflows/deploy.yml`) + # Consolidation: `deploy.yml` is the single authoritative deployment workflow. + trigger-deploy: + if: github.event_name == 'workflow_dispatch' + name: Trigger deploy.yml (staging/production/rollback) runs-on: ubuntu-latest permissions: contents: read - packages: write steps: - - uses: actions/checkout@v4 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }} - ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }} - tags: | - type=sha,prefix= - type=ref,event=branch - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push Backend - uses: docker/build-push-action@v5 - with: - context: ./backend - push: true - tags: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Build and push Frontend - uses: docker/build-push-action@v5 - with: - context: ./frontend - push: true - tags: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - # 3. Deploy to Staging - deploy-staging: - name: Deploy to Staging - needs: build-and-push - if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'staging') - runs-on: ubuntu-latest - environment: staging - steps: - - name: Deploy to Railway (or similar) - run: echo "Deploying to staging environment..." - # Example for Railway/Fly/DigitalOcean would go here - - # 4. Deploy to Production (Manual Approval) - deploy-production: - name: Deploy to Production - needs: deploy-staging - if: github.event_name == 'workflow_dispatch' && inputs.environment == 'production' - runs-on: ubuntu-latest - environment: - name: production - url: https://solfoundry.org - steps: - - name: Deploy to Production - run: | - TAG=${{ inputs.image_tag || github.sha }} - echo "Deploying image tag $TAG to production environment..." - - # 5. Rollback / Direct Tag Deploy - deploy-tag: - name: Deploy Specific Tag - if: github.event_name == 'workflow_dispatch' && inputs.image_tag != '' - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - steps: - - name: Deploy Tag + - name: Trigger deploy workflow + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "Executing direct deployment/rollback of tag ${{ inputs.image_tag }} to ${{ inputs.environment }}..." + echo "Triggering deploy.yml environment=${{ inputs.environment }} image_tag='${{ inputs.image_tag }}'" + gh workflow run deploy.yml \ + --ref "${{ github.ref_name }}" \ + -f environment="${{ inputs.environment }}" \ + -f image_tag="${{ inputs.image_tag }}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2cf7c936c..bf585ba17 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -128,6 +128,21 @@ jobs: run: | echo "- [Staging Site](${{ steps.vercel_deploy_preview.outputs['preview-url'] }})" >> $GITHUB_STEP_SUMMARY + - name: Upload staging URL artifact + if: env.DEPLOY_ENV != 'production' + run: | + echo "${{ steps.vercel_deploy_preview.outputs['preview-url'] }}" > staging-url.txt + cat staging-url.txt >> $GITHUB_STEP_SUMMARY + shell: bash + + - name: Upload staging URL artifact (file) + if: env.DEPLOY_ENV != 'production' + uses: actions/upload-artifact@v4 + with: + name: staging-url + path: staging-url.txt + retention-days: 7 + # ==================== BUILD BACKEND DOCKER IMAGE ==================== build-backend: name: Build Backend Docker Image @@ -171,7 +186,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max file: ./backend/Dockerfile - continue-on-error: true # ==================== DEPLOY BACKEND TO DIGITALOCEAN ==================== deploy-backend: @@ -203,7 +217,6 @@ jobs: kubectl rollout status deployment/solfoundry-backend \ --namespace ${{ env.DEPLOY_ENV }} \ --timeout=300s - continue-on-error: true # ==================== DATABASE MIGRATIONS ==================== migrate-database: @@ -232,7 +245,50 @@ jobs: run: alembic upgrade head env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - continue-on-error: true + + # ==================== DEPLOYMENT VERIFICATION ==================== + # Verify backend readiness + frontend health after rollout. + verify-deployment: + name: Verify Deployment (health checks) + runs-on: ubuntu-latest + needs: [deploy-frontend, deploy-backend, migrate-database] + if: always() + environment: ${{ env.DEPLOY_ENV }} + steps: + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + - name: Save DigitalOcean kubeconfig + run: doctl kubernetes cluster kubeconfig save ${{ secrets.DIGITALOCEAN_CLUSTER_NAME }} + + - name: Verify backend /health via port-forward + env: + KUBE_NAMESPACE: ${{ env.DEPLOY_ENV }} + run: | + set -euo pipefail + kubectl port-forward deployment/solfoundry-backend 18000:8000 --namespace "$KUBE_NAMESPACE" >/tmp/port-forward.log 2>&1 & + PF_PID=$! + trap 'kill "$PF_PID" >/dev/null 2>&1 || true' EXIT + + for i in {1..30}; do + if curl -fsS http://localhost:18000/health >/dev/null 2>&1; then + break + fi + sleep 2 + done + + curl -fsS http://localhost:18000/health + + - name: Verify frontend /health endpoint + run: | + set -euo pipefail + if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + curl -fsS https://solfoundry.org/health | head -n 1 + else + curl -fsS "${{ needs.deploy-frontend.outputs.vercel_preview_url }}/health" | head -n 1 + fi # ==================== DEPLOYMENT SUMMARY ==================== deploy-status: diff --git a/backend/.env.example b/backend/.env.example index 416c23b95..a149004f3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,7 +10,9 @@ REDIS_URL=redis://localhost:6379/0 # Auth GITHUB_CLIENT_ID=your_client_id GITHUB_CLIENT_SECRET=your_client_secret -JWT_SECRET=your_jwt_secret_change_me +# WARNING: Replace this with a long, random secret (>= 32 chars / 256 bits). +# Example only (DO NOT use in production). +JWT_SECRET=unsafe-example-jwt-secret-please-change # CORS ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080 diff --git a/frontend/public/health b/frontend/public/health new file mode 100644 index 000000000..4f25bafb1 --- /dev/null +++ b/frontend/public/health @@ -0,0 +1,2 @@ +ok + From b1c2adf245e53fbda59768a3a16bbdf58b9a1c97 Mon Sep 17 00:00:00 2001 From: codebestia Date: Sat, 21 Mar 2026 05:17:43 +0100 Subject: [PATCH 6/6] chore: implement ai review --- .github/workflows/ci-cd.yml | 89 ------------------- .github/workflows/ci.yml | 50 ++++++++++- .github/workflows/deploy.yml | 162 +++++++++++++++++++++++++++-------- .gitignore | 2 + backend/app/main.py | 15 +++- 5 files changed, 188 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/ci-cd.yml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index 1733dc97a..000000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: CI/CD Pipeline - -on: - pull_request: - branches: [main] - workflow_dispatch: - inputs: - environment: - description: 'Environment to deploy to' - required: true - default: 'staging' - type: choice - options: [staging, production] - image_tag: - description: 'Specific image tag to deploy (e.g. for rollback)' - required: false - type: string - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - REGISTRY: ghcr.io - BACKEND_IMAGE: ${{ github.repository }}/backend - FRONTEND_IMAGE: ${{ github.repository }}/frontend - -jobs: - # 1. Quality Gate (Lint & Test) - quality-gate: - if: github.event_name == 'pull_request' - name: Lint & Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: 'pip' - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Backend Lint - working-directory: backend - run: | - pip install ruff - ruff check . - - - name: Backend Tests - working-directory: backend - run: | - pip install -r requirements.txt - pip install pytest pytest-asyncio - pytest tests/ - env: - DATABASE_URL: sqlite+aiosqlite:///./test.db - - - name: Frontend Lint & Typecheck - working-directory: frontend - run: | - npm ci - npm run lint --if-present - npx tsc --noEmit - - # 2. Deployment Trigger (delegates to `.github/workflows/deploy.yml`) - # Consolidation: `deploy.yml` is the single authoritative deployment workflow. - trigger-deploy: - if: github.event_name == 'workflow_dispatch' - name: Trigger deploy.yml (staging/production/rollback) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Trigger deploy workflow - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "Triggering deploy.yml environment=${{ inputs.environment }} image_tag='${{ inputs.image_tag }}'" - gh workflow run deploy.yml \ - --ref "${{ github.ref_name }}" \ - -f environment="${{ inputs.environment }}" \ - -f image_tag="${{ inputs.image_tag }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e49d1788e..fe9cacc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,20 @@ jobs: run: cargo fmt -- --check continue-on-error: true + # ==================== CONTAINER IMAGE BUILD CHECKS ==================== + container-image-build: + name: Container Image Build (backend + frontend) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -t solfoundry-backend:test ./backend + + - name: Build frontend image + run: docker build -t solfoundry-frontend:test ./frontend + # ==================== DOCKER COMPOSE SMOKE TEST ==================== # Spins up the full local stack and verifies basic readiness. compose-smoke-test: @@ -267,14 +281,15 @@ jobs: - name: Start stack (db + redis + backend + frontend) env: - JWT_SECRET: smoke-test-jwt + JWT_SECRET: smoke-test-jwt-secret-at-least-32-characters GITHUB_CLIENT_ID: smoke-test-client-id GITHUB_CLIENT_SECRET: smoke-test-client-secret run: | docker compose up -d --build db redis backend frontend - - name: Wait for health endpoints + basic API connectivity + - name: Wait for health endpoints + verify DB/Redis/proxy/auth paths run: | + set -euo pipefail for i in {1..30}; do if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then break @@ -282,11 +297,37 @@ jobs: sleep 2 done - curl -fsS http://localhost:8000/health + BACKEND_HEALTH="$(curl -fsS http://localhost:8000/health)" + echo "$BACKEND_HEALTH" + BACKEND_HEALTH="$BACKEND_HEALTH" python - <<'PY' +import json, os +health = json.loads(os.environ["BACKEND_HEALTH"]) +assert health.get("database") == "ok", "database is not healthy" +assert health.get("redis") == "ok", "redis is not healthy" +assert health.get("status") == "ok", "overall backend health is not ok" +print("backend health verified (database + redis)") +PY + curl -fsS http://localhost:8081/health # Verify that nginx -> backend proxying works. curl -fsS "http://localhost:8081/api/leaderboard?limit=1" >/dev/null + # Minimal authenticated path exercise: protected endpoint should reject unauthenticated requests. + CODE="$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/auth/me)" + test "$CODE" = "401" + + - name: Exercise backend schema initialization + run: | + docker compose exec -T backend python -c "import asyncio; from app.database import init_db; asyncio.run(init_db())" + + - name: Ensure compose services are healthy + run: | + set -euo pipefail + docker inspect --format '{{json .State.Health.Status}}' solfoundry-db | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-redis | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-backend | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-frontend | rg '"healthy"' + - name: Tear down if: always() run: docker compose down -v @@ -295,7 +336,7 @@ jobs: ci-status: name: CI Status Summary runs-on: ubuntu-latest - needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint, compose-smoke-test, backend-integration-postgres] + needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint, container-image-build, compose-smoke-test, backend-integration-postgres] if: always() steps: - name: Check CI Results @@ -312,6 +353,7 @@ jobs: echo "| Frontend Build | ${{ needs.frontend-build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Contracts Check | ${{ needs.contracts-check.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Rust Lint | ${{ needs.rust-lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Container Image Build | ${{ needs.container-image-build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Compose Smoke Test | ${{ needs.compose-smoke-test.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Backend Integration (Postgres) | ${{ needs.backend-integration-postgres.result }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bf585ba17..4669d20e0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,7 @@ on: environment: description: 'Deployment environment' required: true - default: 'production' + default: 'staging' type: choice options: - production @@ -30,22 +30,24 @@ env: PYTHON_VERSION: '3.11' NODE_VERSION: '20' REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}/backend - DEPLOY_ENV: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }} - DEPLOY_TAG: ${{ inputs.image_tag || github.sha }} + BACKEND_IMAGE: ${{ github.repository }}/backend + FRONTEND_IMAGE: ${{ github.repository }}/frontend + # Push to main auto-deploys staging; production is manual via workflow_dispatch. + DEPLOY_ENV: ${{ github.event_name == 'push' && 'staging' || inputs.environment }} + # Rollback uses an immutable image tag and is independent from source checkout. + BACKEND_IMAGE_TAG: ${{ inputs.image_tag || github.sha }} jobs: # ==================== BUILD FRONTEND ==================== build-frontend: name: Build Frontend runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' || inputs.image_tag == '' outputs: has_frontend: ${{ steps.check.outputs.has_frontend }} steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ env.DEPLOY_TAG }} - name: Check for package.json id: check @@ -76,14 +78,14 @@ jobs: run: npm run build env: NODE_ENV: production - NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }} + VITE_API_URL: ${{ secrets.API_URL }} - name: Upload Build Artifacts if: steps.check.outputs.has_frontend == 'true' uses: actions/upload-artifact@v4 with: name: frontend-build - path: frontend/.next/ + path: frontend/dist/ retention-days: 7 # ==================== DEPLOY FRONTEND TO VERCEL ==================== @@ -92,14 +94,12 @@ jobs: runs-on: ubuntu-latest environment: ${{ env.DEPLOY_ENV }} needs: build-frontend - if: needs.build-frontend.outputs.has_frontend == 'true' + if: (github.event_name != 'workflow_dispatch' || inputs.image_tag == '') && needs.build-frontend.outputs.has_frontend == 'true' outputs: vercel_preview_url: ${{ steps.vercel_deploy_preview.outputs['preview-url'] }} steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ env.DEPLOY_TAG }} - name: Deploy to Vercel (production) id: vercel_deploy @@ -143,15 +143,16 @@ jobs: path: staging-url.txt retention-days: 7 - # ==================== BUILD BACKEND DOCKER IMAGE ==================== - build-backend: - name: Build Backend Docker Image + # ==================== BUILD & PUSH CONTAINER IMAGES ==================== + build-and-push-images: + name: Build and Push Images (GHCR) runs-on: ubuntu-latest permissions: contents: read packages: write outputs: - image_tag: ${{ steps.meta.outputs.tags }} + backend_image_tag: ${{ steps.tags.outputs.backend_image_tag }} + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.image_tag == '') steps: - name: Checkout uses: actions/checkout@v4 @@ -166,33 +167,109 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract Metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix= - type=ref,event=branch - type=semver,pattern={{version}} + - name: Compute image tags + id: tags + run: | + BACKEND_TAGS="${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.ref_name }}" + FRONTEND_TAGS="${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.ref_name }}" + if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + BACKEND_TAGS="$BACKEND_TAGS,${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest" + FRONTEND_TAGS="$FRONTEND_TAGS,${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest" + else + BACKEND_TAGS="$BACKEND_TAGS,${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:staging" + FRONTEND_TAGS="$FRONTEND_TAGS,${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:staging" + fi + echo "backend_tags=$BACKEND_TAGS" >> $GITHUB_OUTPUT + echo "frontend_tags=$FRONTEND_TAGS" >> $GITHUB_OUTPUT + echo "backend_image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT - - name: Build and Push Docker Image + - name: Build and Push Backend Image uses: docker/build-push-action@v5 with: context: ./backend push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.tags.outputs.backend_tags }} cache-from: type=gha cache-to: type=gha,mode=max file: ./backend/Dockerfile + - name: Build and Push Frontend Image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.tags.outputs.frontend_tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + file: ./frontend/Dockerfile + + # ==================== DEPLOY REDIS TO DIGITALOCEAN ==================== + deploy-redis: + name: Deploy Redis (DigitalOcean) + runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} + needs: build-and-push-images + if: always() + steps: + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + - name: Save DigitalOcean kubeconfig + run: doctl kubernetes cluster kubeconfig save ${{ secrets.DIGITALOCEAN_CLUSTER_NAME }} + + - name: Apply Redis resources + run: | + kubectl apply --namespace ${{ env.DEPLOY_ENV }} -f - <<'EOF' + apiVersion: apps/v1 + kind: Deployment + metadata: + name: solfoundry-redis + spec: + replicas: 1 + selector: + matchLabels: + app: solfoundry-redis + template: + metadata: + labels: + app: solfoundry-redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + readinessProbe: + tcpSocket: + port: 6379 + initialDelaySeconds: 5 + periodSeconds: 10 + --- + apiVersion: v1 + kind: Service + metadata: + name: redis + spec: + selector: + app: solfoundry-redis + ports: + - protocol: TCP + port: 6379 + targetPort: 6379 + EOF + + - name: Wait for Redis rollout + run: | + kubectl rollout status deployment/solfoundry-redis --namespace ${{ env.DEPLOY_ENV }} --timeout=300s + # ==================== DEPLOY BACKEND TO DIGITALOCEAN ==================== deploy-backend: name: Deploy Backend (DigitalOcean) runs-on: ubuntu-latest environment: ${{ env.DEPLOY_ENV }} - needs: build-backend + needs: [build-and-push-images, deploy-redis] if: always() steps: - name: Checkout @@ -210,7 +287,7 @@ jobs: run: | # Update deployment image kubectl set image deployment/solfoundry-backend \ - backend=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEPLOY_TAG }} \ + backend=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ env.BACKEND_IMAGE_TAG }} \ --namespace ${{ env.DEPLOY_ENV }} # Wait for rollout @@ -271,17 +348,26 @@ jobs: kubectl port-forward deployment/solfoundry-backend 18000:8000 --namespace "$KUBE_NAMESPACE" >/tmp/port-forward.log 2>&1 & PF_PID=$! trap 'kill "$PF_PID" >/dev/null 2>&1 || true' EXIT - for i in {1..30}; do if curl -fsS http://localhost:18000/health >/dev/null 2>&1; then break fi sleep 2 done - - curl -fsS http://localhost:18000/health + HEALTH_JSON="$(curl -fsS http://localhost:18000/health)" + echo "$HEALTH_JSON" + HEALTH_JSON="$HEALTH_JSON" python - <<'PY' +import json, os +health = json.loads(os.environ["HEALTH_JSON"]) +if health.get("database") != "ok": + raise SystemExit("backend database connectivity check failed") +if health.get("redis") != "ok": + raise SystemExit("backend redis connectivity check failed") +print("backend health verified (database + redis)") +PY - name: Verify frontend /health endpoint + if: github.event_name != 'workflow_dispatch' || inputs.image_tag == '' run: | set -euo pipefail if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then @@ -294,7 +380,7 @@ jobs: deploy-status: name: Deployment Status runs-on: ubuntu-latest - needs: [build-frontend, deploy-frontend, build-backend, deploy-backend, migrate-database] + needs: [build-frontend, deploy-frontend, build-and-push-images, deploy-redis, deploy-backend, migrate-database, verify-deployment] if: always() steps: - name: Generate Deployment Summary @@ -302,19 +388,23 @@ jobs: echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Environment:** ${{ env.DEPLOY_ENV }}" >> $GITHUB_STEP_SUMMARY - echo "**Commit/Image Tag:** ${{ env.DEPLOY_TAG }}" >> $GITHUB_STEP_SUMMARY + echo "**Backend Image Tag:** ${{ env.BACKEND_IMAGE_TAG }}" >> $GITHUB_STEP_SUMMARY echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Frontend Build | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Frontend Deploy | ${{ needs.deploy-frontend.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Backend Build | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build & Push Images | ${{ needs.build-and-push-images.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Redis Deploy | ${{ needs.deploy-redis.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Backend Deploy | ${{ needs.deploy-backend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Database Migration | ${{ needs.migrate-database.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Post-deploy Verification | ${{ needs.verify-deployment.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Links" >> $GITHUB_STEP_SUMMARY - if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.image_tag }}" != "" ]; then + echo "- Backend rollback/tag deployment executed (frontend unchanged)." >> $GITHUB_STEP_SUMMARY + elif [ "${{ env.DEPLOY_ENV }}" = "production" ]; then echo "- [Production Site](https://solfoundry.org)" >> $GITHUB_STEP_SUMMARY echo "- [API Health](https://api.solfoundry.org/health)" >> $GITHUB_STEP_SUMMARY else diff --git a/.gitignore b/.gitignore index d977f837c..2f9977e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Environment & Secrets .env *.env +.env.* +!.env.example *.key *.pem treasury_key.json diff --git a/backend/app/main.py b/backend/app/main.py index b590a1e85..869f6750d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,11 +2,13 @@ import asyncio import logging +import os from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware +import redis.asyncio as redis from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.logging_config import setup_logging @@ -249,10 +251,21 @@ async def health_check(): logger.error("Health check DB failure: %s", e) db_status = "error" + redis_status = "ok" + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + try: + redis_client = redis.from_url(redis_url) + await redis_client.ping() + await redis_client.close() + except Exception as e: + logger.error("Health check Redis failure: %s", e) + redis_status = "error" + last_sync = get_last_sync() return { - "status": "ok" if db_status == "ok" else "degraded", + "status": "ok" if db_status == "ok" and redis_status == "ok" else "degraded", "database": db_status, + "redis": redis_status, "bounties": len(_bounty_store), "contributors": len(_store), "last_sync": last_sync.isoformat() if last_sync else None,