Keep @ content-type guidance system-only and strip legacy leak text #282
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy FPAI Backend to AWS Lambda | |
| on: | |
| push: | |
| branches: ["main", "prod"] | |
| paths: | |
| - "backend/**" | |
| - ".github/workflows/deploy-backend-aws.yml" | |
| workflow_dispatch: | |
| permissions: | |
| id-token: write | |
| contents: read | |
| concurrency: | |
| group: deploy-fpai-backend-${{ github.ref_name == 'prod' && 'prod' || 'dev' }} | |
| cancel-in-progress: true | |
| env: | |
| AWS_REGION: us-east-1 | |
| AWS_ROLE_TO_ASSUME: arn:aws:iam::675177356722:role/GitHubActionsDeployer | |
| DEPLOY_REF: ${{ github.ref_name }} | |
| ENVIRONMENT: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }} | |
| PIP_DISABLE_PIP_VERSION_CHECK: "1" | |
| PIP_NO_PYTHON_VERSION_WARNING: "1" | |
| jobs: | |
| # ============================================ | |
| # PRE-DEPLOYMENT CHECKS (run in parallel) | |
| # ============================================ | |
| lint: | |
| name: Lint & Code Quality | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: .github/workflows/deploy-backend-aws.yml | |
| - name: Install linting tools | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install ruff mypy | |
| - name: Run Ruff linter | |
| working-directory: backend | |
| run: | | |
| ruff check . --output-format=github || true | |
| ruff check . --select=E,F,W --exit-zero | |
| - name: Run Ruff formatter check | |
| working-directory: backend | |
| run: | | |
| ruff format --check . || echo "::warning::Code formatting issues detected" | |
| - name: Validate Python syntax (strict) | |
| working-directory: backend | |
| run: | | |
| python -m py_compile lambda_handler.py agent.py llm_client.py wiki_images.py | |
| - name: Check for common issues | |
| working-directory: backend | |
| run: | | |
| # Check for print statements that should be logging | |
| echo "Checking for debug print statements..." | |
| if grep -rn "print(" *.py --include="*.py" | grep -v "test_" | grep -v "__main__" | grep -v "# debug" | head -5; then | |
| echo "::warning::Found print statements - consider using logging instead" | |
| fi | |
| security: | |
| name: Security Scan | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: .github/workflows/deploy-backend-aws.yml | |
| - name: Install security tools | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install pip-audit bandit | |
| - name: Run pip-audit (dependency vulnerabilities) | |
| id: pip_audit | |
| working-directory: backend | |
| run: | | |
| # Audit declared requirements directly; avoid installing heavy runtime deps (e.g. manim) | |
| # that require OS libraries just to perform vulnerability checks. | |
| set +e | |
| if grep -qE "^[a-zA-Z]" requirements.txt 2>/dev/null; then | |
| pip-audit -r requirements.txt -f json -o pip-audit-report.json | |
| AUDIT_STATUS=$? | |
| else | |
| echo "No external dependencies to audit" | |
| printf '{"message":"No external dependencies to audit"}\n' > pip-audit-report.json | |
| AUDIT_STATUS=0 | |
| fi | |
| set -e | |
| if [ ! -s pip-audit-report.json ]; then | |
| printf '{"audit_error":"pip-audit did not generate a report"}\n' > pip-audit-report.json | |
| fi | |
| VULN_COUNT=$(jq -r ' | |
| if has("dependencies") and (.dependencies | type == "array") then | |
| [.dependencies[]? | (.vulns // []) | length] | add // 0 | |
| else | |
| 0 | |
| end | |
| ' pip-audit-report.json 2>/dev/null || echo 0) | |
| if [ "$VULN_COUNT" -gt 0 ]; then | |
| echo "::error::pip-audit found $VULN_COUNT vulnerabilities" | |
| FINAL_STATUS=1 | |
| elif [ "$AUDIT_STATUS" -ne 0 ]; then | |
| # Dependency-resolution failures (e.g. native build deps) should not block deploy | |
| # when no vulnerability report is available. | |
| echo "::warning::pip-audit failed to fully resolve dependencies; continuing without blocking on resolver error" | |
| FINAL_STATUS=0 | |
| else | |
| FINAL_STATUS=0 | |
| fi | |
| echo "status=$FINAL_STATUS" >> "$GITHUB_OUTPUT" | |
| echo "vuln_count=$VULN_COUNT" >> "$GITHUB_OUTPUT" | |
| - name: Run Bandit (security linter) | |
| id: bandit_scan | |
| working-directory: backend | |
| run: | | |
| set +e | |
| # Only high-severity issues should block deployment. | |
| bandit -r . -x ./tests -lll -ii -f json -o bandit-report.json | |
| BANDIT_EXIT=$? | |
| set -e | |
| if [ ! -s bandit-report.json ]; then | |
| printf '{"results":[]}\n' > bandit-report.json | |
| fi | |
| HIGH_COUNT=$(jq -r '[.results[]? | select(.issue_severity == "HIGH")] | length' bandit-report.json 2>/dev/null || echo 0) | |
| if [ "$HIGH_COUNT" -gt 0 ]; then | |
| echo "::error::Bandit detected $HIGH_COUNT high-severity findings" | |
| FINAL_STATUS=1 | |
| else | |
| FINAL_STATUS=0 | |
| if [ "$BANDIT_EXIT" -ne 0 ]; then | |
| echo "::warning::Bandit returned non-zero with no high-severity findings" | |
| fi | |
| fi | |
| echo "status=$FINAL_STATUS" >> "$GITHUB_OUTPUT" | |
| echo "high_count=$HIGH_COUNT" >> "$GITHUB_OUTPUT" | |
| - name: Upload backend security reports | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: backend-security-reports | |
| path: | | |
| backend/pip-audit-report.json | |
| backend/bandit-report.json | |
| retention-days: 7 | |
| - name: Enforce security gate | |
| run: | | |
| if [ "${{ steps.pip_audit.outputs.status }}" != "0" ]; then | |
| exit 1 | |
| fi | |
| if [ "${{ steps.bandit_scan.outputs.status }}" != "0" ]; then | |
| exit 1 | |
| fi | |
| secret-scan: | |
| name: Secret Scan (Gitleaks) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| fetch-depth: 0 | |
| - name: Install gitleaks CLI | |
| run: | | |
| go version | |
| go install github.com/zricethezav/gitleaks/v8@v8.24.2 | |
| echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| - name: Run gitleaks | |
| run: | | |
| gitleaks detect --source . --redact --report-format sarif --report-path gitleaks-report.sarif | |
| - name: Upload gitleaks report | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: backend-gitleaks-report | |
| path: gitleaks-report.sarif | |
| retention-days: 7 | |
| test: | |
| name: Unit Tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: .github/workflows/deploy-backend-aws.yml | |
| - name: Install test dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install pytest pytest-cov pytest-xdist boto3 numpy | |
| - name: Run unit tests with coverage | |
| working-directory: backend | |
| run: | | |
| python -m pytest -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=25 | |
| env: | |
| AWS_DEFAULT_REGION: us-east-1 | |
| AWS_ACCESS_KEY_ID: dummy | |
| AWS_SECRET_ACCESS_KEY: dummy | |
| JOBS_TABLE: fpai-jobs-test | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: coverage-report | |
| path: backend/coverage.xml | |
| retention-days: 7 | |
| validate-sam: | |
| name: Validate SAM Template | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Install SAM CLI | |
| uses: aws-actions/setup-sam@v2 | |
| with: | |
| use-installer: true | |
| - name: Validate SAM template | |
| working-directory: backend | |
| run: | | |
| sam validate --lint | |
| echo "✅ SAM template is valid" | |
| - name: Check template structure | |
| working-directory: backend | |
| run: | | |
| # Verify critical resources are defined | |
| if ! grep -q "AWS::Serverless::Function" template.yaml; then | |
| echo "::error::No Lambda function defined in template" | |
| exit 1 | |
| fi | |
| if ! grep -q "AWS::Serverless::Api\|Api:" template.yaml; then | |
| echo "::warning::No explicit API Gateway defined" | |
| fi | |
| echo "✅ Template structure looks good" | |
| validate-routes: | |
| name: Validate API Routes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: .github/workflows/deploy-backend-aws.yml | |
| - name: Validate route definitions | |
| working-directory: backend | |
| run: | | |
| pip install boto3 numpy | |
| python << 'EOF' | |
| import ast | |
| import sys | |
| import re | |
| # Parse lambda_handler.py | |
| import unittest.mock as mock | |
| import sys | |
| mock_boto = mock.MagicMock() | |
| sys.modules['boto3'] = mock_boto | |
| with open("lambda_handler.py", "r") as f: | |
| content = f.read() | |
| tree = ast.parse(content) | |
| # Extract routes from handler function | |
| routes_in_code = [] | |
| route_pattern = r'(path|resource)\s*==\s*["\']([^"\']+)["\']' | |
| method_pattern = r'method\s*==\s*["\']([^"\']+)["\']' | |
| routes_found = [m[1] for m in re.findall(route_pattern, content)] | |
| print(f"Found {len(routes_found)} route definitions in code:") | |
| for route in routes_found: | |
| print(f" - {route}") | |
| # Verify handler functions exist for each route | |
| handler_functions = [] | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.FunctionDef) and node.name.startswith("handle_"): | |
| handler_functions.append(node.name) | |
| print(f"\nHandler functions found: {handler_functions}") | |
| # Basic validation against current route surface | |
| expected_routes = [ | |
| "/", | |
| "/access", | |
| "/chat", | |
| "/continue", | |
| "/documents", | |
| "/events/frontend", | |
| "/feedback", | |
| "/health", | |
| "/images", | |
| "/invites/redeem", | |
| "/jobs/{job_id}", | |
| "/jobs/{job_id}/events", | |
| "/memory", | |
| "/session", | |
| "/sessions", | |
| "/status", | |
| "/tools", | |
| "/uploads/prepare", | |
| "/uploads/presigned", | |
| "/waitlist", | |
| ] | |
| missing_routes = [r for r in expected_routes if r not in routes_found] | |
| if missing_routes: | |
| print(f"\n❌ Missing expected routes: {missing_routes}") | |
| sys.exit(1) | |
| expected_handlers = [ | |
| "handle_chat", | |
| "handle_clear_memory", | |
| "handle_continue", | |
| "handle_delete_session", | |
| "handle_frontend_events", | |
| "handle_feedback", | |
| "handle_get_access", | |
| "handle_get_job", | |
| "handle_get_job_events", | |
| "handle_get_memory", | |
| "handle_get_presigned_url", | |
| "handle_prepare_uploads", | |
| "handle_get_status", | |
| "handle_health", | |
| "handle_list_documents", | |
| "handle_list_sessions", | |
| "handle_redeem_invite", | |
| "handle_search_images", | |
| "handle_session", | |
| "handle_tools", | |
| "handle_update_memory", | |
| "handle_update_session", | |
| "handle_waitlist_signup", | |
| ] | |
| missing_handlers = [h for h in expected_handlers if h not in handler_functions] | |
| if missing_handlers: | |
| print(f"\n❌ Missing expected handlers: {missing_handlers}") | |
| sys.exit(1) | |
| print("\n✅ Route validation complete") | |
| EOF | |
| - name: Test route imports | |
| working-directory: backend | |
| run: | | |
| python -c " | |
| import sys | |
| try: | |
| import unittest.mock as mock | |
| mock_boto = mock.MagicMock() | |
| sys.modules['boto3'] = mock_boto | |
| from lambda_handler import handler | |
| print('✅ Lambda handler imports successfully') | |
| # Test each route with mock events | |
| test_routes = [ | |
| {'httpMethod': 'GET', 'path': '/health', 'ok': {200, 401}}, | |
| {'httpMethod': 'GET', 'path': '/status', 'ok': {200, 401}}, | |
| {'httpMethod': 'GET', 'path': '/tools', 'ok': {200, 401}}, | |
| {'httpMethod': 'OPTIONS', 'path': '/chat', 'ok': {200}}, | |
| {'httpMethod': 'POST', 'path': '/waitlist', 'body': '{\"email\":\"ci@example.com\"}', 'ok': {200, 201, 202, 400, 401, 410}}, | |
| ] | |
| for event in test_routes: | |
| try: | |
| response = handler(event, None) | |
| status = response.get('statusCode', 'unknown') | |
| print(f\" ✅ {event['httpMethod']} {event['path']} -> {status}\") | |
| if status not in event['ok']: | |
| print(f\" ❌ Unexpected status for {event['path']}: {status} (expected one of {sorted(event['ok'])})\") | |
| sys.exit(1) | |
| except Exception as e: | |
| print(f\" ❌ {event['httpMethod']} {event['path']} -> Error: {e}\") | |
| sys.exit(1) | |
| except Exception as e: | |
| print(f'❌ Import failed: {e}') | |
| sys.exit(1) | |
| " | |
| env: | |
| AWS_DEFAULT_REGION: us-east-1 | |
| AWS_ACCESS_KEY_ID: dummy | |
| AWS_SECRET_ACCESS_KEY: dummy | |
| JOBS_TABLE: fpai-jobs-test | |
| # ============================================ | |
| # QUALITY GATE (all checks must pass) | |
| # ============================================ | |
| quality-gate: | |
| name: Quality Gate | |
| needs: [lint, security, secret-scan, test, validate-sam, validate-routes] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: All checks passed | |
| run: | | |
| echo "## ✅ All Pre-Deployment Checks Passed" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY | |
| echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Lint & Code Quality | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Security Scan | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Secret Scan | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Unit Tests | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| SAM Validation | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Route Validation | ✅ Passed |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Proceeding to deployment..." >> $GITHUB_STEP_SUMMARY | |
| # ============================================ | |
| # DEPLOYMENT (only after quality gate passes) | |
| # ============================================ | |
| deploy: | |
| needs: quality-gate | |
| runs-on: ubuntu-latest | |
| environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }} | |
| env: | |
| FIREBASE_PROJECT_ID: ${{ vars.BACKEND_FIREBASE_PROJECT_ID || 'fpai-cbcb2' }} | |
| OPENAI_MODEL: ${{ vars.BACKEND_OPENAI_MODEL || 'gpt-5.2' }} | |
| API_KEYS_SECRET_ID: ${{ vars.BACKEND_API_KEYS_SECRET_ID }} | |
| ALLOWED_ORIGINS: ${{ vars.BACKEND_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:5173,https://fpai.reunifylabs.com,https://d1gyb6wodlq2q2.cloudfront.net' }} | |
| ASSETS_ALLOWED_ORIGINS: ${{ vars.BACKEND_ASSETS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:5173,https://d1gyb6wodlq2q2.cloudfront.net,https://fpai.reunifylabs.com' }} | |
| SHARE_PUBLIC_BASE_URL: ${{ vars.BACKEND_SHARE_PUBLIC_BASE_URL || 'https://fpai.reunifylabs.com' }} | |
| REQUIRE_BRAVE_API_KEY: ${{ vars.BACKEND_REQUIRE_BRAVE_API_KEY || 'false' }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.DEPLOY_REF }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| cache-dependency-path: | | |
| backend/requirements.txt | |
| backend/template.yaml | |
| - name: Install SAM CLI | |
| uses: aws-actions/setup-sam@v2 | |
| with: | |
| use-installer: true | |
| - name: Configure AWS credentials via OIDC | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| aws-region: ${{ env.AWS_REGION }} | |
| role-to-assume: ${{ env.AWS_ROLE_TO_ASSUME }} | |
| - name: Cache SAM build artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: backend/.aws-sam/cache | |
| key: ${{ runner.os }}-sam-${{ env.ENVIRONMENT }}-${{ hashFiles('backend/template.yaml', 'backend/requirements.txt') }} | |
| restore-keys: | | |
| ${{ runner.os }}-sam-${{ env.ENVIRONMENT }}- | |
| ${{ runner.os }}-sam- | |
| - name: Who am I (debug) | |
| run: aws sts get-caller-identity | |
| - name: Sync secrets to AWS Secrets Manager | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} | |
| BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} | |
| FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }} | |
| REQUIRE_BRAVE_API_KEY: ${{ env.REQUIRE_BRAVE_API_KEY }} | |
| run: | | |
| if [ -z "$OPENAI_API_KEY" ]; then | |
| echo "::error::Missing GitHub secret OPENAI_API_KEY" | |
| exit 1 | |
| fi | |
| EFFECTIVE_GEMINI_API_KEY="${GEMINI_API_KEY:-$GOOGLE_API_KEY}" | |
| if [ -z "$EFFECTIVE_GEMINI_API_KEY" ]; then | |
| echo "::error::Missing GitHub secret GEMINI_API_KEY (or GOOGLE_API_KEY)" | |
| exit 1 | |
| fi | |
| if [ -z "$FIREBASE_SERVICE_ACCOUNT_JSON" ]; then | |
| echo "::error::Missing GitHub secret FIREBASE_SERVICE_ACCOUNT_JSON" | |
| exit 1 | |
| fi | |
| if [ "${REQUIRE_BRAVE_API_KEY}" != "false" ] && [ -z "$BRAVE_API_KEY" ]; then | |
| echo "::error::Missing GitHub secret BRAVE_API_KEY" | |
| exit 1 | |
| fi | |
| if [ "${{ env.ENVIRONMENT }}" = "prod" ]; then | |
| DEFAULT_SECRET_ID="fpai/api-keys" | |
| else | |
| DEFAULT_SECRET_ID="fpai/api-keys-${{ env.ENVIRONMENT }}" | |
| fi | |
| SECRET_ID="${API_KEYS_SECRET_ID:-$DEFAULT_SECRET_ID}" | |
| echo "Using Secrets Manager secret: $SECRET_ID" | |
| if aws secretsmanager describe-secret --secret-id "$SECRET_ID" >/dev/null 2>&1; then | |
| SECRET_EXISTS=true | |
| CURRENT_SECRET=$(aws secretsmanager get-secret-value \ | |
| --secret-id "$SECRET_ID" \ | |
| --query SecretString \ | |
| --output text 2>/dev/null || echo "{}") | |
| else | |
| SECRET_EXISTS=false | |
| CURRENT_SECRET="{}" | |
| fi | |
| UPDATED_SECRET=$(printf '%s' "$CURRENT_SECRET" \ | |
| | jq --arg openai_key "$OPENAI_API_KEY" \ | |
| --arg anthropic_key "$ANTHROPIC_API_KEY" \ | |
| --arg gemini_key "$EFFECTIVE_GEMINI_API_KEY" \ | |
| --arg brave_key "$BRAVE_API_KEY" \ | |
| --arg firebase_sa "$FIREBASE_SERVICE_ACCOUNT_JSON" \ | |
| '.OPENAI_API_KEY = $openai_key | |
| | .GEMINI_API_KEY = $gemini_key | |
| | .FIREBASE_SERVICE_ACCOUNT_JSON = $firebase_sa | |
| | if (($anthropic_key // "") | length) > 0 then .ANTHROPIC_API_KEY = $anthropic_key else . end | |
| | if (($brave_key // "") | length) > 0 then .BRAVE_API_KEY = $brave_key else . end') | |
| if [ "$SECRET_EXISTS" = "true" ]; then | |
| aws secretsmanager put-secret-value \ | |
| --secret-id "$SECRET_ID" \ | |
| --secret-string "$UPDATED_SECRET" | |
| else | |
| aws secretsmanager create-secret \ | |
| --name "$SECRET_ID" \ | |
| --secret-string "$UPDATED_SECRET" | |
| fi | |
| echo "✅ Updated OPENAI_API_KEY + GEMINI_API_KEY + FIREBASE_SERVICE_ACCOUNT_JSON in Secrets Manager ($SECRET_ID)" | |
| if [ -n "$ANTHROPIC_API_KEY" ]; then | |
| echo "✅ ANTHROPIC_API_KEY was also updated in Secrets Manager" | |
| else | |
| echo "INFO: ANTHROPIC_API_KEY not provided; existing Secrets Manager value preserved" | |
| fi | |
| if [ -n "$BRAVE_API_KEY" ]; then | |
| echo "✅ BRAVE_API_KEY was also updated in Secrets Manager" | |
| else | |
| echo "INFO: BRAVE_API_KEY not provided; existing Secrets Manager value preserved" | |
| fi | |
| - name: Validate required Secrets Manager fields | |
| env: | |
| REQUIRE_BRAVE_API_KEY: ${{ env.REQUIRE_BRAVE_API_KEY }} | |
| run: | | |
| if [ "${{ env.ENVIRONMENT }}" = "prod" ]; then | |
| DEFAULT_SECRET_ID="fpai/api-keys" | |
| else | |
| DEFAULT_SECRET_ID="fpai/api-keys-${{ env.ENVIRONMENT }}" | |
| fi | |
| SECRET_ID="${API_KEYS_SECRET_ID:-$DEFAULT_SECRET_ID}" | |
| SECRET_JSON=$(aws secretsmanager get-secret-value \ | |
| --secret-id "$SECRET_ID" \ | |
| --query SecretString \ | |
| --output text) | |
| echo "$SECRET_JSON" | jq -e ' | |
| ((.OPENAI_API_KEY // "") | length > 0) and | |
| ((.GEMINI_API_KEY // "") | length > 0) and | |
| ((.FIREBASE_SERVICE_ACCOUNT_JSON // "") | length > 0) | |
| ' >/dev/null | |
| if [ "${REQUIRE_BRAVE_API_KEY}" != "false" ]; then | |
| echo "$SECRET_JSON" | jq -e '(.BRAVE_API_KEY // "") | length > 0' >/dev/null | |
| fi | |
| - name: Validate required dev assets CORS origins | |
| run: | | |
| set -euo pipefail | |
| ASSETS_ALLOWED_ORIGINS_CLEAN=$(echo "${ASSETS_ALLOWED_ORIGINS}" | tr -d '[:space:]') | |
| REQUIRED_ORIGINS=( | |
| "http://localhost:3000" | |
| "http://localhost:5173" | |
| "https://d1gyb6wodlq2q2.cloudfront.net" | |
| "https://fpai.reunifylabs.com" | |
| ) | |
| MISSING=() | |
| for origin in "${REQUIRED_ORIGINS[@]}"; do | |
| if [[ ",${ASSETS_ALLOWED_ORIGINS_CLEAN}," != *",${origin},"* ]]; then | |
| MISSING+=("${origin}") | |
| fi | |
| done | |
| if [ "${#MISSING[@]}" -gt 0 ]; then | |
| echo "::error::ASSETS_ALLOWED_ORIGINS is missing required values: ${MISSING[*]}" | |
| exit 1 | |
| fi | |
| - name: SAM Build | |
| working-directory: backend | |
| run: sam build --cached --parallel --cache-dir .aws-sam/cache | |
| - name: SAM Deploy | |
| working-directory: backend | |
| run: | | |
| if [ "${{ env.ENVIRONMENT }}" = "prod" ]; then | |
| DEFAULT_SECRET_ID="fpai/api-keys" | |
| else | |
| DEFAULT_SECRET_ID="fpai/api-keys-${{ env.ENVIRONMENT }}" | |
| fi | |
| SECRET_ID="${API_KEYS_SECRET_ID:-$DEFAULT_SECRET_ID}" | |
| ALLOWED_ORIGINS_CLEAN=$(echo "${ALLOWED_ORIGINS}" | tr -d '[:space:]') | |
| ASSETS_ALLOWED_ORIGINS_CLEAN=$(echo "${ASSETS_ALLOWED_ORIGINS}" | tr -d '[:space:]') | |
| SHARE_PUBLIC_BASE_URL_CLEAN=$(echo "${SHARE_PUBLIC_BASE_URL}" | tr -d '[:space:]') | |
| if [ "${{ env.ENVIRONMENT }}" = "prod" ]; then | |
| BETA_GATE_ENABLED=true | |
| else | |
| BETA_GATE_ENABLED=false | |
| fi | |
| sam deploy \ | |
| --config-env ${{ env.ENVIRONMENT }} \ | |
| --no-confirm-changeset \ | |
| --no-fail-on-empty-changeset \ | |
| --resolve-s3 \ | |
| --resolve-image-repos \ | |
| --capabilities CAPABILITY_IAM \ | |
| --parameter-overrides "Environment=${{ env.ENVIRONMENT }} OpenAIModel=${OPENAI_MODEL} FirebaseProjectId=${FIREBASE_PROJECT_ID} BetaGateEnabled=${BETA_GATE_ENABLED} EnableJobEventStreaming=true ApiKeysSecretId=${SECRET_ID} AllowedOrigins=${ALLOWED_ORIGINS_CLEAN} AssetsAllowedOrigins=${ASSETS_ALLOWED_ORIGINS_CLEAN} SharePublicBaseUrl=${SHARE_PUBLIC_BASE_URL_CLEAN}" | |
| - name: Force API Gateway stage deployment | |
| run: | | |
| ENDPOINT=$(aws cloudformation describe-stacks \ | |
| --stack-name fpai-backend-${{ env.ENVIRONMENT }} \ | |
| --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \ | |
| --output text) | |
| REST_API_ID=$(echo "$ENDPOINT" | sed -E 's#https://([^.]+)\.execute-api\..*#\1#') | |
| aws apigateway create-deployment \ | |
| --rest-api-id "$REST_API_ID" \ | |
| --stage-name "${{ env.ENVIRONMENT }}" \ | |
| --description "github-actions-${GITHUB_RUN_ID}-${GITHUB_SHA}" | |
| - name: Get API Endpoint | |
| id: get-endpoint | |
| working-directory: backend | |
| run: | | |
| ENDPOINT=$(aws cloudformation describe-stacks \ | |
| --stack-name fpai-backend-${{ env.ENVIRONMENT }} \ | |
| --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \ | |
| --output text) | |
| echo "endpoint=$ENDPOINT" >> $GITHUB_OUTPUT | |
| echo "API Endpoint: $ENDPOINT" | |
| - name: Health Check | |
| run: | | |
| ENDPOINT="${{ steps.get-endpoint.outputs.endpoint }}" | |
| echo "Testing health endpoint: $ENDPOINT/health" | |
| for i in {1..5}; do | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$ENDPOINT/health" || echo "000") | |
| # 200 = healthy (auth disabled), 401 = Lambda is up but auth is enforced — both mean the function is running. | |
| if [ "$STATUS" = "200" ] || [ "$STATUS" = "401" ]; then | |
| echo "✅ Lambda is responding (status: $STATUS)" | |
| exit 0 | |
| fi | |
| echo "Attempt $i: Status $STATUS, retrying..." | |
| sleep 5 | |
| done | |
| echo "❌ Health check failed after 5 attempts" | |
| exit 1 | |
| - name: Test Chat Endpoint | |
| if: success() | |
| run: | | |
| ENDPOINT="${{ steps.get-endpoint.outputs.endpoint }}" | |
| echo "Testing chat endpoint reachability: $ENDPOINT/chat" | |
| STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ENDPOINT/chat" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"message": "Hello"}') | |
| # 200/202 = success, 401 = auth enforced (expected in prod), 400 = reached but bad input — all confirm Lambda is live. | |
| if [ "$STATUS" = "200" ] || [ "$STATUS" = "202" ] || [ "$STATUS" = "401" ] || [ "$STATUS" = "400" ]; then | |
| echo "✅ Chat endpoint reachable (status: $STATUS)" | |
| else | |
| echo "⚠️ Chat endpoint returned unexpected status: $STATUS" | |
| fi | |
| notify: | |
| needs: deploy | |
| runs-on: ubuntu-latest | |
| if: always() | |
| steps: | |
| - name: Deployment Summary | |
| run: | | |
| echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY | |
| echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Environment | ${{ env.ENVIRONMENT }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Status | ${{ needs.deploy.result }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Commit | ${{ github.sha }} |" >> $GITHUB_STEP_SUMMARY |