Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,92 @@ jobs:

- name: Run offline test suite
run: uv run pytest tests/ -q -m "not live"

migration-smoke:
name: Migration smoke-test
runs-on: ubuntu-latest
services:
postgres:
# NOT the vanilla postgres image. Prod is a Supabase-managed cluster, so a plain
# postgres:17 lacks both the cluster roles the snapshot assigns ownership to
# (supabase_admin, supabase_auth_admin, anon, authenticated, service_role,
# dashboard_user) and the extensions its types come from — loading the snapshot there
# dies on line 18 with `role "supabase_admin" does not exist`.
#
# This tag is prod's EXACT version (17.4.1.052), not just its major: verified via
# `select version()` on zgkvbjqlvebttbnkklpo on 2026-07-24, and it is the same image
# `supabase db dump` runs to produce the snapshot. postgis/vector/pg_net all resolve
# to prod's exact versions here too. Re-verify and bump if prod is upgraded.
image: public.ecr.aws/supabase/postgres:17.4.1.052
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
PGHOST: localhost
PGPORT: 5432
# supabase_admin, not postgres: in this image `postgres` is a restricted role and the
# snapshot's first statements (ALTER SCHEMA auth OWNER TO ...) fail with
# `permission denied for schema auth`. supabase_admin is the actual superuser.
PGUSER: supabase_admin
PGPASSWORD: postgres
PGDATABASE: postgres
steps:
- uses: actions/checkout@v4
with:
# Full history: the apply step diffs against the base branch to find which
# migrations this PR adds. A shallow clone has no merge base.
fetch-depth: 0

- name: Install postgresql-client
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client

- name: Require schema snapshot
run: |
set -euo pipefail
if [ ! -f supabase/schema.snapshot.sql ]; then
echo "::error::supabase/schema.snapshot.sql is missing — this job cannot smoke-test migrations without a prod schema baseline."
echo "Generate and commit it with:"
echo " supabase db dump --schema auth,public,private -f supabase/schema.snapshot.sql"
echo "(requires the Supabase CLI authenticated as renaud@bluegreen.ai against project zgkvbjqlvebttbnkklpo — see docs/operations.md §1)."
exit 1
fi

- name: Drop the image's own auth schema
# The image ships a GoTrue-initialised `auth` schema older than prod's. The snapshot
# uses CREATE TABLE IF NOT EXISTS, so it would silently skip auth.users and then fail
# on `column "is_sso_user" ... does not exist`. Dropping it first makes the snapshot
# the sole source of the auth schema under test.
run: psql -v ON_ERROR_STOP=1 -c "DROP SCHEMA IF EXISTS auth CASCADE;"

- name: Install prod extensions
run: psql -v ON_ERROR_STOP=1 -f tests/fixtures/ci_extensions.sql

- name: Load prod schema snapshot
run: psql -v ON_ERROR_STOP=1 -f supabase/schema.snapshot.sql

- name: Apply migrations added by this PR
# NOT "the newest migration". The snapshot is a dump of prod, so it already contains
# every migration prod has applied — including the newest one on main. Replaying that
# fails with `relation ... already exists` on every run, red for a reason unrelated to
# any defect. What is genuinely unapplied is what THIS branch adds on top of the base.
run: |
set -euo pipefail
base_ref="${{ github.base_ref || github.event.repository.default_branch }}"
git fetch -q origin "$base_ref"
new=$(git diff --name-only "origin/$base_ref...HEAD" -- 'supabase/migrations/*.sql')
if [ -z "$new" ]; then
echo "No migrations added on this branch — nothing to smoke-test."
exit 0
fi
echo "Migrations to apply:"; echo "$new"
for f in $new; do
echo "::group::Applying $f"
psql -v ON_ERROR_STOP=1 -f "$f"
echo "::endgroup::"
done
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ hal/
│ ├── functions/
│ │ ├── hal-mcp/index.ts # THE runtime — 26 MCP tools
│ │ └── edifice-map-generator/ # IGN 2D map generation (pg_net trigger)
│ └── migrations/ # halcrm_* migrations (source of truth)
│ ├── migrations/ # halcrm_* migrations (source of truth)
│ └── schema.snapshot.sql # prod schema baseline for CI (see docs/operations.md)
├── scripts/
│ ├── generate_devis.py # /hal devis — docxtpl DOCX renderer (live)
Expand Down Expand Up @@ -281,6 +282,9 @@ make deploy # supabase functions deploy hal-mcp --n
# dashboard SQL editor. The Supabase CLI push is NOT safe on this shared
# partial-ledger DB; see docs/operations.md §1 for the runbook.

# CI migration smoke-test (migration-smoke job) replays the newest migration against
# a Postgres seeded with supabase/schema.snapshot.sql — see docs/operations.md §1.

# Edge Function tests (Deno)
deno test supabase/functions/hal-mcp/

Expand Down
62 changes: 62 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,68 @@ The DB is **shared** (edifice + hal). Each repo holds only its own migration fil
any change to `provision_workspace_membership()`, `trg_provision_workspace_membership`,
`workspace_members`, or `halcrm_workspaces.company_id`.

### CI migration smoke-test (`migration-smoke` job, issue #65)

Every PR applies **the migrations that branch adds** against an ephemeral service container
seeded with a committed snapshot of prod's real schema (`supabase/schema.snapshot.sql`). This
is the only net that catches DDL-only failures — bad FK/CHECK/type, missing column/table,
index/constraint violations, and the `42P17` class from PR #60 — *before* a human runs
`apply_migration` against the shared prod project. Neither `deno check` nor the offline
`pytest` suite exercises a real Postgres.

**Refresh the snapshot** whenever a migration lands (slow-moving — a re-dump alongside each
DDL migration is enough):

```bash
supabase db dump --schema auth,public,private -f supabase/schema.snapshot.sql
```

Requires the CLI authenticated as `renaud@bluegreen.ai` against `zgkvbjqlvebttbnkklpo` (same
auth prerequisite as §2 deploy below) **and a running Docker daemon** — the CLI shells out to
a `pg_dump` inside the `supabase/postgres` image rather than using the local one, because
`pg_dump` refuses to dump a server newer than itself.

Four things about this job are load-bearing and were each established by making the job fail
first. Change them only against evidence:

- **`--schema auth,public,private`, not `auth,public`.** The issue prescribed two schemas; the
snapshot then aborts at line 4852 on `schema "private" does not exist`, because objects in
`public` reference prod's `private` schema. If a future migration reaches into yet another
schema, this list grows — the symptom is always a `schema "X" does not exist` mid-load.
- **The service container is `public.ecr.aws/supabase/postgres:<prod's exact version>`, not
`postgres:17`.** A vanilla image has neither the cluster roles the snapshot assigns
ownership to (`supabase_admin`, `supabase_auth_admin`, `anon`, `authenticated`,
`service_role`, `dashboard_user`) nor the extensions its types come from; it dies on line 18
with `role "supabase_admin" does not exist`. The Supabase image carries prod's exact
postgis/vector/pg_net versions. Connect as **`supabase_admin`** — `postgres` is restricted
here and hits `permission denied for schema auth`.
- **The job drops the image's own `auth` schema first.** The image ships a GoTrue-initialised
`auth` older than prod's, and the snapshot uses `CREATE TABLE IF NOT EXISTS` — so it would
silently skip `auth.users` and fail later on `column "is_sso_user" ... does not exist`.
Dropping it makes the snapshot the only source of the schema under test.
- **It applies what the branch adds, never "the newest migration".** The snapshot is a dump of
prod, so it already contains every migration prod has applied — replaying the newest one
fails `relation ... already exists` on every single run. The job diffs against the base
branch (`git diff origin/<base>...HEAD -- 'supabase/migrations/*.sql'`), which is why
`actions/checkout` needs `fetch-depth: 0`. A branch adding no migration has nothing to
smoke-test and passes.

**Honest limitations:**

- The snapshot captures schema objects at dump time but **not** Supabase's internal GoTrue
auth-service migration history. Inconsequential for DDL on `auth.users` (this repo's only
case so far); would matter only for a migration depending on GoTrue's own internal state.
- Coverage depends on the refresh discipline above. A stale snapshot means the job validates
new migrations against an old prod — still useful, but no longer the guarantee it looks like.
- On a push directly to the default branch the diff is empty, so the job passes without
applying anything. The net is a **pull-request** net by design.

**Verified negative (issue #65 acceptance criterion), run 2026-07-24**: with the snapshot
loaded, a trigger re-introducing the pre-fix form —
`AFTER INSERT ... FOR EACH ROW WHEN (OLD.company_id IS DISTINCT FROM NEW.company_id)` — fails
with `INSERT trigger's WHEN condition cannot reference OLD values` and a non-zero exit. That is
the exact error that reached prod through PR #60.

---

## 2. Edge-function deploy
Expand Down
Loading
Loading