From a455325147b2fd75ca661822544fbd0065e637f8 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 28 Aug 2026 14:54:12 +0100 Subject: [PATCH] ci(deploy): establish staged Dokploy promotion --- .github/workflows/deploy-supabase.yml | 215 ------------------------- .github/workflows/verify.yml | 95 +++++------ AGENTS.md | 18 ++- apps/mobile/eas.json | 6 +- apps/supabase/AGENTS.md | 18 +-- docs/index.md | 1 + docs/operations/backend-deployments.md | 44 +++++ docs/operations/database-migrations.md | 45 +++--- docs/testing/strategy.md | 20 +-- infra/backend/README.md | 7 + infra/backend/compose.production.yaml | 2 - package.json | 3 +- scripts/lint-harness.mjs | 19 ++- scripts/test-api-integration.sh | 16 ++ 14 files changed, 199 insertions(+), 310 deletions(-) delete mode 100644 .github/workflows/deploy-supabase.yml create mode 100644 docs/operations/backend-deployments.md create mode 100755 scripts/test-api-integration.sh diff --git a/.github/workflows/deploy-supabase.yml b/.github/workflows/deploy-supabase.yml deleted file mode 100644 index d5e266d..0000000 --- a/.github/workflows/deploy-supabase.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 38fdaa1..078aa3b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 714770d..a1f0b73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -- `. 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` | diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 55c47c5..2e59cd9 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -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", diff --git a/apps/supabase/AGENTS.md b/apps/supabase/AGENTS.md index 095ac6a..ef7f5b0 100644 --- a/apps/supabase/AGENTS.md +++ b/apps/supabase/AGENTS.md @@ -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 -- `. -- 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`. diff --git a/docs/index.md b/docs/index.md index 35c6c87..8dedc45 100644 --- a/docs/index.md +++ b/docs/index.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) diff --git a/docs/operations/backend-deployments.md b/docs/operations/backend-deployments.md new file mode 100644 index 0000000..2ccb482 --- /dev/null +++ b/docs/operations/backend-deployments.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. diff --git a/docs/operations/database-migrations.md b/docs/operations/database-migrations.md index 16037db..927f449 100644 --- a/docs/operations/database-migrations.md +++ b/docs/operations/database-migrations.md @@ -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 -- ` -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). diff --git a/docs/testing/strategy.md b/docs/testing/strategy.md index b438494..8730b81 100644 --- a/docs/testing/strategy.md +++ b/docs/testing/strategy.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. diff --git a/infra/backend/README.md b/infra/backend/README.md index 9a2ed9d..d96230f 100644 --- a/infra/backend/README.md +++ b/infra/backend/README.md @@ -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 diff --git a/infra/backend/compose.production.yaml b/infra/backend/compose.production.yaml index 0dd50fa..8fb0ac9 100644 --- a/infra/backend/compose.production.yaml +++ b/infra/backend/compose.production.yaml @@ -1,5 +1,3 @@ -name: sauci-backend - x-common-environment: &common-environment NODE_ENV: production DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL} diff --git a/package.json b/package.json index 671087a..3094eee 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lint-harness.mjs b/scripts/lint-harness.mjs index b15f2d7..1a4c470 100644 --- a/scripts/lint-harness.mjs +++ b/scripts/lint-harness.mjs @@ -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) { diff --git a/scripts/test-api-integration.sh b/scripts/test-api-integration.sh new file mode 100755 index 0000000..81d695b --- /dev/null +++ b/scripts/test-api-integration.sh @@ -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