mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
ci(deploy): establish staged Dokploy promotion
This commit is contained in:
1 parent
2dad7aa352
commit
a455325147
14 files changed
+199
-310
No files matched your search
@@ -1,215 +0,0 @@
|
|||||||
name: Deploy Supabase to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
deploy_production:
|
|
||||||
description: Promote the verified change to production after non-production
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
deploy_infrastructure:
|
|
||||||
description: Apply Terraform as part of this manual deployment
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths:
|
|
||||||
- 'apps/supabase/**'
|
|
||||||
- 'terraform/**'
|
|
||||||
- '.github/workflows/deploy-supabase.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changes:
|
|
||||||
name: Detect infrastructure changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
infrastructure: ${{ steps.infrastructure.outputs.changed }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: infrastructure
|
|
||||||
name: Check Terraform scope
|
|
||||||
env:
|
|
||||||
BEFORE_SHA: ${{ github.event.before }}
|
|
||||||
CURRENT_SHA: ${{ github.sha }}
|
|
||||||
DEPLOY_INFRASTRUCTURE: ${{ inputs.deploy_infrastructure }}
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "changed=${DEPLOY_INFRASTRUCTURE:-false}" >> "$GITHUB_OUTPUT"
|
|
||||||
elif git diff --quiet "$BEFORE_SHA" "$CURRENT_SHA" -- terraform/; then
|
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
quality:
|
|
||||||
name: Verify deployable source
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
cache: npm
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run verify:fast
|
|
||||||
- run: npm run build
|
|
||||||
|
|
||||||
deploy-database:
|
|
||||||
name: Deploy Migrations & Functions
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: quality
|
|
||||||
environment: ${{ matrix.environment }}
|
|
||||||
strategy:
|
|
||||||
max-parallel: 1
|
|
||||||
matrix:
|
|
||||||
environment: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.deploy_production && '["non-prod","prod"]' || '["non-prod"]') }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
||||||
SUPABASE_PROJECT_REF: ${{ matrix.environment == 'prod' && 'ckjcrkjpmhqhiucifukx' || 'itbzhrvlgvdmzbnhzhyx' }}
|
|
||||||
ENVIRONMENT_NAME: ${{ matrix.environment }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Supabase CLI
|
|
||||||
uses: supabase/setup-cli@v1
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Link to ${{ matrix.environment }} Project
|
|
||||||
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
|
||||||
|
|
||||||
- name: Apply Database Migrations (${{ matrix.environment }})
|
|
||||||
run: supabase db push --workdir apps --include-all
|
|
||||||
|
|
||||||
- name: Sync Edge Function Secrets (${{ matrix.environment }})
|
|
||||||
env:
|
|
||||||
# Add secrets from GitHub Secrets
|
|
||||||
REVENUECAT_WEBHOOK_SECRET: ${{ secrets.REVENUECAT_WEBHOOK_SECRET }}
|
|
||||||
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
|
|
||||||
REVENUECAT_ENTITLEMENT_ID: ${{ secrets.REVENUECAT_ENTITLEMENT_ID }}
|
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
||||||
ADMIN_PRIVATE_KEY_JWK: ${{ secrets.ADMIN_PRIVATE_KEY_JWK }}
|
|
||||||
ADMIN_KEYS_JSON: ${{ secrets.ADMIN_KEYS_JSON }}
|
|
||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
run: |
|
|
||||||
# Sync secrets to Supabase (only if they're set in GitHub)
|
|
||||||
secrets_to_set=""
|
|
||||||
|
|
||||||
[ -n "$REVENUECAT_WEBHOOK_SECRET" ] && secrets_to_set="$secrets_to_set REVENUECAT_WEBHOOK_SECRET=$REVENUECAT_WEBHOOK_SECRET"
|
|
||||||
[ -n "$REVENUECAT_API_KEY" ] && secrets_to_set="$secrets_to_set REVENUECAT_API_KEY=$REVENUECAT_API_KEY"
|
|
||||||
[ -n "$REVENUECAT_ENTITLEMENT_ID" ] && secrets_to_set="$secrets_to_set REVENUECAT_ENTITLEMENT_ID=$REVENUECAT_ENTITLEMENT_ID"
|
|
||||||
[ -n "$OPENROUTER_API_KEY" ] && secrets_to_set="$secrets_to_set OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
|
||||||
[ -n "$ADMIN_PRIVATE_KEY_JWK" ] && secrets_to_set="$secrets_to_set ADMIN_PRIVATE_KEY_JWK=$ADMIN_PRIVATE_KEY_JWK"
|
|
||||||
[ -n "$ADMIN_KEYS_JSON" ] && secrets_to_set="$secrets_to_set ADMIN_KEYS_JSON=$ADMIN_KEYS_JSON"
|
|
||||||
[ -n "$DISCORD_WEBHOOK_URL" ] && secrets_to_set="$secrets_to_set DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL"
|
|
||||||
|
|
||||||
if [ -n "$secrets_to_set" ]; then
|
|
||||||
echo "Syncing secrets to Supabase ($ENVIRONMENT_NAME)..."
|
|
||||||
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
|
|
||||||
else
|
|
||||||
echo "No secrets to sync"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy Edge Functions (${{ matrix.environment }})
|
|
||||||
run: |
|
|
||||||
# Deploy all edge functions (excluding _shared directory)
|
|
||||||
for dir in apps/supabase/functions/*/; do
|
|
||||||
function_name=$(basename "$dir")
|
|
||||||
|
|
||||||
# Skip _shared directory (it's a shared module, not a function)
|
|
||||||
if [ "$function_name" = "_shared" ]; then
|
|
||||||
echo "Skipping _shared (shared module)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Deploying function: $function_name to $ENVIRONMENT_NAME"
|
|
||||||
supabase functions deploy "$function_name" --no-verify-jwt --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Remove Deleted Functions (${{ matrix.environment }})
|
|
||||||
run: |
|
|
||||||
# Get list of functions deployed on Supabase (skip header lines, extract slug column)
|
|
||||||
deployed=$(supabase functions list --project-ref $SUPABASE_PROJECT_REF | grep -E '^[a-z]' | awk '{print $1}')
|
|
||||||
|
|
||||||
# Get list of functions in the repo (excluding _shared)
|
|
||||||
local_functions=$(ls -d apps/supabase/functions/*/ 2>/dev/null | xargs -I {} basename {} | grep -v '^_shared$' || true)
|
|
||||||
|
|
||||||
# Delete any deployed functions that no longer exist in the repo
|
|
||||||
for func in $deployed; do
|
|
||||||
# Skip empty or invalid function names
|
|
||||||
[ -z "$func" ] && continue
|
|
||||||
[[ "$func" =~ ^[^a-z] ]] && continue
|
|
||||||
|
|
||||||
if ! echo "$local_functions" | grep -q "^${func}$"; then
|
|
||||||
echo "Deleting removed function from $ENVIRONMENT_NAME: $func"
|
|
||||||
supabase functions delete "$func" --project-ref $SUPABASE_PROJECT_REF
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
deploy-infrastructure:
|
|
||||||
name: Deploy Infrastructure (Terraform)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [quality, changes]
|
|
||||||
if: needs.changes.outputs.infrastructure == 'true'
|
|
||||||
|
|
||||||
env:
|
|
||||||
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Terraform
|
|
||||||
uses: hashicorp/setup-terraform@v3
|
|
||||||
with:
|
|
||||||
terraform_version: "1.6"
|
|
||||||
|
|
||||||
# Non-Production Environment
|
|
||||||
- name: Terraform Init (Non-Prod)
|
|
||||||
working-directory: terraform/environments/non-prod
|
|
||||||
run: terraform init
|
|
||||||
|
|
||||||
- name: Terraform Format Check
|
|
||||||
working-directory: terraform/modules/supabase
|
|
||||||
run: terraform fmt -check
|
|
||||||
|
|
||||||
- name: Terraform Validate (Non-Prod)
|
|
||||||
working-directory: terraform/environments/non-prod
|
|
||||||
run: terraform validate
|
|
||||||
|
|
||||||
- name: Terraform Plan (Non-Prod)
|
|
||||||
working-directory: terraform/environments/non-prod
|
|
||||||
run: terraform plan -out=tfplan
|
|
||||||
|
|
||||||
- name: Terraform Apply (Non-Prod)
|
|
||||||
working-directory: terraform/environments/non-prod
|
|
||||||
run: terraform apply -auto-approve tfplan
|
|
||||||
|
|
||||||
# Production Environment
|
|
||||||
- name: Terraform Init (Prod)
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
|
|
||||||
working-directory: terraform/environments/prod
|
|
||||||
run: terraform init
|
|
||||||
|
|
||||||
- name: Terraform Validate (Prod)
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
|
|
||||||
working-directory: terraform/environments/prod
|
|
||||||
run: terraform validate
|
|
||||||
|
|
||||||
- name: Terraform Plan (Prod)
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
|
|
||||||
working-directory: terraform/environments/prod
|
|
||||||
run: terraform plan -out=tfplan
|
|
||||||
|
|
||||||
- name: Terraform Apply (Prod)
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
|
|
||||||
working-directory: terraform/environments/prod
|
|
||||||
run: terraform apply -auto-approve tfplan
|
|
||||||
@@ -3,13 +3,35 @@ name: Verify
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [staging, main]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: verify-${{ github.ref }}
|
group: verify-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
branch-policy:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Enforce promotion flow
|
||||||
|
env:
|
||||||
|
BASE_BRANCH: ${{ github.base_ref }}
|
||||||
|
HEAD_BRANCH: ${{ github.head_ref }}
|
||||||
|
run: |
|
||||||
|
if [ "$BASE_BRANCH" = "master" ]; then
|
||||||
|
echo "master is frozen; target staging or main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then
|
||||||
|
echo "Only staging may be promoted to main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
|
||||||
|
echo "Feature pull requests target staging; promotions target main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
static:
|
static:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -23,56 +45,39 @@ jobs:
|
|||||||
- run: npm run verify:fast
|
- run: npm run verify:fast
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
full-stack:
|
backend-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 15
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: sauci_test
|
||||||
|
POSTGRES_USER: sauci
|
||||||
|
POSTGRES_PASSWORD: sauci_ci_only
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U sauci -d sauci_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: denoland/setup-deno@v2
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
- name: Run standalone backend integration tests
|
||||||
- name: Start local Supabase
|
|
||||||
env:
|
env:
|
||||||
GOOGLE_CLIENT_ID: local
|
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
||||||
GOOGLE_CLIENT_SECRET: local
|
run: npm run test:api:integration
|
||||||
APPLE_CLIENT_ID: local
|
|
||||||
APPLE_CLIENT_SECRET: local
|
container-contract:
|
||||||
run: npx supabase start --workdir apps
|
runs-on: ubuntu-latest
|
||||||
- name: Reset and seed
|
timeout-minutes: 20
|
||||||
run: |
|
steps:
|
||||||
npx supabase db reset --workdir apps
|
- uses: actions/checkout@v4
|
||||||
scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs
|
- name: Verify production Compose contract
|
||||||
- name: Start application services
|
run: infra/backend/verify-production.sh
|
||||||
run: |
|
|
||||||
npx supabase functions serve --workdir apps > /tmp/sauci-functions.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh web npm run dev -w @sauci/web -- --port 3000 > /tmp/sauci-web.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh admin npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001 > /tmp/sauci-admin.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh mcp npm run dev -w sauci-mcp-server > /tmp/sauci-mcp.log 2>&1 &
|
|
||||||
- name: Wait for services
|
|
||||||
run: |
|
|
||||||
for url in http://127.0.0.1:3000 http://127.0.0.1:3001/login http://127.0.0.1:3002/health; do
|
|
||||||
for attempt in $(seq 1 60); do
|
|
||||||
curl --fail --silent "$url" >/dev/null && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
curl --fail --silent "$url" >/dev/null
|
|
||||||
done
|
|
||||||
- run: npm run test:supabase
|
|
||||||
- run: npm run verify:e2e
|
|
||||||
- name: Upload E2E proof
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-proof
|
|
||||||
path: |
|
|
||||||
e2e/test-results
|
|
||||||
e2e/playwright-report
|
|
||||||
/tmp/sauci-*.log
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
# Sauci agent map
|
# Sauci agent map
|
||||||
|
|
||||||
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
|
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
|
||||||
web app, Supabase backend, and an internal MCP service in one npm/Turborepo
|
web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
|
||||||
workspace.
|
MCP service in one npm/Turborepo workspace.
|
||||||
|
|
||||||
## Golden rules
|
## Golden rules
|
||||||
|
|
||||||
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
||||||
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
|
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
|
||||||
3. All schema changes are local files in `apps/supabase/migrations/` created with
|
3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
|
||||||
`npm run db:migration:new -- <name>`. Never use remote `apply_migration`, never
|
Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
|
||||||
execute DDL through MCP SQL tools, and use those SQL tools for `SELECT` only.
|
Functions there. Never execute remote DDL through MCP or provider tools.
|
||||||
4. Never target production from local development or E2E. Local tests must fail
|
4. Never target production from local development or E2E. Database integration
|
||||||
closed when a configured Supabase URL is not localhost.
|
tests must fail closed unless `DATABASE_URL` resolves to loopback. Authenticated
|
||||||
|
native acceptance may use only the designated non-production Auth project.
|
||||||
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
||||||
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
||||||
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
||||||
@@ -26,7 +27,8 @@ workspace.
|
|||||||
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
||||||
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
|
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
|
||||||
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
|
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
|
||||||
| `apps/supabase` | migrations, functions, local backend | `apps/supabase/AGENTS.md` |
|
| `apps/api` | standalone API, worker, PostgreSQL migrations | repository rules |
|
||||||
|
| `apps/supabase` | legacy data-plane history; hosted Auth configuration only | `apps/supabase/AGENTS.md` |
|
||||||
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
|
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
|
||||||
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
||||||
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"node": "20.10.0",
|
"node": "20.10.0",
|
||||||
"distribution": "internal"
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"node": "20.10.0",
|
"node": "20.10.0",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
||||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
|
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
|
||||||
"EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_lfPyYonFQtVytftPAfXIrGsMNFu",
|
"EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_lfPyYonFQtVytftPAfXIrGsMNFu",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# Supabase backend
|
# Legacy Supabase data plane
|
||||||
|
|
||||||
This directory owns local configuration, migrations, Edge Functions, seeds, and
|
This directory preserves the former product-data schema, Edge Functions, seeds,
|
||||||
integration tests.
|
and migration source needed for audited cutover. Hosted Supabase now owns Auth
|
||||||
|
only. The active application schema and server code live under `apps/api`.
|
||||||
|
|
||||||
## Schema safety
|
## Schema safety
|
||||||
|
|
||||||
- Create migrations only with `npm run db:migration:new -- <name>`.
|
- Do not add product-data migrations or functions here.
|
||||||
- Edit only `apps/supabase/migrations/`; this is the authoritative migration tree.
|
- Do not deploy this migration tree or these Edge Functions to hosted Supabase.
|
||||||
- Never use any remote `apply_migration` tool.
|
- Never use any remote `apply_migration` tool.
|
||||||
- Never execute DDL through MCP SQL tools; those tools are `SELECT`-only.
|
- Never execute DDL through MCP or provider SQL tools.
|
||||||
- Never link or reset a remote project during local verification.
|
- Never link or reset a remote project during local verification.
|
||||||
- Cron/function URLs must resolve per environment, never hardcode production.
|
|
||||||
|
|
||||||
## Function boundaries
|
## Function boundaries
|
||||||
|
|
||||||
@@ -21,5 +21,5 @@ integration tests.
|
|||||||
- Preserve CORS handling and return non-sensitive errors.
|
- Preserve CORS handling and return non-sensitive errors.
|
||||||
- Test RLS, authorization, and important side effects, not only HTTP status.
|
- Test RLS, authorization, and important side effects, not only HTTP status.
|
||||||
|
|
||||||
Verify with local reset, `npm run test:supabase`, invariant lint, and affected
|
Legacy tests may be run during cutover archaeology, but they are not the active
|
||||||
client tests. See `docs/operations/database-migrations.md`.
|
deployment gate. See `docs/operations/database-migrations.md`.
|
||||||
@@ -9,6 +9,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Local full-stack development](development/local-stack.md)
|
- [Local full-stack development](development/local-stack.md)
|
||||||
- [Testing strategy](testing/strategy.md)
|
- [Testing strategy](testing/strategy.md)
|
||||||
- [Database migration safety](operations/database-migrations.md)
|
- [Database migration safety](operations/database-migrations.md)
|
||||||
|
- [Backend deployment and branching](operations/backend-deployments.md)
|
||||||
- [Agent routing and effort](agents/routing.md)
|
- [Agent routing and effort](agents/routing.md)
|
||||||
- [Learning promotion](agents/learnings.md)
|
- [Learning promotion](agents/learnings.md)
|
||||||
- [Authentication](authentication.md)
|
- [Authentication](authentication.md)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Backend deployment and branching
|
||||||
|
|
||||||
|
Dokploy deploys the standalone backend directly from GitHub. GitHub Actions is
|
||||||
|
the verification gate; it does not hold a broad Dokploy API credential and does
|
||||||
|
not deploy the retired Supabase data plane.
|
||||||
|
|
||||||
|
## Branch contract
|
||||||
|
|
||||||
|
| Branch | Purpose | Dokploy target | Public API |
|
||||||
|
|---|---|---|---|
|
||||||
|
| feature branches | isolated reviewed work | none | none |
|
||||||
|
| `staging` | integrated release candidate | `backend-staging` | `https://api.preprod.sauci.app` |
|
||||||
|
| `main` | production promotion | `backend-production` | `https://api.sauci.app` |
|
||||||
|
| `master` | frozen legacy default | none | none |
|
||||||
|
|
||||||
|
Normal feature pull requests target `staging`. After CI and authenticated staging
|
||||||
|
acceptance pass, open a promotion pull request from `staging` to `main`. Do not
|
||||||
|
add production-only commits to `main`: production should run the exact commit
|
||||||
|
already exercised in staging. Direct pushes to either deployment branch are for
|
||||||
|
explicit incident recovery only.
|
||||||
|
|
||||||
|
Each Dokploy Compose application is connected to `jjbrunton/Sauci`, watches its
|
||||||
|
single branch with push-triggered automatic deployment, and uses
|
||||||
|
`infra/backend/compose.production.yaml`. The Compose file deliberately has no
|
||||||
|
top-level project name so Dokploy assigns different project names, networks, and
|
||||||
|
named volumes to staging and production. Never point both applications at the
|
||||||
|
same PostgreSQL or media volume.
|
||||||
|
|
||||||
|
## Promotion checks
|
||||||
|
|
||||||
|
1. GitHub `Verify / static` and `Verify / backend-integration` succeed for the
|
||||||
|
candidate commit.
|
||||||
|
2. The Dokploy deployment for `staging` reports success; `/health/live` and
|
||||||
|
`/health/ready` return 200 over valid TLS.
|
||||||
|
3. An unauthenticated protected request returns 401, proving the public route is
|
||||||
|
not bypassing authentication.
|
||||||
|
4. The mobile app signs in through non-production Supabase Auth and reads real
|
||||||
|
migrated content from `https://api.preprod.sauci.app`.
|
||||||
|
5. Promote the exact `staging` commit to `main` and verify the production health,
|
||||||
|
TLS, and authentication boundary independently.
|
||||||
|
|
||||||
|
Health proves infrastructure, not customer-data cutover. The App Store build
|
||||||
|
must not be released against production until the controlled final sync and the
|
||||||
|
checks in [backend-cutover.md](backend-cutover.md) are complete.
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
# Database migration safety
|
# Database migration safety
|
||||||
|
|
||||||
All schema changes are versioned local files under `apps/supabase/migrations/`.
|
The standalone PostgreSQL schema is owned by ordered SQL files under
|
||||||
|
`apps/api/drizzle/`. Hosted Supabase owns Auth only; its legacy product-data
|
||||||
|
migrations and Edge Functions are retained as cutover history and are not a
|
||||||
|
deployment target.
|
||||||
|
|
||||||
## Required workflow
|
## Required workflow
|
||||||
|
|
||||||
1. `npm run db:migration:new -- <descriptive_name>`
|
1. Add the next ordered SQL migration under `apps/api/drizzle/`.
|
||||||
2. Edit the generated local migration.
|
2. Keep application, repository, shared-contract, and client changes in the same
|
||||||
3. Prefer idempotent SQL where semantics permit it.
|
reviewed change where the schema contract requires them.
|
||||||
4. `npm run db:reset` against local Supabase.
|
3. Run `npm run verify:full` against the dedicated loopback PostgreSQL service.
|
||||||
5. Run `npm run test:supabase` and affected application checks.
|
4. Run `infra/backend/verify-production.sh` for container and migration startup.
|
||||||
6. Commit the migration with dependent code and types.
|
5. Merge to `staging`; the Dokploy migration service applies the committed files
|
||||||
|
under an advisory lock before the API receives traffic.
|
||||||
|
6. Prove staging health and affected authenticated behavior before promoting the
|
||||||
|
exact commit from `staging` to `main`.
|
||||||
|
|
||||||
## Forbidden paths
|
## Safety invariants
|
||||||
|
|
||||||
- Never call a remote `apply_migration` MCP tool.
|
- Never point local integration tests, reset commands, or disposable verification
|
||||||
- Never execute DDL through MCP SQL execution.
|
at a remote database.
|
||||||
- MCP SQL execution is read-only `SELECT` use only.
|
- Never deploy `apps/supabase/migrations` or `apps/supabase/functions` as part of
|
||||||
- Never insert migration history manually except an explicitly approved repair.
|
the standalone backend release.
|
||||||
- Never hardcode a production URL in a migration or cron definition.
|
- Never execute DDL through MCP or provider SQL tools.
|
||||||
|
- Never edit migration history already applied to a persistent environment; add
|
||||||
|
a forward migration.
|
||||||
|
- Back up PostgreSQL and private media together before a destructive production
|
||||||
|
migration or data import, and prove restore separately.
|
||||||
|
- A failed migration must block API startup. Do not bypass the one-shot `migrate`
|
||||||
|
service to force new code against an old schema.
|
||||||
|
|
||||||
CI deploys committed migrations and Edge Functions to non-production after relevant
|
Production data cutover has additional stop-source, final-sync, parity, legacy
|
||||||
pushes. Production promotion is a manual workflow dispatch. Terraform is applied
|
media, and authenticated-mobile checks in [backend-cutover.md](backend-cutover.md).
|
||||||
only when files under `terraform/` change, or when `deploy_infrastructure` is
|
|
||||||
explicitly selected during a manual deployment. Local verification must not link a
|
|
||||||
remote project.
|
|
||||||
@@ -6,23 +6,25 @@ is proved through the real stack.
|
|||||||
## Gates
|
## Gates
|
||||||
|
|
||||||
- `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests.
|
- `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests.
|
||||||
- `verify:full`: fast gate plus builds and local Supabase function tests when the
|
- `verify:full`: fast gate plus builds and standalone API/PostgreSQL integration
|
||||||
required local tools are present.
|
tests against a loopback database.
|
||||||
- `verify:e2e`: Playwright browser flows and native Maestro flows against an
|
- `verify:e2e`: Playwright browser flows and native Maestro flows against an
|
||||||
already-running local stack.
|
already-running local stack.
|
||||||
- `verify`: full plus E2E; this is the pre-PR target.
|
- `verify`: full plus E2E; this is the pre-PR target.
|
||||||
|
|
||||||
CI runs deterministic checks on every pull request. Running-stack tests use a
|
CI runs deterministic checks on every pull request and on pushes to `staging`
|
||||||
separate job because they require Docker/Supabase and produce traces,
|
and `main`. The backend integration job provisions disposable PostgreSQL and
|
||||||
screenshots, and videos as artifacts.
|
refuses non-loopback database URLs. User-facing browser/native acceptance remains
|
||||||
|
a pre-promotion check because it requires running services and test identities.
|
||||||
|
|
||||||
## Test ownership
|
## Test ownership
|
||||||
|
|
||||||
- Unit/component tests live with their workspace.
|
- Unit/component tests live with their workspace.
|
||||||
- Supabase function integration tests live in `apps/supabase/tests`.
|
- Standalone backend integration tests live in `apps/api/test`.
|
||||||
|
- `apps/supabase/tests` cover the retired data plane and are not a deployment gate.
|
||||||
- Cross-application browser tests live in `e2e`.
|
- Cross-application browser tests live in `e2e`.
|
||||||
- Native black-box flows live in `e2e/maestro`.
|
- Native black-box flows live in `e2e/maestro`.
|
||||||
|
|
||||||
E2E must use local or sandbox endpoints. Tests refuse non-local Supabase URLs.
|
E2E product-data endpoints must be local or staging, never production. Local
|
||||||
Authentication is proved once through the public flow; reusable fixture sessions
|
database tests refuse non-loopback URLs. Authenticated staging acceptance uses
|
||||||
avoid repeating auth in unrelated scenarios.
|
the designated non-production hosted Auth project and disposable fixture users.
|
||||||
@@ -21,6 +21,13 @@ API and worker, PostgreSQL, and a one-shot migration service. Configure Dokploy
|
|||||||
Compose file from the repository root and route the public API hostname to the
|
Compose file from the repository root and route the public API hostname to the
|
||||||
`api` service on port `3003`. Do not publish PostgreSQL.
|
`api` service on port `3003`. Do not publish PostgreSQL.
|
||||||
|
|
||||||
|
Create separate Dokploy Compose applications for `staging` and `main`, both using
|
||||||
|
`infra/backend/compose.production.yaml`; enable push-triggered auto-deploy for
|
||||||
|
their respective branch. The file intentionally omits a top-level Compose project
|
||||||
|
name so Dokploy isolates networks and named volumes per application. See
|
||||||
|
[`docs/operations/backend-deployments.md`](../../docs/operations/backend-deployments.md)
|
||||||
|
for the promotion contract.
|
||||||
|
|
||||||
Copy the variable names from `.env.production.example` into Dokploy's secret
|
Copy the variable names from `.env.production.example` into Dokploy's secret
|
||||||
configuration. `POSTGRES_PASSWORD` and the password embedded (URL encoded) in
|
configuration. `POSTGRES_PASSWORD` and the password embedded (URL encoded) in
|
||||||
`DATABASE_URL` must match. Generate them for the deployment; never reuse the
|
`DATABASE_URL` must match. Generate them for the deployment; never reuse the
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
name: sauci-backend
|
|
||||||
|
|
||||||
x-common-environment: &common-environment
|
x-common-environment: &common-environment
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
|
||||||
|
|||||||
+2
-1
@@ -20,9 +20,10 @@
|
|||||||
"lint:harness": "node scripts/lint-harness.mjs",
|
"lint:harness": "node scripts/lint-harness.mjs",
|
||||||
"test": "turbo test --filter=!@sauci/e2e",
|
"test": "turbo test --filter=!@sauci/e2e",
|
||||||
"test:supabase": "scripts/test-supabase.sh",
|
"test:supabase": "scripts/test-supabase.sh",
|
||||||
|
"test:api:integration": "scripts/test-api-integration.sh",
|
||||||
"test:mobile:e2e": "maestro test e2e/maestro",
|
"test:mobile:e2e": "maestro test e2e/maestro",
|
||||||
"verify:fast": "npm run lint && npm run typecheck && npm test",
|
"verify:fast": "npm run lint && npm run typecheck && npm test",
|
||||||
"verify:full": "npm run verify:fast && npm run build && npm run test:supabase",
|
"verify:full": "npm run verify:fast && npm run build && npm run test:api:integration",
|
||||||
"verify:e2e": "scripts/with-local-supabase-env.sh tests npm run test -w @sauci/e2e",
|
"verify:e2e": "scripts/with-local-supabase-env.sh tests npm run test -w @sauci/e2e",
|
||||||
"verify": "npm run verify:full && npm run verify:e2e",
|
"verify": "npm run verify:full && npm run verify:e2e",
|
||||||
"db:start": "npx supabase start --workdir apps",
|
"db:start": "npx supabase start --workdir apps",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ for (const file of allFiles) {
|
|||||||
if (!/\.(md|sql)$/.test(file)) continue;
|
if (!/\.(md|sql)$/.test(file)) continue;
|
||||||
const source = await readFile(file, 'utf8');
|
const source = await readFile(file, 'utf8');
|
||||||
if (/^\s*[-*]\s*Use\s+.*apply_migration/im.test(source)) {
|
if (/^\s*[-*]\s*Use\s+.*apply_migration/im.test(source)) {
|
||||||
fail(file, 'instruction authorizes untracked remote migrations', 'create a local apps/supabase/migrations file instead');
|
fail(file, 'instruction authorizes untracked remote migrations', 'create a committed apps/api/drizzle migration instead');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ if (!scoped) {
|
|||||||
const duplicateMigrations = path.join(root, 'supabase', 'migrations');
|
const duplicateMigrations = path.join(root, 'supabase', 'migrations');
|
||||||
if (await exists(duplicateMigrations)) {
|
if (await exists(duplicateMigrations)) {
|
||||||
const entries = await readdir(duplicateMigrations);
|
const entries = await readdir(duplicateMigrations);
|
||||||
if (entries.length) fail(duplicateMigrations, 'second migration tree can bypass deployment', 'move migrations to apps/supabase/migrations and remove the duplicate');
|
if (entries.length) fail(duplicateMigrations, 'second migration tree can bypass deployment', 'move active migrations to apps/api/drizzle and remove the duplicate');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoff = '20260201100806';
|
const cutoff = '20260201100806';
|
||||||
@@ -77,6 +77,21 @@ if (!scoped) {
|
|||||||
if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) {
|
if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) {
|
||||||
fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed');
|
fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const directory of [
|
||||||
|
path.join(root, 'apps', 'mobile'),
|
||||||
|
path.join(root, 'apps', 'admin', 'src'),
|
||||||
|
path.join(root, 'apps', 'web'),
|
||||||
|
path.join(root, 'apps', 'mcp', 'src'),
|
||||||
|
]) {
|
||||||
|
for (const file of await walk(directory, [])) {
|
||||||
|
if (!/\.(?:ts|tsx|js|jsx)$/.test(file) || /(?:\.test\.|__tests__)/.test(file)) continue;
|
||||||
|
const source = await readFile(file, 'utf8');
|
||||||
|
if (/\/functions\/v1\/|\.functions\.invoke\s*\(/.test(source)) {
|
||||||
|
fail(file, 'runtime code calls the retired Supabase Edge Function data plane', 'call the standalone versioned API instead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
|
|||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci}"
|
||||||
|
|
||||||
|
case "$DATABASE_URL" in
|
||||||
|
postgresql://*@127.0.0.1:*/*|postgresql://*@localhost:*/*|postgresql://*@\[::1\]:*/*) ;;
|
||||||
|
*)
|
||||||
|
echo "Refusing API integration tests against non-local DATABASE_URL" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
env DATABASE_URL="$DATABASE_URL" npm run test:integration -w @sauci/api
|
||||||
Reference in new issue
Block a user