From 329248f1d8eacb0f8108178a0b09db6ccb6c9611 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:32:28 -0700 Subject: [PATCH 1/2] ci: apply pending migrations to staging on merge to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing applied them. Verified all four places it could have happened and none did: the backend image's CMD is a bare uvicorn, there is no Procfile or release step, main.py's lifespan does not migrate, and the Supabase GitHub integration reads `supabase/migrations/` — the CLI convention — which this repo does not have (only config.toml and snippets live under supabase/, and schema_paths is empty). That is why its check reports "skipping" on every PR: it is connected but has nothing it recognises. Migrations here are raw DDL under backend/db/migrations/ applied by db/migrate.py against its own ledger. So a merge shipped code whose schema had not moved, and someone had to remember to run it. #504 is live proof: it merged code that writes source='gradescope' while the CHECK still rejects that value until 0042 is applied. STAGING ONLY, deliberately. main deploys staging; prod is a separate `production` branch promotion, and auto-applying irreversible DDL to prod on merge is a different risk decision. This runner has no down migrations. Two safety properties, both exercised against the real local database rather than assumed: - No secret set -> notice + skip, so adding this file changes nothing until STAGING_SUPABASE_DB_URL exists. - Preflight refuses to apply on a drifted ledger: a missing schema_migrations table (the #317 shape) or any recorded-but-absent filename fails the job with the offending name, instead of pushing more DDL on top of a history the repo and database already disagree about. Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0), injected drift (exit 1, names the ghost row), and absent ledger (exit 1). The first draft queried a `version` column; the ledger's column is `filename`, which only the real-database test caught. Co-Authored-By: Claude Opus 5 --- .github/workflows/migrate-staging.yml | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/migrate-staging.yml diff --git a/.github/workflows/migrate-staging.yml b/.github/workflows/migrate-staging.yml new file mode 100644 index 00000000..c54f41b7 --- /dev/null +++ b/.github/workflows/migrate-staging.yml @@ -0,0 +1,86 @@ +# Apply pending migrations to STAGING when they land on main. +# +# Why this exists: nothing else applies them. The backend image's CMD is a bare +# uvicorn, there is no Procfile/release step, main.py's lifespan does not +# migrate, and the Supabase GitHub integration reads `supabase/migrations/` +# (the CLI convention) which this repo does not use — its migrations are raw +# DDL under backend/db/migrations/ applied by db/migrate.py against a +# `schema_migrations` ledger. So a merge shipped code whose schema had not +# moved, and someone had to remember to run the migration by hand. +# +# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a +# separate `production` branch promotion, and auto-applying irreversible DDL to +# prod on merge is a different risk decision. This runner has no down +# migrations. +name: Migrate (staging) + +on: + push: + branches: [main] + paths: + - "backend/db/migrations/**" + workflow_dispatch: + +concurrency: + # Never let two runs apply DDL to the same database at once. + group: migrate-staging + cancel-in-progress: false + +jobs: + migrate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install the runner's only dependency + run: pip install "psycopg[binary]" + + - name: Preflight — report ledger drift instead of pushing through it + id: preflight + env: + SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }} + run: | + if [ -z "${SUPABASE_DB_URL}" ]; then + echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + cd backend + python - <<'PY' + import os, sys, pathlib, psycopg + files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql")) + with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c: + exists = c.execute( + "SELECT to_regclass('public.schema_migrations') IS NOT NULL" + ).fetchone()[0] + if not exists: + print("::error::schema_migrations does not exist on this database. " + "Applying now would treat all migrations as pending and fail " + "recreating existing objects. Reconcile with `python -m db.migrate " + "--baseline` against a verified-current schema first (issue #317).") + sys.exit(1) + recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()} + pending = [f for f in files if f not in recorded] + orphans = sorted(recorded - set(files)) + print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}") + for p in pending: + print(f" pending: {p}") + if orphans: + # Recorded-but-absent means the ledger and the repo disagree about + # history — the #317 shape. Applying more on top compounds it. + for o in orphans: + print(f"::error::recorded but not in repo: {o}") + sys.exit(1) + PY + + - name: Apply + if: steps.preflight.outputs.skip != 'true' + env: + SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }} + run: | + cd backend + python -m db.migrate From 21926524c8aa0731cdc0dfeb352d2e153c859156 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:40:09 -0700 Subject: [PATCH 2/2] ci: restrict the migrate job to main; pin psycopg range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found a real hole. workflow_dispatch lets you pick ANY branch containing the workflow file, so a migration could be applied to shared staging straight from an unmerged branch, bypassing the push-to-main gate the whole design assumes. The bypass is not the worst part. The filename lands in schema_migrations, so if the file is then edited before merging — easy, since it was only "tested" — the merge never re-applies it, and staging silently diverges from the canonical file with NO pending/orphan signal, because the recorded filename still matches. That is precisely the immutability rule CLAUDE.md states, violated without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it. Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner can't silently drift onto a major the app has never run against. Co-Authored-By: Claude Opus 5 --- .github/workflows/migrate-staging.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/migrate-staging.yml b/.github/workflows/migrate-staging.yml index c54f41b7..85644276 100644 --- a/.github/workflows/migrate-staging.yml +++ b/.github/workflows/migrate-staging.yml @@ -29,6 +29,15 @@ concurrency: jobs: migrate: runs-on: ubuntu-latest + # workflow_dispatch lets you pick ANY branch containing this file, so + # without this a migration could be applied to shared staging straight from + # an unmerged branch — bypassing review. Worse than the bypass: the + # filename lands in the ledger, so if the file is then edited before merge + # (easy, since it was only "tested"), the merge never re-applies it and + # staging silently diverges from the canonical file with no pending/orphan + # signal to catch it. That is exactly the immutability rule CLAUDE.md + # states — migrations are immutable once applied. + if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 @@ -37,7 +46,9 @@ jobs: python-version: "3.12" - name: Install the runner's only dependency - run: pip install "psycopg[binary]" + # Range matches backend/requirements.txt so this can't drift onto a + # psycopg major the app has never run against. + run: pip install "psycopg[binary]>=3.2,<4" - name: Preflight — report ledger drift instead of pushing through it id: preflight