API Contract Guard #447
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # API Contract Guard — Prevent silent API shape breakage | |
| # Issue #179 — Schema-to-UI Contract Validation (Quality Gate 9/9) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Runs the RPC contract integration tests that validate the shape of every | |
| # Supabase RPC function called by the frontend using Zod schemas. | |
| # If a migration changes a column name, removes a field, or alters a return | |
| # type, these tests FAIL before the frontend silently breaks. | |
| # | |
| # Contract schemas: frontend/src/lib/rpc-contracts/ | |
| # Integration tests: frontend/src/lib/rpc-contracts/__tests__/ | |
| # Unit tests: frontend/src/lib/rpc-contracts/__tests__/schema-validation.test.ts | |
| # | |
| # These tests require live Supabase connectivity (staging environment). | |
| # Triggered on: | |
| # • Every push to main (post-merge validation) | |
| # • PRs that touch SQL migrations or contract schemas (pre-merge prevention) | |
| # • Nightly (drift detection) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| name: API Contract Guard | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - "supabase/migrations/**" | |
| - "db/**" | |
| - "frontend/src/lib/rpc-contract*" | |
| - "frontend/src/lib/rpc-contracts/**" | |
| - ".github/workflows/api-contract.yml" | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - "supabase/migrations/**" | |
| - "db/**" | |
| - "frontend/src/lib/rpc-contract*" | |
| - "frontend/src/lib/rpc-contracts/**" | |
| - ".github/workflows/api-contract.yml" | |
| schedule: | |
| # Nightly 03:30 UTC — detect drift from manual DB changes (staggered from quality-gate) | |
| - cron: "30 3 * * *" | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: api-contract-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| contract-tests: | |
| name: RPC Contract Validation | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # Skip forks — they never have staging secrets | |
| if: github.event.pull_request.head.repo.fork != true | |
| defaults: | |
| run: | |
| working-directory: frontend | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| # ── Fail-fast: validate required secrets before wasting CI minutes ── | |
| - name: Validate required secrets | |
| working-directory: . | |
| env: | |
| _URL: ${{ secrets.SUPABASE_URL_STAGING || secrets.NEXT_PUBLIC_SUPABASE_URL }} | |
| _KEY: ${{ secrets.SUPABASE_ANON_KEY_STAGING || secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | |
| run: | | |
| missing=0 | |
| if [ -z "$_URL" ]; then | |
| echo "::error::Neither SUPABASE_URL_STAGING nor NEXT_PUBLIC_SUPABASE_URL is set" | |
| missing=1 | |
| fi | |
| if [ -z "$_KEY" ]; then | |
| echo "::error::Neither SUPABASE_ANON_KEY_STAGING nor NEXT_PUBLIC_SUPABASE_ANON_KEY is set" | |
| missing=1 | |
| fi | |
| if [ "$missing" -ne 0 ]; then | |
| echo "::error::Contract tests require live Supabase connectivity — set the secrets above" | |
| exit 1 | |
| fi | |
| echo "All required secrets present." | |
| - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - run: npm ci | |
| - name: Run RPC contract tests | |
| env: | |
| INTEGRATION: "1" | |
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SUPABASE_URL_STAGING || secrets.NEXT_PUBLIC_SUPABASE_URL }} | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY_STAGING || secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY_STAGING || secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| run: | | |
| echo "## API Contract Test Results" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| set +e | |
| npx vitest run rpc-contract 2>&1 | tee /tmp/contract-output.txt | |
| exit_code=${PIPESTATUS[0]} | |
| set -e | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| cat /tmp/contract-output.txt >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$exit_code" -ne 0 ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "❌ **API contract violation detected!**" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "One or more RPC functions returned unexpected shapes." >> "$GITHUB_STEP_SUMMARY" | |
| echo "This means a migration likely changed the API contract." >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**To reproduce locally:**" >> "$GITHUB_STEP_SUMMARY" | |
| echo '```bash' >> "$GITHUB_STEP_SUMMARY" | |
| echo 'cd frontend && npm run test:integration' >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Fix options:**" >> "$GITHUB_STEP_SUMMARY" | |
| echo "1. Fix the migration to preserve the existing contract" >> "$GITHUB_STEP_SUMMARY" | |
| echo "2. Update the frontend to handle the new shape" >> "$GITHUB_STEP_SUMMARY" | |
| echo "3. Update the contract tests if the change is intentional" >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "✅ All API contracts validated successfully." >> "$GITHUB_STEP_SUMMARY" | |
| - name: Verify health endpoint contract | |
| env: | |
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SUPABASE_URL_STAGING || secrets.NEXT_PUBLIC_SUPABASE_URL }} | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY_STAGING || secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY_STAGING || secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| run: | | |
| # Also run the health route unit test which validates response shape | |
| npx vitest run api/health |