mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10: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:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [staging, main]
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -23,56 +45,39 @@ jobs:
|
||||
- run: npm run verify:fast
|
||||
- run: npm run build
|
||||
|
||||
full-stack:
|
||||
backend-integration:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Start local Supabase
|
||||
- name: Run standalone backend integration tests
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: local
|
||||
GOOGLE_CLIENT_SECRET: local
|
||||
APPLE_CLIENT_ID: local
|
||||
APPLE_CLIENT_SECRET: local
|
||||
run: npx supabase start --workdir apps
|
||||
- name: Reset and seed
|
||||
run: |
|
||||
npx supabase db reset --workdir apps
|
||||
scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs
|
||||
- name: Start application services
|
||||
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
|
||||
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
||||
run: npm run test:api:integration
|
||||
|
||||
container-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify production Compose contract
|
||||
run: infra/backend/verify-production.sh
|
||||
@@ -1,18 +1,19 @@
|
||||
# Sauci agent map
|
||||
|
||||
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
|
||||
workspace.
|
||||
web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
|
||||
MCP service in one npm/Turborepo workspace.
|
||||
|
||||
## Golden rules
|
||||
|
||||
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
||||
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
|
||||
`npm run db:migration:new -- <name>`. Never use remote `apply_migration`, never
|
||||
execute DDL through MCP SQL tools, and use those SQL tools for `SELECT` only.
|
||||
4. Never target production from local development or E2E. Local tests must fail
|
||||
closed when a configured Supabase URL is not localhost.
|
||||
3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
|
||||
Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
|
||||
Functions there. Never execute remote DDL through MCP or provider tools.
|
||||
4. Never target production from local development or E2E. Database integration
|
||||
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.
|
||||
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.
|
||||
@@ -26,7 +27,8 @@ workspace.
|
||||
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
||||
| `apps/admin` | Vite administration UI | `apps/admin/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` |
|
||||
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
||||
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
},
|
||||
"preview": {
|
||||
"node": "20.10.0",
|
||||
"distribution": "internal"
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"node": "20.10.0",
|
||||
"autoIncrement": true,
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://api.sauci.app",
|
||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
|
||||
"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
|
||||
integration tests.
|
||||
This directory preserves the former product-data schema, Edge Functions, seeds,
|
||||
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
|
||||
|
||||
- Create migrations only with `npm run db:migration:new -- <name>`.
|
||||
- Edit only `apps/supabase/migrations/`; this is the authoritative migration tree.
|
||||
- Do not add product-data migrations or functions here.
|
||||
- Do not deploy this migration tree or these Edge Functions to hosted Supabase.
|
||||
- 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.
|
||||
- Cron/function URLs must resolve per environment, never hardcode production.
|
||||
|
||||
## Function boundaries
|
||||
|
||||
@@ -21,5 +21,5 @@ integration tests.
|
||||
- Preserve CORS handling and return non-sensitive errors.
|
||||
- Test RLS, authorization, and important side effects, not only HTTP status.
|
||||
|
||||
Verify with local reset, `npm run test:supabase`, invariant lint, and affected
|
||||
client tests. See `docs/operations/database-migrations.md`.
|
||||
Legacy tests may be run during cutover archaeology, but they are not the active
|
||||
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)
|
||||
- [Testing strategy](testing/strategy.md)
|
||||
- [Database migration safety](operations/database-migrations.md)
|
||||
- [Backend deployment and branching](operations/backend-deployments.md)
|
||||
- [Agent routing and effort](agents/routing.md)
|
||||
- [Learning promotion](agents/learnings.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
|
||||
|
||||
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
|
||||
|
||||
1. `npm run db:migration:new -- <descriptive_name>`
|
||||
2. Edit the generated local migration.
|
||||
3. Prefer idempotent SQL where semantics permit it.
|
||||
4. `npm run db:reset` against local Supabase.
|
||||
5. Run `npm run test:supabase` and affected application checks.
|
||||
6. Commit the migration with dependent code and types.
|
||||
1. Add the next ordered SQL migration under `apps/api/drizzle/`.
|
||||
2. Keep application, repository, shared-contract, and client changes in the same
|
||||
reviewed change where the schema contract requires them.
|
||||
3. Run `npm run verify:full` against the dedicated loopback PostgreSQL service.
|
||||
4. Run `infra/backend/verify-production.sh` for container and migration startup.
|
||||
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 execute DDL through MCP SQL execution.
|
||||
- MCP SQL execution is read-only `SELECT` use only.
|
||||
- Never insert migration history manually except an explicitly approved repair.
|
||||
- Never hardcode a production URL in a migration or cron definition.
|
||||
- Never point local integration tests, reset commands, or disposable verification
|
||||
at a remote database.
|
||||
- Never deploy `apps/supabase/migrations` or `apps/supabase/functions` as part of
|
||||
the standalone backend release.
|
||||
- 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
|
||||
pushes. Production promotion is a manual workflow dispatch. Terraform is applied
|
||||
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.
|
||||
Production data cutover has additional stop-source, final-sync, parity, legacy
|
||||
media, and authenticated-mobile checks in [backend-cutover.md](backend-cutover.md).
|
||||
@@ -6,23 +6,25 @@ is proved through the real stack.
|
||||
## Gates
|
||||
|
||||
- `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests.
|
||||
- `verify:full`: fast gate plus builds and local Supabase function tests when the
|
||||
required local tools are present.
|
||||
- `verify:full`: fast gate plus builds and standalone API/PostgreSQL integration
|
||||
tests against a loopback database.
|
||||
- `verify:e2e`: Playwright browser flows and native Maestro flows against an
|
||||
already-running local stack.
|
||||
- `verify`: full plus E2E; this is the pre-PR target.
|
||||
|
||||
CI runs deterministic checks on every pull request. Running-stack tests use a
|
||||
separate job because they require Docker/Supabase and produce traces,
|
||||
screenshots, and videos as artifacts.
|
||||
CI runs deterministic checks on every pull request and on pushes to `staging`
|
||||
and `main`. The backend integration job provisions disposable PostgreSQL and
|
||||
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
|
||||
|
||||
- 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`.
|
||||
- Native black-box flows live in `e2e/maestro`.
|
||||
|
||||
E2E must use local or sandbox endpoints. Tests refuse non-local Supabase URLs.
|
||||
Authentication is proved once through the public flow; reusable fixture sessions
|
||||
avoid repeating auth in unrelated scenarios.
|
||||
E2E product-data endpoints must be local or staging, never production. Local
|
||||
database tests refuse non-loopback URLs. Authenticated staging acceptance uses
|
||||
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
|
||||
`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
|
||||
configuration. `POSTGRES_PASSWORD` and the password embedded (URL encoded) in
|
||||
`DATABASE_URL` must match. Generate them for the deployment; never reuse the
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
name: sauci-backend
|
||||
|
||||
x-common-environment: &common-environment
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
|
||||
|
||||
+2
-1
@@ -20,9 +20,10 @@
|
||||
"lint:harness": "node scripts/lint-harness.mjs",
|
||||
"test": "turbo test --filter=!@sauci/e2e",
|
||||
"test:supabase": "scripts/test-supabase.sh",
|
||||
"test:api:integration": "scripts/test-api-integration.sh",
|
||||
"test:mobile:e2e": "maestro test e2e/maestro",
|
||||
"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": "npm run verify:full && npm run verify:e2e",
|
||||
"db:start": "npx supabase start --workdir apps",
|
||||
|
||||
@@ -31,7 +31,7 @@ for (const file of allFiles) {
|
||||
if (!/\.(md|sql)$/.test(file)) continue;
|
||||
const source = await readFile(file, 'utf8');
|
||||
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');
|
||||
if (await exists(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';
|
||||
@@ -77,6 +77,21 @@ if (!scoped) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
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