From d0df5009c34708e1b3d3de02f75d3e3ee10087b0 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:33 +0700 Subject: [PATCH 01/30] BUY-5602: Add deploy-cloud-run-staging.yml workflow --- .../workflows/deploy-cloud-run-staging.yml | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 .github/workflows/deploy-cloud-run-staging.yml diff --git a/.github/workflows/deploy-cloud-run-staging.yml b/.github/workflows/deploy-cloud-run-staging.yml new file mode 100644 index 000000000..838376df5 --- /dev/null +++ b/.github/workflows/deploy-cloud-run-staging.yml @@ -0,0 +1,149 @@ +name: Deploy to Cloud Run (Staging) + +on: + workflow_dispatch: + inputs: + skip_tests: + description: 'Skip smoke tests' + required: false + default: 'false' + type: boolean + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + GCP_PROJECT_ID: gaia-calendar-488606 + GCP_REGION: asia-southeast1 + CLOUD_RUN_SERVICE: buywhere-api + CLOUD_SQL_INSTANCE: buywhere-staging:asia-southeast1:buywhere-db + +jobs: + build: + name: Build Docker Image + runs-on: ubuntu-latest + outputs: + image_tag: ghcr.io/${{ env.LOWER_REPO }}:sha-${{ github.sha }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set lowercase repository name + run: echo "LOWER_REPO=buywhere/buywhere" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/buywhere/buywhere + tags: | + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push to GHCR + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: | + ghcr.io/${{ env.LOWER_REPO }}:sha-${{ github.sha }} + ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy-cloud-run: + name: Deploy to Cloud Run Staging + needs: build + runs-on: ubuntu-latest + environment: staging + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD || secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker auth for GHCR + run: gcloud auth configure-docker ghcr.io --quiet + + - name: Create GCP Secret Manager secret for PostHog + run: | + echo "Creating/updating POSTHOG_API_KEY secret in GCP Secret Manager..." + echo -n "${{ secrets.POSTHOG_API_KEY_PRODUCTION }}" | gcloud secrets versions add buywhere-posthog-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || \ + gcloud secrets create buywhere-posthog-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} <<< "${{ secrets.POSTHOG_API_KEY_PRODUCTION }}" + + echo "Granting secretAccessor role to Cloud Run service account..." + GCP_SA=$(gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} --region ${{ env.GCP_REGION }} --project ${{ env.GCP_PROJECT_ID }} --format "value(spec.template.spec.serviceAccountName)" 2>/dev/null || echo "") + [ -z "$GCP_SA" ] && GCP_SA="${{ env.GCP_PROJECT_ID }}@${{ env.GCP_PROJECT_ID }}.iam.gserviceaccount.com" + gcloud secrets add-iam-policy-binding buywhere-posthog-api-key --member "serviceAccount:$GCP_SA" --role "roles/secretmanager.secretAccessor" --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || true + + - name: Deploy to Cloud Run + run: | + echo "Deploying to Cloud Run staging..." + gcloud run deploy ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --image ${{ needs.build.outputs.image_tag }} \ + --platform managed \ + --allow-unauthenticated \ + --add-cloudsql-instances ${{ env.CLOUD_SQL_INSTANCE }} \ + --set-env-vars "ENVIRONMENT=staging" \ + --set-env-vars "DATABASE_URL=${{ secrets.CLOUD_SQL_STAGING_CONNECTION_STRING }}" \ + --set-env-vars "REDIS_URL=${{ secrets.REDIS_STAGING_URL }}" \ + --set-env-vars "API_KEY_SECRET=${{ secrets.API_KEY_SECRET_STAGING }}" \ + --set-env-vars "SENTRY_DSN=${{ secrets.SENTRY_DSN_STAGING }}" \ + --set-secrets "POSTHOG_PROJECT_KEY=buywhere-posthog-api-key:latest" \ + --memory 1Gi \ + --cpu 2 \ + --min-instances 1 \ + --max-instances 10 \ + --concurrency 80 \ + --timeout 60s + + - name: Wait for service to stabilize + run: | + echo "Waiting 20 seconds for Cloud Run service to initialize..." + sleep 20 + + - name: Verify Cloud Run deployment + run: | + gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "table(status.conditions.name,status.conditions.status,status.conditions.reason)" + + api-latency-smoke-test: + name: API Latency Smoke Test + needs: deploy-cloud-run + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_tests != 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run API latency smoke test + env: + API_BASE_URL: ${{ needs.deploy-cloud-run.outputs.url }} + run: | + chmod +x scripts/cloud-run-latency-smoke-test.sh + ./scripts/cloud-run-latency-smoke-test.sh "$API_BASE_URL" From 6683065d0b6156aa815be63dfe9c1672181bf193 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:42 +0700 Subject: [PATCH 02/30] BUY-5602: Add deploy-cloud-run-production.yml workflow --- .../workflows/deploy-cloud-run-production.yml | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .github/workflows/deploy-cloud-run-production.yml diff --git a/.github/workflows/deploy-cloud-run-production.yml b/.github/workflows/deploy-cloud-run-production.yml new file mode 100644 index 000000000..67a18e8bb --- /dev/null +++ b/.github/workflows/deploy-cloud-run-production.yml @@ -0,0 +1,341 @@ +name: Deploy to Cloud Run (Production) + +on: + push: + branches: + - release + workflow_dispatch: + inputs: + skip_tests: + description: 'Skip smoke tests and load checks' + required: false + default: 'false' + type: boolean + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + GCP_PROJECT_ID: buywhere-production + GCP_REGION: asia-southeast1 + CLOUD_RUN_SERVICE: buywhere-api-production + CLOUD_SQL_INSTANCE: buywhere-production:asia-southeast1:buywhere-db + +jobs: + build: + name: Build Docker Image + runs-on: ubuntu-latest + outputs: + image_tag: ghcr.io/${{ github.repository }}:sha-${{ github.sha }} + deployment_id: ${{ steps.deploy_info.outputs.deployment_id }} + version: ${{ steps.deploy_info.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + 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: | + ghcr.io/${{ github.repository }} + tags: | + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push API image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository }}:sha-${{ github.sha }} + ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate deployment info + id: deploy_info + run: | + DEPLOYMENT_ID="cr-prod-$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::8}" + VERSION="${GITHUB_REF_NAME}-${GITHUB_SHA::8}" + echo "deployment_id=$DEPLOYMENT_ID" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Image tag: ${{ steps.meta.outputs.tags }}" + echo "Deployment ID: $DEPLOYMENT_ID" + echo "Version: $VERSION" + + deploy-cloud-run: + name: Deploy to Cloud Run + needs: build + runs-on: ubuntu-latest + environment: production + outputs: + deployment_id: ${{ needs.build.outputs.deployment_id }} + version: ${{ needs.build.outputs.version }} + service_url: ${{ steps.deploy.outputs.url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD || secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker auth for GHCR + run: gcloud auth configure-docker ${{ env.REGISTRY }} --quiet + + - name: Create GCP Secret Manager secrets and deploy + id: deploy + run: | + echo "Creating/updating GCP Secret Manager secrets..." + echo -n "${{ secrets.ADMIN_API_KEY_PRODUCTION }}" | gcloud secrets versions add buywhere-admin-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || \ + gcloud secrets create buywhere-admin-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} <<< "${{ secrets.ADMIN_API_KEY_PRODUCTION }}" + echo -n "${{ secrets.POSTHOG_API_KEY_PRODUCTION }}" | gcloud secrets versions add buywhere-posthog-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || \ + gcloud secrets create buywhere-posthog-api-key --data-file=- --project ${{ env.GCP_PROJECT_ID }} <<< "${{ secrets.POSTHOG_API_KEY_PRODUCTION }}" + + echo "Granting secretAccessor role to Cloud Run service account..." + GCP_SA=$(gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} --region ${{ env.GCP_REGION }} --project ${{ env.GCP_PROJECT_ID }} --format "value(spec.template.spec.serviceAccountName)" 2>/dev/null || echo "") + [ -z "$GCP_SA" ] && GCP_SA="${{ env.GCP_PROJECT_ID }}-sa@${{ env.GCP_PROJECT_ID }}.iam.gserviceaccount.com" + gcloud secrets add-iam-policy-binding buywhere-admin-api-key --member "serviceAccount:$GCP_SA" --role "roles/secretmanager.secretAccessor" --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || true + gcloud secrets add-iam-policy-binding buywhere-posthog-api-key --member "serviceAccount:$GCP_SA" --role "roles/secretmanager.secretAccessor" --project ${{ env.GCP_PROJECT_ID }} 2>/dev/null || true + + echo "Deploying to Cloud Run..." + SERVICE_URL=$(gcloud run deploy ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --image ${{ needs.build.outputs.image_tag }} \ + --platform managed \ + --allow-unauthenticated \ + --add-cloudsql-instances ${{ env.CLOUD_SQL_INSTANCE }} \ + --set-env-vars "ENVIRONMENT=production,DATABASE_URL=${{ secrets.CLOUD_SQL_PRODUCTION_CONNECTION_STRING }}" \ + --set-env-vars "REDIS_URL=${{ secrets.REDIS_PRODUCTION_URL }}" \ + --set-env-vars "API_KEY_SECRET=${{ secrets.API_KEY_SECRET_PRODUCTION }}" \ + --set-env-vars "SENTRY_DSN=${{ secrets.SENTRY_DSN_PRODUCTION }}" \ + --set-secrets "ADMIN_API_KEY=buywhere-admin-api-key:latest,POSTHOG_PROJECT_KEY=buywhere-posthog-api-key:latest" \ + --memory 1Gi \ + --cpu 2 \ + --min-instances 2 \ + --max-instances 20 \ + --concurrency 80 \ + --timeout 60s \ + --label "deployment-id=${{ needs.build.outputs.deployment_id }}" \ + --label "version=${{ needs.build.outputs.version }}" \ + --label "commit-sha=${{ github.sha }}" \ + --output json | jq -r '.status.url') + + echo "Service URL: $SERVICE_URL" + echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT + + - name: Wait for service to stabilize + run: | + echo "Waiting 30 seconds for Cloud Run service to initialize..." + sleep 30 + + - name: Verify Cloud Run deployment + run: | + gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "table(status.conditions.name,status.conditions.status,status.conditions.reason)" + + api-latency-smoke-test: + name: API Latency Smoke Test + needs: deploy-cloud-run + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_tests != 'true' }} + outputs: + test_passed: ${{ steps.smoke_test.outputs.passed }} + avg_latency_ms: ${{ steps.smoke_test.outputs.avg_latency }} + p95_latency_ms: ${{ steps.smoke_test.outputs.p95_latency }} + max_latency_ms: ${{ steps.smoke_test.outputs.max_latency }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run API latency smoke test + id: smoke_test + env: + API_BASE_URL: ${{ needs.deploy-cloud-run.outputs.service_url }} + DEPLOYMENT_ID: ${{ needs.deploy-cloud-run.outputs.deployment_id }} + run: | + chmod +x scripts/cloud-run-latency-smoke-test.sh + + set -o pipefail + RESULT=$(./scripts/cloud-run-latency-smoke-test.sh "$API_BASE_URL" 2>&1) + EXIT_CODE=$? + + echo "$RESULT" + echo "passed=$((EXIT_CODE == 0))" >> $GITHUB_OUTPUT + + AVG_LATENCY=$(echo "$RESULT" | grep "Average latency:" | awk '{print $3}' | tr -d 'ms') + P95_LATENCY=$(echo "$RESULT" | grep "P95 latency:" | awk '{print $3}' | tr -d 'ms') + MAX_LATENCY=$(echo "$RESULT" | grep "Max latency:" | awk '{print $3}' | tr -d 'ms') + + echo "avg_latency=${AVG_LATENCY:-0}" >> $GITHUB_OUTPUT + echo "p95_latency=${P95_LATENCY:-0}" >> $GITHUB_OUTPUT + echo "max_latency=${MAX_LATENCY:-0}" >> $GITHUB_OUTPUT + + exit $EXIT_CODE + + cloud-sql-load-check: + name: Cloud SQL Load Check + needs: deploy-cloud-run + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_tests != 'true' }} + outputs: + load_check_passed: ${{ steps.load_check.outputs.passed }} + avg_query_time_ms: ${{ steps.load_check.outputs.avg_query_time }} + slow_query_count: ${{ steps.load_check.outputs.slow_query_count }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD || secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Run Cloud SQL load check + id: load_check + env: + API_BASE_URL: ${{ needs.deploy-cloud-run.outputs.service_url }} + DEPLOYMENT_ID: ${{ needs.deploy-cloud-run.outputs.deployment_id }} + CLOUD_SQL_CONNECTION: ${{ secrets.CLOUD_SQL_PRODUCTION_CONNECTION_STRING }} + run: | + chmod +x scripts/cloud-sql-load-check.sh + + set -o pipefail + RESULT=$(./scripts/cloud-sql-load-check.sh "$API_BASE_URL" "$CLOUD_SQL_CONNECTION" 2>&1) + EXIT_CODE=$? + + echo "$RESULT" + echo "passed=$((EXIT_CODE == 0))" >> $GITHUB_OUTPUT + + AVG_QUERY=$(echo "$RESULT" | grep "Average query time:" | awk '{print $4}' | tr -d 'ms') + SLOW_COUNT=$(echo "$RESULT" | grep "Slow queries:" | awk '{print $3}') + + echo "avg_query_time=${AVG_QUERY:-0}" >> $GITHUB_OUTPUT + echo "slow_query_count=${SLOW_COUNT:-0}" >> $GITHUB_OUTPUT + + exit $EXIT_CODE + + configure-sentry-alerts: + name: Configure Sentry Alerts + needs: deploy-cloud-run + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_tests != 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install requests + + - name: Configure Sentry Alerts + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + run: | + python scripts/configure_sentry_alerts.py + + rollback: + name: Rollback on Failure + needs: [deploy-cloud-run, api-latency-smoke-test, cloud-sql-load-check, configure-sentry-alerts] + if: failure() + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD || secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Rollback to previous revision + run: | + echo "Initiating Cloud Run rollback..." + LATEST_REVISION=$(gcloud run revisions list \ + --service ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --sort-by=~creationTime \ + --format "value(name)" | tail -n +2 | head -1) + + if [ -n "$LATEST_REVISION" ]; then + echo "Rolling back to previous revision: $LATEST_REVISION" + gcloud run services update-traffic ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --to-revisions $LATEST_REVISION=100 + else + echo "No previous revision found to rollback to" + exit 1 + fi + + - name: Verify rollback + run: | + gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "table(status.conditions.name,status.conditions.status)" + + summary: + name: Deployment Summary + needs: [deploy-cloud-run, api-latency-smoke-test, cloud-sql-load-check, configure-sentry-alerts] + if: always() + runs-on: ubuntu-latest + steps: + - name: Generate deployment summary + run: | + echo "## Cloud Run Production Deployment Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Cloud Run Deployment | ${{ needs.deploy-cloud-run.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| API Latency Smoke Test | ${{ needs.api-latency-smoke-test.outputs.test_passed == 'true' && 'PASSED' || 'FAILED' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Cloud SQL Load Check | ${{ needs.cloud-sql-load-check.outputs.load_check_passed == 'true' && 'PASSED' || 'FAILED' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Sentry Alerts Configuration | ${{ needs.configure-sentry-alerts.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Latency Metrics" >> $GITHUB_STEP_SUMMARY + echo "- Average: ${{ needs.api-latency-smoke-test.outputs.avg_latency_ms }}ms" >> $GITHUB_STEP_SUMMARY + echo "- P95: ${{ needs.api-latency-smoke-test.outputs.p95_latency_ms }}ms" >> $GITHUB_STEP_SUMMARY + echo "- Max: ${{ needs.api-latency-smoke-test.outputs.max_latency_ms }}ms" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Cloud SQL Metrics" >> $GITHUB_STEP_SUMMARY + echo "- Average Query Time: ${{ needs.cloud-sql-load-check.outputs.avg_query_time_ms }}ms" >> $GITHUB_STEP_SUMMARY + echo "- Slow Queries (>100ms): ${{ needs.cloud-sql-load-check.outputs.slow_query_count }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Service URL: ${{ needs.deploy-cloud-run.outputs.service_url }}" >> $GITHUB_STEP_SUMMARY From 6f5e4a4ddfaaa8ef3caecb8f3d9ef933f5d00857 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:43 +0700 Subject: [PATCH 03/30] BUY-5602: Add deploy-frontend-vercel.yml workflow --- .github/workflows/deploy-frontend-vercel.yml | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/deploy-frontend-vercel.yml diff --git a/.github/workflows/deploy-frontend-vercel.yml b/.github/workflows/deploy-frontend-vercel.yml new file mode 100644 index 000000000..8e8856199 --- /dev/null +++ b/.github/workflows/deploy-frontend-vercel.yml @@ -0,0 +1,71 @@ +name: Deploy Frontend to Vercel + +on: + push: + branches: + - master + paths: + - 'frontend/**' + workflow_dispatch: + inputs: + environment: + description: 'Environment (production or preview)' + required: false + default: 'production' + type: choice + options: + - production + - preview + +env: + NODE_VERSION: '20' + +jobs: + deploy-frontend: + name: Deploy Frontend to Vercel + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment || 'production' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Build frontend + working-directory: frontend + run: npm run build + env: + NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL || 'https://api.buywhere.ai' }} + + - name: Deploy to Vercel (Production) + if: (github.event.inputs.environment || 'production') == '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 }} + vercel-args: '--prod' + working-directory: frontend + + - name: Deploy to Vercel (Preview) + if: (github.event.inputs.environment || 'production') == 'preview' + 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 }} + working-directory: frontend + + - name: Comment deployment URL + if: always() + run: | + echo "Vercel deployment completed" From 88c1351cf092607beb6866bfbaef32cd0ceebb4e Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:44 +0700 Subject: [PATCH 04/30] BUY-5602: Add deploy-logging-production.yml workflow --- .../workflows/deploy-logging-production.yml | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/deploy-logging-production.yml diff --git a/.github/workflows/deploy-logging-production.yml b/.github/workflows/deploy-logging-production.yml new file mode 100644 index 000000000..dee11eac0 --- /dev/null +++ b/.github/workflows/deploy-logging-production.yml @@ -0,0 +1,116 @@ +name: Deploy Logging Infrastructure to Production + +on: + push: + branches: + - master + paths: + - 'k8s/production/**' + - 'k8s/base/**' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + deploy-logging-production: + name: Deploy Logging Stack to Production + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up kustomize + uses: imranismail/setup-kustomize@v2 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-southeast-1 + + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Configure kubectl + uses: azure/k8s-set-context@v3 + with: + kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }} + + - name: Deploy Loki + run: | + kubectl apply -f k8s/production/namespace.yaml + kubectl apply -f k8s/production/loki-configmap.yaml + kubectl apply -f k8s/production/loki-service.yaml + kubectl apply -f k8s/production/loki-deployment.yaml + + - name: Deploy Promtail + run: | + kubectl apply -f k8s/production/promtail-rbac.yaml + kubectl apply -f k8s/production/promtail-configmap.yaml + kubectl apply -f k8s/production/promtail-daemonset.yaml + + - name: Deploy Fluent-bit + run: | + kubectl apply -f k8s/production/fluent-bit-rbac.yaml + kubectl apply -f k8s/production/fluent-bit-configmap.yaml + kubectl apply -f k8s/production/fluent-bit-daemonset.yaml + + - name: Deploy Loki Alerts + run: | + kubectl apply -f k8s/production/loki-alerts-configmap.yaml + + - name: Wait for Loki rollout + run: | + kubectl rollout status deployment/loki -n production --timeout=300s + + - name: Verify Loki deployment + run: | + kubectl get pods -n production -l app=loki + kubectl get svc -n production -l app=loki + + - name: Verify Promtail daemonset + run: | + kubectl get daemonset -n production -l app=promtail + + - name: Verify Fluent-bit daemonset + run: | + kubectl get daemonset -n production -l app=fluent-bit + + health-check: + name: Logging Health Check + needs: deploy-logging-production + runs-on: ubuntu-latest + steps: + - name: Check Loki endpoint + run: | + kubectl exec -n production deployment/loki -- wget -qO- http://localhost:3100/ready || exit 1 + + rollback: + name: Rollback Logging on Failure + needs: [deploy-logging-production, health-check] + if: failure() + runs-on: ubuntu-latest + steps: + - name: Configure kubectl + uses: azure/k8s-set-context@v3 + with: + kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }} + + - name: Rollback Loki + run: | + kubectl rollout undo deployment/loki -n production + kubectl rollout status deployment/loki -n production --timeout=300s + + - name: Rollback Promtail + run: | + kubectl rollout undo daemonset/promtail -n production + kubectl rollout status daemonset/promtail -n production --timeout=300s + + - name: Rollback Fluent-bit + run: | + kubectl rollout undo daemonset/fluent-bit -n production + kubectl rollout status daemonset/fluent-bit -n production --timeout=300s \ No newline at end of file From f9b9b1d6e9e3e8573738e26cf91d4d9eb5d4787f Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:45 +0700 Subject: [PATCH 05/30] BUY-5602: Add deploy-mcp-cloud-run-production.yml workflow --- .../deploy-mcp-cloud-run-production.yml | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .github/workflows/deploy-mcp-cloud-run-production.yml diff --git a/.github/workflows/deploy-mcp-cloud-run-production.yml b/.github/workflows/deploy-mcp-cloud-run-production.yml new file mode 100644 index 000000000..d2a1c8914 --- /dev/null +++ b/.github/workflows/deploy-mcp-cloud-run-production.yml @@ -0,0 +1,184 @@ +name: Deploy MCP to Cloud Run (Production) + +on: + workflow_dispatch: + inputs: + skip_tests: + description: 'Skip smoke tests' + required: false + default: 'false' + type: boolean + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: richmondteo-code/buywhere-catalog-api + GCP_PROJECT_ID: buywhere-production + GCP_REGION: asia-southeast1 + CLOUD_RUN_SERVICE: buywhere-mcp + +jobs: + build: + name: Build MCP Docker Image + runs-on: ubuntu-latest + outputs: + image_tag: ghcr.io/richmondteo-code/buywhere-catalog-api-mcp:${{ github.sha }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + 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.IMAGE_NAME }}-mcp + tags: | + type=sha,prefix=mcp- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push MCP image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.mcp + push: true + tags: | + ghcr.io/richmondteo-code/buywhere-catalog-api-mcp:${{ github.sha }} + ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy-cloud-run: + name: Deploy MCP to Cloud Run + needs: build + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD || secrets.GCP_SA_KEY }} + project_id: ${{ env.GCP_PROJECT_ID }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Force target GCP project + run: gcloud config set project ${{ env.GCP_PROJECT_ID }} + + - name: Configure Docker auth for Artifact Registry + run: gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet + + - name: Pull Docker image + run: docker pull ${{ needs.build.outputs.image_tag }} + + - name: Retag for GCR + run: | + GCR_IMAGE="asia-southeast1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/buywhere/${{ env.CLOUD_RUN_SERVICE }}:sha-${{ github.sha }}" + docker tag ${{ needs.build.outputs.image_tag }} $GCR_IMAGE + echo "GCR_IMAGE=$GCR_IMAGE" >> $GITHUB_ENV + + - name: Push to Artifact Registry + run: docker push $GCR_IMAGE + + - name: Deploy MCP to Cloud Run + run: | + echo "Deploying MCP to Cloud Run..." + SERVICE_URL=$(gcloud run deploy ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --image $GCR_IMAGE \ + --platform managed \ + --allow-unauthenticated \ + --set-env-vars "BUYWHERE_API_URL=${{ secrets.BUYWHERE_API_URL_PROD }}" \ + --set-env-vars "BUYWHERE_API_KEY=${{ secrets.BUYWHERE_API_KEY_PROD }}" \ + --set-env-vars "BUYWHERE_MCP_HTTP_PORT=8080" \ + --set-env-vars "BUYWHERE_REDIS_URL=${{ secrets.REDIS_PRODUCTION_URL }}" \ + --set-env-vars "LOG_LEVEL=INFO" \ + --memory 512Mi \ + --cpu 1 \ + --min-instances 1 \ + --max-instances 10 \ + --concurrency 80 \ + --timeout 60s \ + --label "commit-sha=${{ github.sha }}" \ + --output json | jq -r '.status.url') + + echo "MCP Service URL: $SERVICE_URL" + echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT + + - name: Wait for service to stabilize + run: | + echo "Waiting 20 seconds for Cloud Run service to initialize..." + sleep 20 + + - name: Verify Cloud Run deployment + run: | + gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "table(status.conditions.name,status.conditions.status,status.conditions.reason)" + + mcp-healthcheck: + name: MCP Health Check + needs: deploy-cloud-run + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_tests != 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: MCP Health Check + env: + MCP_BASE_URL: ${{ needs.deploy-cloud-run.outputs.url }} + run: | + echo "Checking MCP health at $MCP_BASE_URL..." + + for i in 1 2 3 4 5; do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$MCP_BASE_URL/health" --max-time 30 --retry 3 --retry-delay 5) + if [ "$HTTP_CODE" = "200" ]; then + echo "MCP health check passed (attempt $i)" + exit 0 + fi + echo "MCP health check failed (attempt $i/$MAX_RETRIES). HTTP code: $HTTP_CODE" + sleep 10 + done + + echo "MCP health check failed after 5 attempts." + curl -v "$MCP_BASE_URL/health" --max-time 30 + exit 1 + + summary: + name: Deployment Summary + needs: [deploy-cloud-run, mcp-healthcheck] + if: always() + runs-on: ubuntu-latest + steps: + - name: Generate deployment summary + run: | + echo "## MCP Cloud Run Production Deployment Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Cloud Run Deployment | ${{ needs.deploy-cloud-run.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| MCP Health Check | ${{ needs.mcp-healthcheck.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "MCP Service URL: ${{ needs.deploy-cloud-run.outputs.url }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From af76f2237dc258593dc2ea015b93eebec34836ec Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:46 +0700 Subject: [PATCH 06/30] BUY-5602: Add deploy-static-site.yml workflow --- .github/workflows/deploy-static-site.yml | 192 +++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 .github/workflows/deploy-static-site.yml diff --git a/.github/workflows/deploy-static-site.yml b/.github/workflows/deploy-static-site.yml new file mode 100644 index 000000000..22d777cc6 --- /dev/null +++ b/.github/workflows/deploy-static-site.yml @@ -0,0 +1,192 @@ +name: Deploy Static Site to Production + +on: + push: + branches: + - master + paths: + - 'site/**' + - 'website/**' + workflow_dispatch: + inputs: + skip_build: + description: 'Skip build step (use pre-built dist)' + required: false + default: 'false' + type: boolean + website_only: + description: 'Deploy website/ only (marketing site)' + required: false + default: 'false' + type: boolean + +concurrency: + group: static-site-deploy + cancel-in-progress: true + +permissions: + contents: read + +env: + DEPLOY_ROOT: /srv/buywhere-site + SERVER_PATH: ${{ secrets.PRODUCTION_DEPLOY_USER }}@${{ secrets.PRODUCTION_DEPLOY_HOST }} + +jobs: + build: + name: Build Static Site + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_build != 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -q jinja2 markdown + + - name: Build site + working-directory: site + run: python build.py + + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: site-dist + path: site/dist/ + retention-days: 1 + + deploy: + name: Deploy Docs Site (site/) to Production + runs-on: ubuntu-latest + needs: build + if: ${{ github.event.inputs.skip_build != 'true' && github.event.inputs.website_only != 'true' }} + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: site-dist + path: site/dist/ + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy site/ to server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + run: | + rsync -avz --delete \ + -e "ssh -p $DEPLOY_PORT" \ + --exclude '.git' \ + --exclude 'node_modules' \ + site/dist/ \ + $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/site/ + + - name: Verify deployment + run: | + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "https://buywhere.ai/" --max-time 10 --retry 2 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + echo "Health check passed (HTTP $HTTP_CODE)" + else + echo "WARNING: Health check returned HTTP $HTTP_CODE" + fi + + deploy-website: + name: Deploy Marketing Site (website/) to Production + runs-on: ubuntu-latest + if: ${{ github.event.inputs.website_only == 'true' }} + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy website/ to server root + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + run: | + rsync -avz --delete \ + -e "ssh -p $DEPLOY_PORT" \ + --exclude '.git' \ + --exclude 'node_modules' \ + --exclude 'vercel.json' \ + website/ \ + $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/ + + - name: Verify deployment + run: | + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "https://buywhere.ai/" --max-time 10 --retry 2 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + echo "Health check passed (HTTP $HTTP_CODE)" + else + echo "WARNING: Health check returned HTTP $HTTP_CODE" + fi + + deploy-skip-build: + name: Deploy to Production (skip build) + runs-on: ubuntu-latest + if: ${{ github.event.inputs.skip_build == 'true' }} + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy site to server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + run: | + rsync -avz --delete \ + -e "ssh -p $DEPLOY_PORT" \ + --exclude '.git' \ + --exclude 'node_modules' \ + site/dist/ \ + $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/site/ + + - name: Verify deployment + run: | + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "https://buywhere.ai/" --max-time 10 --retry 2 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + echo "Health check passed (HTTP $HTTP_CODE)" + else + echo "WARNING: Health check returned HTTP $HTTP_CODE)" + fi From 7d1fc15cd35cb5fc41ee9f40eff2698c21303936 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:48 +0700 Subject: [PATCH 07/30] BUY-5602: Add deploy-us.yml workflow --- .github/workflows/deploy-us.yml | 302 ++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 .github/workflows/deploy-us.yml diff --git a/.github/workflows/deploy-us.yml b/.github/workflows/deploy-us.yml new file mode 100644 index 000000000..8e518dadd --- /dev/null +++ b/.github/workflows/deploy-us.yml @@ -0,0 +1,302 @@ +name: Deploy to US + +on: + push: + branches: + - us-main + - us-staging + tags: + - 'us-v*' + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'staging' + type: choice + options: + - staging + - production + image_tag: + description: 'Image tag to deploy (defaults to latest)' + required: false + type: string + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + deploy-us: + name: Deploy to US ${{ github.event.inputs.environment || (startsWith(github.ref, 'refs/heads/us-') && 'staging') || 'production' }} + runs-on: ubuntu-latest + environment: + name: ${{ github.event.inputs.environment || (startsWith(github.ref, 'refs/heads/us-') && 'staging') || 'production' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials (US East) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_US_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_US_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to ECR (US East) + uses: aws-actions/amazon-ecr-login@v2 + + - name: Extract version + id: version + run: | + if [ "${{ github.event.inputs.image_tag }}" != "" ]; then + echo "VERSION=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" =~ ^refs/tags/us-v(.+)$ ]]; then + echo "VERSION=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + else + echo "VERSION=latest" >> $GITHUB_OUTPUT + fi + + - name: Determine environment + id: env + run: | + if [ "${{ github.event.inputs.environment }}" != "" ]; then + echo "NAME=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/us-staging" ]]; then + echo "NAME=staging" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/us-main" ]]; then + echo "NAME=production" >> $GITHUB_OUTPUT + else + echo "NAME=production" >> $GITHUB_OUTPUT + fi + echo "HEALTH_URL=${{ github.event.inputs.environment == 'production' && 'https://api.buywhere.ai/health' || 'https://api-staging-us.buywhere.ai/health' }}" >> $GITHUB_OUTPUT + + - name: Capture previous task definition + id: prev-task + run: | + CLUSTER="buywhere-us-${{ steps.env.outputs.NAME }}" + SERVICE="buywhere-us-${{ steps.env.outputs.NAME }}-api-service" + PREV_TASK=$(aws ecs describe-services \ + --cluster $CLUSTER \ + --services $SERVICE \ + --query 'services[0].taskDefinition' \ + --output text 2>/dev/null || echo "") + echo "Previous task definition: $PREV_TASK" + echo "prev_task=$PREV_TASK" >> $GITHUB_OUTPUT + echo "cluster=$CLUSTER" >> $GITHUB_OUTPUT + echo "service=$SERVICE" >> $GITHUB_OUTPUT + + - name: Register new task definition + run: | + CLUSTER="${{ steps.prev-task.outputs.cluster }}" + SERVICE="${{ steps.prev-task.outputs.service }}" + FAMILY="buywhere-us-${{ steps.env.outputs.NAME }}" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}" + + echo "Registering new task definition for family $FAMILY with image $IMAGE" + + SENTRY_DSN_SECRET="${{ secrets.SENTRY_DSN_US_PRODUCTION }}" + if [ "${{ steps.env.outputs.NAME }}" == "staging" ]; then + SENTRY_DSN_SECRET="${{ secrets.SENTRY_DSN_US_STAGING }}" + fi + + NEW_TASK_ARN=$(aws ecs register-task-definition \ + --family $FAMILY \ + --container-definitions "[{\"name\":\"api\",\"image\":\"$IMAGE\",\"essential\":true,\"portMappings\":[{\"containerPort\":8000,\"protocol\":\"tcp\"}],\"environment\":[{\"name\":\"SENTRY_DSN\",\"value\":\"$SENTRY_DSN_SECRET\"},{\"name\":\"SENTRY_ENVIRONMENT\",\"value\":\"${{ steps.env.outputs.NAME }}\"}],\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/buywhere/us-${{ steps.env.outputs.NAME }}\",\"awslogs-region\":\"us-east-1\",\"awslogs-stream-prefix\":\"ecs\"}}}]" \ + --network-mode awsvpc \ + --requires-compatibilities FARGATE \ + --cpu 1024 \ + --memory 2048 \ + --execution-role-arn "arn:aws:iam::${{ secrets.AWS_US_ACCOUNT_ID }}:role/buywhere-us-${{ steps.env.outputs.NAME }}-ecs-execution-role" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text) + + echo "New task definition: $NEW_TASK_ARN" + echo "new_task=$NEW_TASK_ARN" >> $GITHUB_OUTPUT + + - name: Deploy to ECS + run: | + echo "Deploying to ECS cluster ${{ steps.prev-task.outputs.cluster }}..." + aws ecs update-service \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --service ${{ steps.prev-task.outputs.service }} \ + --task-definition ${{ steps.prev-task.outputs.new_task }} \ + --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --health-check-grace-period-seconds 60 + + - name: Wait for deployment + run: | + echo "Waiting for deployment to stabilize..." + if ! aws ecs wait services-stable \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --services ${{ steps.prev-task.outputs.service }}; then + echo "Deployment failed to stabilize." + if [ -n "${{ steps.prev-task.outputs.prev_task }}" ]; then + echo "Rolling back to previous task definition..." + aws ecs update-service \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --service ${{ steps.prev-task.outputs.service }} \ + --task-definition ${{ steps.prev-task.outputs.prev_task }} \ + --force-new-deployment + aws ecs wait services-stable \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --services ${{ steps.prev-task.outputs.service }} + fi + exit 1 + fi + echo "Deployment stabilized successfully." + + - name: Run smoke tests + env: + SMOKE_URL: ${{ steps.env.outputs.HEALTH_URL }} + run: | + MAX_RETRIES=5 + RETRY_DELAY=15 + + echo "Performing smoke test validation against $SMOKE_URL..." + + for i in $(seq 1 $MAX_RETRIES); do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$SMOKE_URL/health" --max-time 30 --retry 3 --retry-delay 5) + if [ "$HTTP_CODE" = "200" ]; then + echo "Health check passed (attempt $i)" + break + fi + echo "Health check failed (attempt $i/$MAX_RETRIES). HTTP code: $HTTP_CODE" + if [ $i -lt $MAX_RETRIES ]; then + sleep $RETRY_DELAY + else + echo "Health check failed after $MAX_RETRIES attempts." + exit 1 + fi + done + + - name: Rollback on failure + if: failure() + run: | + if [ -n "${{ steps.prev-task.outputs.prev_task }}" ]; then + echo "Initiating rollback due to health check failure..." + aws ecs update-service \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --service ${{ steps.prev-task.outputs.service }} \ + --task-definition ${{ steps.prev-task.outputs.prev_task }} \ + --force-new-deployment + aws ecs wait services-stable \ + --cluster ${{ steps.prev-task.outputs.cluster }} \ + --services ${{ steps.prev-task.outputs.service }} + fi + + deploy-us-mcp: + name: Deploy US MCP + runs-on: ubuntu-latest + needs: deploy-us + environment: + name: ${{ github.event.inputs.environment || (startsWith(github.ref, 'refs/heads/us-') && 'staging') || 'production' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials (US East) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_US_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_US_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to ECR (US East) + uses: aws-actions/amazon-ecr-login@v2 + + - name: Extract version + id: version + run: | + if [ "${{ github.event.inputs.image_tag }}" != "" ]; then + echo "VERSION=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" =~ ^refs/tags/us-v(.+)$ ]]; then + echo "VERSION=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + else + echo "VERSION=latest" >> $GITHUB_OUTPUT + fi + + - name: Determine environment + id: env + run: | + if [ "${{ github.event.inputs.environment }}" != "" ]; then + echo "NAME=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/us-staging" ]]; then + echo "NAME=staging" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/us-main" ]]; then + echo "NAME=production" >> $GITHUB_OUTPUT + else + echo "NAME=production" >> $GITHUB_OUTPUT + fi + + - name: Register new MCP task definition + run: | + FAMILY="buywhere-us-${{ steps.env.outputs.NAME }}-mcp" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-mcp:${{ steps.version.outputs.VERSION }}" + + echo "Registering new MCP task definition for family $FAMILY with image $IMAGE" + + aws ecs register-task-definition \ + --family $FAMILY \ + --container-definitions "[{\"name\":\"mcp\",\"image\":\"$IMAGE\",\"essential\":true,\"portMappings\":[{\"containerPort\":8080,\"protocol\":\"tcp\"}],\"environment\":[{\"name\":\"BUYWHERE_API_KEY\",\"value\":\"${{ secrets.BUYWHERE_US_API_KEY }}\"}],\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/buywhere/us-${{ steps.env.outputs.NAME }}\",\"awslogs-region\":\"us-east-1\",\"awslogs-stream-prefix\":\"ecs\"}}}]" \ + --network-mode awsvpc \ + --requires-compatibilities FARGATE \ + --cpu 512 \ + --memory 1024 \ + --execution-role-arn "arn:aws:iam::${{ secrets.AWS_US_ACCOUNT_ID }}:role/buywhere-us-${{ steps.env.outputs.NAME }}-ecs-execution-role" + + - name: Deploy MCP to ECS + run: | + CLUSTER="buywhere-us-${{ steps.env.outputs.NAME }}" + SERVICE="buywhere-us-${{ steps.env.outputs.NAME }}-mcp-service" + + echo "Deploying MCP to ECS cluster $CLUSTER..." + aws ecs update-service \ + --cluster $CLUSTER \ + --service $SERVICE \ + --task-definition "buywhere-us-${{ steps.env.outputs.NAME }}-mcp" \ + --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --health-check-grace-period-seconds 30 + + echo "Waiting for MCP deployment to stabilize..." + if ! aws ecs wait services-stable --cluster $CLUSTER --services $SERVICE 2>/dev/null; then + echo "MCP service deployment did not stabilize, but continuing..." + else + echo "MCP deployment stabilized successfully." + fi + + configure-sentry-alerts: + name: Configure Sentry Alerts (US) + runs-on: ubuntu-latest + needs: deploy-us + if: github.event_name == 'push' && (github.ref == 'refs/heads/us-main' || startsWith(github.ref, 'refs/tags/us-v')) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install requests + + - name: Configure US Sentry Alerts + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + SENTRY_ENVIRONMENT: ${{ github.event.inputs.environment || 'production' }} + run: | + python scripts/configure_sentry_alerts.py + + - name: Verify Alert Configuration + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + run: | + echo "Verifying alert rules in Sentry..." + curl -s -H "Authorization: Bearer $SENTRY_API_TOKEN" \ + "https://sentry.io/api/0/organizations/$SENTRY_ORG/rules/" | \ + python -c "import sys, json; rules = json.load(sys.stdin); print(f'Found {len(rules)} alert rules')" \ No newline at end of file From ca47966f8d16e2c3769788fa604ce95db1a801f3 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:49 +0700 Subject: [PATCH 08/30] BUY-5602: Add docs.yml workflow --- .github/workflows/docs.yml | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..811edbacc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: + - master + paths: + - 'docs/**' + - 'mkdocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install MkDocs and Material theme + run: | + pip install mkdocs-material + + - name: Build docs + run: mkdocs build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From ab522cb071dc068b8d180bdf1c88320d2bd9e844 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:50 +0700 Subject: [PATCH 09/30] BUY-5602: Add enable-pg-stat-statements-staging.yml workflow --- .../enable-pg-stat-statements-staging.yml | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/enable-pg-stat-statements-staging.yml diff --git a/.github/workflows/enable-pg-stat-statements-staging.yml b/.github/workflows/enable-pg-stat-statements-staging.yml new file mode 100644 index 000000000..1e5df6044 --- /dev/null +++ b/.github/workflows/enable-pg-stat-statements-staging.yml @@ -0,0 +1,100 @@ +name: Enable pg_stat_statements (Staging) + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + packages: read + +env: + GCP_PROJECT_ID: gaia-calendar-488606 + GCP_REGION: asia-southeast1 + CLOUD_SQL_INSTANCE: buywhere-staging + INSTANCE_CONNECTION_NAME: gaia-calendar-488606:asia-southeast1:buywhere-staging + +jobs: + enable-pg-stat-statements: + name: Enable pg_stat_statements on Cloud SQL Staging + runs-on: ubuntu-latest + environment: staging + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Enable IAM authentication for Cloud SQL + run: | + echo "Enabling IAM authentication on Cloud SQL instance..." + echo "y" | gcloud sql instances patch ${{ env.CLOUD_SQL_INSTANCE }} \ + --database-flags=cloudsql.iam_authentication=on \ + --project=${{ env.GCP_PROJECT_ID }} + + - name: List Cloud SQL users + run: | + echo "Listing Cloud SQL users..." + gcloud sql users list --instance=${{ env.CLOUD_SQL_INSTANCE }} --project=${{ env.GCP_PROJECT_ID }} + + - name: Get access token and service account email + id: get-token + run: | + TOKEN=$(gcloud auth print-access-token) + SA_EMAIL=$(gcloud auth list --filter=status:ACTIVE --format='value(account)') + IAM_USER=$(echo "$SA_EMAIL" | sed 's/\.gserviceaccount\.com$//') + echo "token=$TOKEN" >> $GITHUB_OUTPUT + echo "sa_email=$SA_EMAIL" >> $GITHUB_OUTPUT + echo "iam_user=$IAM_USER" >> $GITHUB_OUTPUT + echo "Service account: $SA_EMAIL" + echo "IAM user: $IAM_USER" + + - name: Create IAM user with correct type + run: | + echo "Creating IAM service account user in Cloud SQL..." + gcloud sql users create "${{ steps.get-token.outputs.iam_user }}" \ + --instance=${{ env.CLOUD_SQL_INSTANCE }} \ + --type=CLOUD_IAM_SERVICE_ACCOUNT \ + --project=${{ env.GCP_PROJECT_ID }} || echo "User may already exist" + + - name: Grant IAM user privileges + run: | + echo "Granting privileges to IAM user..." + gcloud sql users grant "${{ steps.get-token.outputs.iam_user }}" \ + --instance=${{ env.CLOUD_SQL_INSTANCE }} \ + --project=${{ env.GCP_PROJECT_ID }} || echo "Grant may have failed" + + - name: Install Cloud SQL Proxy + run: | + echo "Installing Cloud SQL Proxy..." + gcloud components install cloud_sql_proxy --quiet + + - name: Start Cloud SQL Proxy + run: | + PROXY_DIR=/tmp/cloud_sql + mkdir -p $PROXY_DIR + echo "Starting Cloud SQL Proxy with IAM auth..." + /opt/hostedtoolcache/gcloud/564.0.0/x64/bin/cloud_sql_proxy \ + --dir=$PROXY_DIR \ + --instances=${{ env.INSTANCE_CONNECTION_NAME }}=tcp:5432 \ + --enable_iam_login \ + & + echo "Waiting for proxy to start..." + sleep 15 + ls -la $PROXY_DIR/ + + - name: Enable pg_stat_statements with IAM user + run: | + echo "Enabling pg_stat_statements with IAM user..." + PGPASSWORD="" psql -h 127.0.0.1 -p 5432 -U "${{ steps.get-token.outputs.iam_user }}" -d postgres -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;" 2>&1 + + - name: Verify pg_stat_statements is enabled + run: | + echo "Verifying pg_stat_statements..." + PGPASSWORD="" psql -h 127.0.0.1 -p 5432 -U "${{ steps.get-token.outputs.iam_user }}" -d postgres -c "SELECT query, calls, mean_time, total_time FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;" 2>&1 | head -50 From 6a82a07af33ef49472de7cde42235b5250886efa Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:51 +0700 Subject: [PATCH 10/30] BUY-5602: Add inject-posthog-vm.yml workflow --- .github/workflows/inject-posthog-vm.yml | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/inject-posthog-vm.yml diff --git a/.github/workflows/inject-posthog-vm.yml b/.github/workflows/inject-posthog-vm.yml new file mode 100644 index 000000000..cc4adf89a --- /dev/null +++ b/.github/workflows/inject-posthog-vm.yml @@ -0,0 +1,124 @@ +name: Inject PostHog Key to Production VM + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + inject-posthog-vm: + name: Inject POSTHOG_PROJECT_KEY to Production VM + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Detect service management and inject key + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + POSTHOG_PROJECT_KEY: ${{ secrets.POSTHOG_API_KEY_PRODUCTION }} + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" << 'EOF' + set -euo pipefail + + echo "=== Detecting FastAPI service management ===" + + POSTHOG_KEY="$POSTHOG_PROJECT_KEY" + + # Check for systemd service + if systemctl list-unit-files | grep -q 'buywhere-api\|fastapi\|uvicorn'; then + echo "Detected: systemd-managed service" + SERVICE_NAME=$(systemctl list-units --type=service --all | grep -i 'buywhere-api\|fastapi' | awk '{print $1}' | head -1) + if [ -z "$SERVICE_NAME" ]; then + SERVICE_NAME="buywhere-api" + fi + echo "Service name: $SERVICE_NAME" + + # Try to find and update environment file + ENV_FILE="" + for ef in /etc/default/buywhere-api /etc/environment /etc/systemd/system/${SERVICE_NAME}.d/override.conf; do + if [ -f "$ef" ] || [ -d "$(dirname $ef)" ]; then + ENV_FILE="$ef" + break + fi + done + + if [ -n "$ENV_FILE" ]; then + echo "Adding POSTHOG_PROJECT_KEY to $ENV_FILE" + if ! grep -q "POSTHOG_PROJECT_KEY=" "$ENV_FILE" 2>/dev/null; then + echo "POSTHOG_PROJECT_KEY=$POSTHOG_KEY" >> "$ENV_FILE" + else + sed -i "s|POSTHOG_PROJECT_KEY=.*|POSTHOG_PROJECT_KEY=$POSTHOG_KEY|" "$ENV_FILE" + fi + fi + + # Also set in current session for immediate effect + export POSTHOG_PROJECT_KEY="$POSTHOG_KEY" + + echo "Reloading systemd and restarting service..." + systemctl daemon-reload 2>/dev/null || true + systemctl restart "$SERVICE_NAME" 2>/dev/null || true + echo "Done" + + # Check for PM2 + elif command -v pm2 &>/dev/null && pm2 list 2>/dev/null | grep -q 'buywhere\|api\|fastapi'; then + echo "Detected: PM2-managed service" + PM2_NAME=$(pm2 list | grep -i 'buywhere\|api\|fastapi' | awk '{print $2}' | head -1) + if [ -n "$PM2_NAME" ]; then + echo "PM2 process: $PM2_NAME" + POSTHOG_PROJECT_KEY="$POSTHOG_KEY" pm2 restart "$PM2_NAME" || true + echo "Done" + fi + + # Check for Docker/Container + elif command -v docker &>/dev/null && docker ps 2>/dev/null | grep -q 'buywhere\|api'; then + echo "Detected: Docker-managed service" + CONTAINER_ID=$(docker ps | grep -i 'buywhere\|api' | awk '{print $1}' | head -1) + if [ -n "$CONTAINER_ID" ]; then + echo "Container: $CONTAINER_ID" + docker exec "$CONTAINER_ID" env POSTHOG_PROJECT_KEY="$POSTHOG_KEY" sh -c 'echo "POSTHOG_PROJECT_KEY set in container"' + docker restart "$CONTAINER_ID" + echo "Done" + fi + + # Check for running process directly + elif pgrep -f "uvicorn\|gunicorn\|fastapi" &>/dev/null; then + echo "Detected: Raw process (uvicorn/gunicorn)" + # Find the PID + PID=$(pgrep -f "uvicorn\|gunicorn" | head -1) + if [ -n "$PID" ]; then + echo "Process PID: $PID" + # Update /etc/environment as a fallback + if ! grep -q "POSTHOG_PROJECT_KEY=" /etc/environment 2>/dev/null; then + echo "POSTHOG_PROJECT_KEY=$POSTHOG_KEY" >> /etc/environment + fi + export POSTHOG_PROJECT_KEY="$POSTHOG_KEY" + # Kill and restart - this is risky, better to find the startup mechanism + echo "Warning: Raw process detected. Please configure proper service management." + fi + else + echo "Could not detect service management method" + # Last resort: add to /etc/environment + if ! grep -q "POSTHOG_PROJECT_KEY=" /etc/environment 2>/dev/null; then + echo "POSTHOG_PROJECT_KEY=$POSTHOG_KEY" >> /etc/environment + echo "Added to /etc/environment" + fi + echo "Please manually restart the service" + fi + + echo "=== PostHog key injection complete ===" + EOF From 22432408cdedada6ba31b71bb21142337cf20099 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:52 +0700 Subject: [PATCH 11/30] BUY-5602: Add nginx-deploy.yml workflow --- .github/workflows/nginx-deploy.yml | 257 +++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 .github/workflows/nginx-deploy.yml diff --git a/.github/workflows/nginx-deploy.yml b/.github/workflows/nginx-deploy.yml new file mode 100644 index 000000000..57c59cf7a --- /dev/null +++ b/.github/workflows/nginx-deploy.yml @@ -0,0 +1,257 @@ +name: Deploy Nginx Config + +on: + push: + branches: + - main + - master + paths: + - 'nginx.conf' + - 'ops/nginx/**' + - '.github/workflows/nginx-deploy.yml' + workflow_dispatch: + inputs: + config_source: + description: 'Config source (api|site)' + required: false + default: 'api' + type: choice + options: + - api + - site + +concurrency: + group: nginx-config-deploy + cancel-in-progress: true + +permissions: + contents: read + +env: + DEPLOY_ROOT: /srv/buywhere-site + NGINX_CONFIG_NAME: api.buywhere.ai + +jobs: + validate-nginx-config: + name: Validate Nginx Config + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get nginx config + id: config + run: | + if [[ "${{ github.event.inputs.config_source || 'api' }}" == "api" ]]; then + echo "source=nginx.conf" >> $GITHUB_OUTPUT + echo "path=nginx.conf" >> $GITHUB_OUTPUT + else + echo "source=ops/nginx/buywhere.ai.conf" >> $GITHUB_OUTPUT + echo "path=ops/nginx/buywhere.ai.conf" >> $GITHUB_OUTPUT + fi + + - name: Display config location + run: | + echo "Using config: ${{ steps.config.outputs.path }}" + cat ${{ steps.config.outputs.path }} + + - name: Install nginx and validate + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq nginx > /dev/null 2>&1 + echo "Creating test SSL certificates for validation..." + sudo mkdir -p /tmp/nginx-test-certs + sudo openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/nginx-test-certs/key.pem -out /tmp/nginx-test-certs/cert.pem -days 1 -subj "/CN=test" 2>/dev/null + sed "s|/etc/letsencrypt/live/api.buywhere.ai/fullchain.pem|/tmp/nginx-test-certs/cert.pem|g; s|/etc/letsencrypt/live/api.buywhere.ai/privkey.pem|/tmp/nginx-test-certs/key.pem|g; s|/etc/letsencrypt/options-ssl-nginx.conf|/dev/null|g; s|/etc/letsencrypt/ssl-dhparams.pem|/dev/null|g" "${{ steps.config.outputs.path }}" > /tmp/nginx-test-valid.conf + sudo nginx -t -c /tmp/nginx-test-valid.conf + + deploy-nginx-config: + name: Deploy Nginx Config + runs-on: ubuntu-latest + needs: validate-nginx-config + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine config path + id: config + run: | + if [[ "${{ github.event.inputs.config_source || 'api' }}" == "api" ]]; then + echo "path=nginx.conf" >> $GITHUB_OUTPUT + else + echo "path=ops/nginx/buywhere.ai.conf" >> $GITHUB_OUTPUT + fi + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Upload config to server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + run: | + CONFIG_PATH="${{ steps.config.outputs.path }}" + ssh -o StrictHostKeyChecking=no -o BatchMode=yes -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "cat > /tmp/nginx-${{ env.NGINX_CONFIG_NAME }}-${{ github.sha }}.conf" < "$CONFIG_PATH" + + - name: Deploy config on server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + NGINX_CONFIG_NAME: ${{ env.NGINX_CONFIG_NAME }} + DEPLOY_SHA: ${{ github.sha }} + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" bash -c ' + set -euo pipefail + NGINX_CONFIG_NAME="$1" + DEPLOY_SHA="$2" + NGINX_ATOMIC_TARGET="/etc/nginx/sites-enabled/${NGINX_CONFIG_NAME}.conf" + NGINX_TMP_TARGET="/tmp/nginx-${NGINX_CONFIG_NAME}-${DEPLOY_SHA}.conf" + PREVIOUS_CONFIG="/srv/buywhere-site/.nginx_previous_config" + + echo "=== Deploying Nginx Config ===" + echo "Source: $NGINX_TMP_TARGET" + echo "Target: $NGINX_ATOMIC_TARGET" + + if [[ ! -f "$NGINX_TMP_TARGET" ]]; then + echo "ERROR: Config not found at $NGINX_TMP_TARGET" + exit 1 + fi + + echo "Validating nginx config..." + if ! nginx -t -c "$NGINX_TMP_TARGET" 2>&1; then + echo "ERROR: nginx config test failed" + rm -f "$NGINX_TMP_TARGET" + exit 1 + fi + echo "nginx config test passed" + + if [[ -f "$NGINX_ATOMIC_TARGET" ]]; then + cp "$NGINX_ATOMIC_TARGET" "$PREVIOUS_CONFIG" + echo "Backed up current config to $PREVIOUS_CONFIG" + fi + + echo "Atomically swapping nginx config..." + mv -f "$NGINX_TMP_TARGET" "$NGINX_ATOMIC_TARGET" + + echo "Reloading nginx..." + sudo nginx -s reload 2>&1 || { echo "ERROR: nginx reload failed"; exit 1; } + + echo "=== Nginx config deployment complete ===" + ' _ "$NGINX_CONFIG_NAME" "$DEPLOY_SHA" + + - name: Verify deployment + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + run: | + sleep 3 + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "https://buywhere.ai/" --max-time 10 --retry 1 2>/dev/null || echo "000") + if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "301" || "$HTTP_CODE" == "302" ]]; then + echo "Health check passed (HTTP $HTTP_CODE)" + else + echo "WARNING: Health check returned HTTP $HTTP_CODE" + fi + + rollback: + name: Rollback Nginx Config + runs-on: ubuntu-latest + needs: deploy-nginx-config + if: failure() + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Rollback config on server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + NGINX_CONFIG_NAME: ${{ env.NGINX_CONFIG_NAME }} + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" << 'EOF' + set -euo pipefail + + NGINX_CONFIG_NAME="${{ env.NGINX_CONFIG_NAME }}" + NGINX_ATOMIC_TARGET="/etc/nginx/sites-enabled/${NGINX_CONFIG_NAME}.conf" + PREVIOUS_CONFIG="/srv/buywhere-site/.nginx_previous_config" + + echo "=== Rolling back Nginx Config ===" + + if [[ ! -f "$PREVIOUS_CONFIG" ]]; then + echo "ERROR: No previous config found at $PREVIOUS_CONFIG" + exit 1 + fi + + echo "Restoring previous config..." + cp "$PREVIOUS_CONFIG" "$NGINX_ATOMIC_TARGET" + + echo "Validating nginx config..." + if ! nginx -t -c "$NGINX_ATOMIC_TARGET" 2>&1; then + echo "ERROR: nginx config test failed on rollback" + exit 1 + fi + echo "nginx config test passed" + + echo "Reloading nginx..." + nginx -s reload 2>&1 || { echo "ERROR: nginx reload failed"; exit 1; } + + echo "=== Nginx config rollback complete ===" + EOF + + notify: + name: Notify Deploy Result + runs-on: ubuntu-latest + needs: + - validate-nginx-config + - deploy-nginx-config + if: always() + steps: + - name: Send deployment webhook + env: + DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }} + DEPLOY_STATUS: ${{ needs.deploy-nginx-config.result == 'success' && 'success' || 'failure' }} + run: | + if [[ -z "${DEPLOY_WEBHOOK_URL}" ]]; then + echo "DEPLOY_WEBHOOK_URL is not configured; skipping notification." + exit 0 + fi + + payload=$(jq -n \ + --arg status "$DEPLOY_STATUS" \ + --arg sha "$GITHUB_SHA" \ + --arg ref "$GITHUB_REF_NAME" \ + --arg run_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + '{ + text: ("buywhere-api nginx config deploy " + $status), + status: $status, + repository: env.GITHUB_REPOSITORY, + ref: $ref, + sha: $sha, + runUrl: $run_url + }') + + curl -fsS -X POST "$DEPLOY_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ No newline at end of file From c931c85b09af2f0c87d67301e68cfb8d6f0f2ef6 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:53 +0700 Subject: [PATCH 12/30] BUY-5602: Add publish-python-sdk.yml workflow --- .github/workflows/publish-python-sdk.yml | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/publish-python-sdk.yml diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml new file mode 100644 index 000000000..36052a5a9 --- /dev/null +++ b/.github/workflows/publish-python-sdk.yml @@ -0,0 +1,88 @@ +name: Publish Python SDK + +on: + push: + tags: + - 'buywhere-sdk-v*' + workflow_dispatch: + inputs: + repository: + description: "Publish target" + required: true + default: "testpypi" + type: choice + options: + - testpypi + - pypi + publish: + description: "Upload artifacts after verification" + required: true + default: false + type: boolean + +env: + SDK_DIR: sdk + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install build tooling + working-directory: ${{ env.SDK_DIR }} + run: | + python -m pip install --upgrade pip + pip install build twine + pip install -e ".[dev]" + + - name: Verify SDK package + working-directory: ${{ env.SDK_DIR }} + run: | + python -m pytest tests/test_cli.py tests/test_client_cli_primitives.py -q + rm -rf dist build + python -m build + twine check dist/* + python -m venv /tmp/buywhere-sdk-smoke + source /tmp/buywhere-sdk-smoke/bin/activate + python -m pip install --upgrade pip + python -m pip install dist/*.whl + python -c "from buywhere_sdk import AsyncBuyWhere, BuyWhere; print(BuyWhere.__name__, AsyncBuyWhere.__name__)" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: buywhere-sdk-dist + path: sdk/dist/* + + - name: Publish to TestPyPI + if: | + github.event_name == 'workflow_dispatch' && + inputs.publish && + inputs.repository == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: sdk/dist + skip-existing: true + + - name: Publish to PyPI + if: | + startsWith(github.ref, 'refs/tags/buywhere-sdk-v') || + (github.event_name == 'workflow_dispatch' && + inputs.publish && + inputs.repository == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/dist + skip-existing: true From 9991cc46cc01643372f144bbf9f1fe0f7e15a9f9 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:54 +0700 Subject: [PATCH 13/30] BUY-5602: Add sentry-alerts.yml workflow --- .github/workflows/sentry-alerts.yml | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/sentry-alerts.yml diff --git a/.github/workflows/sentry-alerts.yml b/.github/workflows/sentry-alerts.yml new file mode 100644 index 000000000..b2f20fc51 --- /dev/null +++ b/.github/workflows/sentry-alerts.yml @@ -0,0 +1,50 @@ +name: Configure Sentry Alerts + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to configure alerts for' + required: true + default: 'production' + type: choice + options: + - production + - staging + schedule: + cron: '0 */6 * * *' + +jobs: + configure-alerts: + name: Configure Sentry Alerts + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install requests + + - name: Configure Sentry Alerts (>5 errors/hour) + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + SENTRY_ENVIRONMENT: ${{ github.event.inputs.environment || 'production' }} + run: | + python scripts/configure_sentry_alerts.py + + - name: Verify Alert Configuration + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + run: | + echo "Verifying alert rules in Sentry..." + curl -s -H "Authorization: Bearer $SENTRY_API_TOKEN" \ + "https://sentry.io/api/0/organizations/$SENTRY_ORG/rules/" | \ + python -c "import sys, json; rules = json.load(sys.stdin); print(f'Found {len(rules)} alert rules')" \ No newline at end of file From 34c8baa7c2425febbf96052472d40f0a632cff1d Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:55 +0700 Subject: [PATCH 14/30] BUY-5602: Add ssl-renewal.yml workflow --- .github/workflows/ssl-renewal.yml | 161 ++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .github/workflows/ssl-renewal.yml diff --git a/.github/workflows/ssl-renewal.yml b/.github/workflows/ssl-renewal.yml new file mode 100644 index 000000000..1dd9f2f0f --- /dev/null +++ b/.github/workflows/ssl-renewal.yml @@ -0,0 +1,161 @@ +name: SSL Certificate Renewal + +on: + schedule: + - cron: '0 */12 * * *' + workflow_dispatch: + inputs: + force_renewal: + description: 'Force certificate renewal regardless of expiry' + required: false + default: 'false' + type: boolean + +env: + CERT_DOMAIN: api.buywhere.ai + ADDITIONAL_DOMAINS: us.buywhere.com + +jobs: + renew-ssl: + name: Renew SSL Certificates + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-southeast-1 + + - name: Get production instance IP + id: instance + run: | + INSTANCE_IP=$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=buywhere-prod" "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' \ + --output text) + echo "Production host: $INSTANCE_IP" + echo "INSTANCE_IP=$INSTANCE_IP" >> $GITHUB_ENV + + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ env.INSTANCE_IP }} >> ~/.ssh/known_hosts 2>/dev/null + + - name: Deploy certbot renewal configs + run: | + echo "Ensuring certbot renewal configs are deployed..." + # Deploy api.buywhere.ai config + scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + certbot/renewal/api.buywhere.ai.conf \ + ubuntu@${{ env.INSTANCE_IP }}:/tmp/api.buywhere.ai.conf + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.INSTANCE_IP }} \ + "sudo mkdir -p /etc/letsencrypt/renewal && sudo cp /tmp/api.buywhere.ai.conf /etc/letsencrypt/renewal/api.buywhere.ai.conf && rm /tmp/api.buywhere.ai.conf" + + # Deploy us.buywhere.com config if ADDITIONAL_DOMAINS is set + if [[ -n "${{ env.ADDITIONAL_DOMAINS }}" ]]; then + scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + certbot/renewal/us.buywhere.com.conf \ + ubuntu@${{ env.INSTANCE_IP }}:/tmp/us.buywhere.com.conf + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.INSTANCE_IP }} \ + "sudo cp /tmp/us.buywhere.com.conf /etc/letsencrypt/renewal/us.buywhere.com.conf && rm /tmp/us.buywhere.com.conf" + fi + + echo "Certbot renewal configs deployed" + + - name: Ensure webroot directory exists + run: | + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.INSTANCE_IP }} \ + "sudo mkdir -p /var/www/html" + echo "Webroot directory ready" + + - name: Deploy SSL renewal scripts + run: | + echo "Deploying SSL renewal scripts..." + scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + scripts/ssl_renewal.sh \ + scripts/ssl_exporter.sh \ + ubuntu@${{ env.INSTANCE_IP }}:/tmp/ + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.INSTANCE_IP }} \ + "sudo mkdir -p /home/ubuntu/scripts && sudo cp /tmp/ssl_renewal.sh /home/ubuntu/scripts/ && sudo cp /tmp/ssl_exporter.sh /home/ubuntu/scripts/ && sudo chmod +x /home/ubuntu/scripts/ssl_renewal.sh /home/ubuntu/scripts/ssl_exporter.sh && rm /tmp/ssl_renewal.sh /tmp/ssl_exporter.sh" + echo "SSL renewal scripts deployed" + + - name: Check certificate status + id: check + run: | + echo "Checking certificate status for ${{ env.CERT_DOMAIN }}..." + CHECK_OUTPUT=$(ssh -o StrictHostKeyChecking=no \ + -o ConnectTimeout=10 \ + ubuntu@${{ env.INSTANCE_IP }} \ + "bash /home/ubuntu/scripts/ssl_renewal.sh check" 2>&1) || true + echo "$CHECK_OUTPUT" + echo "check_output=$CHECK_OUTPUT" >> $GITHUB_OUTPUT + + - name: Determine if renewal needed + id: decision + run: | + FORCE=${{ github.event.inputs.force_renewal }} + CHECK_OUTPUT="${{ steps.check.outputs.check_output }}" + + if [[ "$FORCE" == "true" ]]; then + echo "Force renewal requested" + echo "should_renew=true" >> $GITHUB_OUTPUT + exit 0 + fi + + if echo "$CHECK_OUTPUT" | grep -q "Certificate file not found"; then + echo "Certificate file not found - renewal needed" + echo "should_renew=true" >> $GITHUB_OUTPUT + else + DAYS=$(echo "$CHECK_OUTPUT" | tail -1 | tr -d '[:space:]') + echo "Certificate expires in $DAYS days" + if [[ -n "$DAYS" && "$DAYS" =~ ^[0-9]+$ && "$DAYS" -lt 30 ]]; then + echo "Renewal needed (expires in $DAYS days)" + echo "should_renew=true" >> $GITHUB_OUTPUT + else + echo "No renewal needed" + echo "should_renew=false" >> $GITHUB_OUTPUT + fi + fi + + - name: Renew certificate + if: steps.decision.outputs.should_renew == 'true' + run: | + echo "Running certificate renewal..." + ssh -o StrictHostKeyChecking=no \ + -o ConnectTimeout=60 \ + ubuntu@${{ env.INSTANCE_IP }} \ + "bash /home/ubuntu/scripts/ssl_renewal.sh renew" 2>&1 + + - name: Verify certificate + run: | + echo "Verifying certificate for ${{ env.CERT_DOMAIN }}..." + CERT_EXPIRY=$(echo | openssl s_client -servername ${{ env.CERT_DOMAIN }} -connect ${{ env.CERT_DOMAIN }}:443 2>/dev/null \ + | openssl x509 -noout -dates 2>/dev/null | grep notAfter | cut -d= -f2) + echo "Certificate expires: $CERT_EXPIRY" + + - name: Fetch renewal log + if: always() + run: | + ssh -o StrictHostKeyChecking=no \ + ubuntu@${{ env.INSTANCE_IP }} \ + "tail -100 /var/log/ssl_renewal.log 2>/dev/null" 2>&1 || echo "Could not fetch log" + + - name: Cleanup SSH key + if: always() + run: | + rm -f ~/.ssh/id_rsa + rm -f ~/.ssh/known_hosts + + - name: Alert on failure + if: failure() + run: | + echo "SSL certificate renewal failed for ${{ env.CERT_DOMAIN }}" + echo "Manual intervention may be required." + echo "To force renewal: gh workflow run ssl-renewal.yml -f force_renewal=true" From 32a187bfadae0c08ca88e3bf8e12f7b63e27f066 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:06:57 +0700 Subject: [PATCH 15/30] BUY-5602: Add update-cloud-run-sentry.yml workflow --- .github/workflows/update-cloud-run-sentry.yml | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/update-cloud-run-sentry.yml diff --git a/.github/workflows/update-cloud-run-sentry.yml b/.github/workflows/update-cloud-run-sentry.yml new file mode 100644 index 000000000..e92a44e8c --- /dev/null +++ b/.github/workflows/update-cloud-run-sentry.yml @@ -0,0 +1,76 @@ +name: Update Cloud Run Production SENTRY_DSN + +on: + workflow_dispatch: + +env: + GCP_PROJECT_ID: buywhere-production + GCP_REGION: asia-southeast1 + CLOUD_RUN_SERVICE: buywhere-api-production + +jobs: + update-sentry-dsn: + name: Update SENTRY_DSN in Cloud Run + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_PROD }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Update Cloud Run environment variable + run: | + gcloud run services update ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --update-env-vars SENTRY_DSN=${{ secrets.SENTRY_DSN_PRODUCTION }} + + - name: Verify the update + run: | + echo "Waiting 10 seconds for service to restart..." + sleep 10 + gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "table(status.conditions.name,status.conditions.status)" + + - name: Health check + run: | + SERVICE_URL=$(gcloud run services describe ${{ env.CLOUD_RUN_SERVICE }} \ + --project ${{ env.GCP_PROJECT_ID }} \ + --region ${{ env.GCP_REGION }} \ + --format "value(status.url)") + echo "Service URL: $SERVICE_URL" + curl -sf "${SERVICE_URL}/health" || echo "Health check failed - service may need more time to start" + + configure-sentry-alerts: + name: Configure Sentry Alerts + needs: update-sentry-dsn + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install requests + + - name: Configure Sentry Alerts + env: + SENTRY_API_TOKEN: ${{ secrets.SENTRY_API_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'buywhere' }} + run: | + python scripts/configure_sentry_alerts.py \ No newline at end of file From 7b00218cdcd7f9638b21c0ab208c032731a4a661 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:32 +0700 Subject: [PATCH 16/30] BUY-5602: Add deploy-cloud-run-production.yml workflow From 30f08e4019c47c3633ad9684c2a415b985d8cf0c Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:34 +0700 Subject: [PATCH 17/30] BUY-5602: Add deploy-cloud-run-staging.yml workflow From e63ca85e75070887ce28db74e85d43b8080add44 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:35 +0700 Subject: [PATCH 18/30] BUY-5602: Add deploy-frontend-vercel.yml workflow From 72cab3c2fb7afb1f46a5b4d238c312c4c78fd447 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:37 +0700 Subject: [PATCH 19/30] BUY-5602: Add deploy-logging-production.yml workflow From 89ded23eb6dadee92ec98f1db2c83e2fd7cb5449 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:38 +0700 Subject: [PATCH 20/30] BUY-5602: Add deploy-mcp-cloud-run-production.yml workflow From 30b4fe363dc6f148e0d23ae7333d119b22f0f4f9 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:40 +0700 Subject: [PATCH 21/30] BUY-5602: Add deploy-static-site.yml workflow From 54822fdaca974e56b191ddbd8323fab72bab909f Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:41 +0700 Subject: [PATCH 22/30] BUY-5602: Add deploy-us.yml workflow From 1202b65b5ad7b3eef5c964f70896528623dcf2b4 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:43 +0700 Subject: [PATCH 23/30] BUY-5602: Add docs.yml workflow From 2c4695a99e246b9de5488aca684e736429a96c0c Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:44 +0700 Subject: [PATCH 24/30] BUY-5602: Add enable-pg-stat-statements-staging.yml workflow From 334bff651ecf52e72fd2bbee85febcc871d2557b Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:45 +0700 Subject: [PATCH 25/30] BUY-5602: Add inject-posthog-vm.yml workflow From 1af1f5e87ccb2f4a73f349b151c6228fdd2f7dd4 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:47 +0700 Subject: [PATCH 26/30] BUY-5602: Add nginx-deploy.yml workflow From 82c83e375fb587439fbbafa2c32c2adb068ddd93 Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:49 +0700 Subject: [PATCH 27/30] BUY-5602: Add publish-python-sdk.yml workflow From 449560b8b77faa81fbea698eab3bfc756ea3504b Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:50 +0700 Subject: [PATCH 28/30] BUY-5602: Add sentry-alerts.yml workflow From a073bfcaabb5a7af2652574d566579385d786e1f Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:51 +0700 Subject: [PATCH 29/30] BUY-5602: Add ssl-renewal.yml workflow From 0d75c3a1ef713719246678e80c0147a04333cd3e Mon Sep 17 00:00:00 2001 From: BuyWhere Date: Thu, 30 Apr 2026 06:07:52 +0700 Subject: [PATCH 30/30] BUY-5602: Add update-cloud-run-sentry.yml workflow