From adbdfd3e900ba6ec957a5b5b4ce7ddc662e86594 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 11 May 2026 13:33:44 -0500 Subject: [PATCH] Fix CI migration step: extract Python to scripts/run_migrations.py go-yaml (Gitea's parser) mishandles << inside block scalars, treating the bash heredoc delimiter as a YAML merge key. Move the migration logic to a standalone script called via python3 scripts/run_migrations.py. --- .gitea/workflows/deploy.yaml | 56 +------------------ .gitea/workflows/stage.yaml | 56 +------------------ scripts/run_migrations.py | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 110 deletions(-) create mode 100644 scripts/run_migrations.py diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 5bb01bf..ab2137c 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -85,61 +85,7 @@ jobs: # Applies any pending migrations in migrations/*.sql to the Supabase DB. # Runs unconditionally (asset-only or full build) so the schema is always # current before the new code is deployed. - run: | - set -euo pipefail - python3 - << 'PYEOF' -import json, glob, subprocess, sys, os - -def gcloud_secret(name): - return subprocess.check_output([ - 'gcloud', 'secrets', 'versions', 'access', 'latest', - f'--secret={name}', '--project=neuron-785695' - ], text=True).strip() - -access_token = gcloud_secret('supabase-access-token') -project_id = 'ocojsghaonltunidkzpw' -api_url = f'https://api.supabase.com/v1/projects/{project_id}/database/query' - -def query(sql): - r = subprocess.run([ - 'curl', '-sf', '-X', 'POST', api_url, - '-H', f'Authorization: Bearer {access_token}', - '-H', 'Content-Type: application/json', - '-d', json.dumps({'query': sql}) - ], capture_output=True, text=True) - if r.returncode != 0: - raise RuntimeError(f'curl failed: {r.stderr}') - resp = json.loads(r.stdout) - if isinstance(resp, dict) and resp.get('message') and not isinstance(resp.get('message'), list): - raise RuntimeError(f'DB error: {resp}') - return resp - -query(""" -CREATE TABLE IF NOT EXISTS schema_migrations ( - id text PRIMARY KEY, - applied_at timestamptz DEFAULT now() -) -""") - -applied = {row['id'] for row in query('SELECT id FROM schema_migrations')} -print(f'Already applied: {sorted(applied)}') - -pending = [p for p in sorted(glob.glob('migrations/*.sql')) - if os.path.basename(p) not in applied] - -if not pending: - print('No pending migrations.') - sys.exit(0) - -for path in pending: - name = os.path.basename(path) - print(f'Applying {name}...') - query(open(path).read()) - query(f"INSERT INTO schema_migrations (id) VALUES ('{name}')") - print(f'Applied {name}') - -print(f'Done. Applied {len(pending)} migration(s).') -PYEOF + run: python3 scripts/run_migrations.py - name: Configure docker auth for Artifact Registry run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet diff --git a/.gitea/workflows/stage.yaml b/.gitea/workflows/stage.yaml index 8ccd796..2430c5e 100644 --- a/.gitea/workflows/stage.yaml +++ b/.gitea/workflows/stage.yaml @@ -102,61 +102,7 @@ jobs: # Applies any pending migrations in migrations/*.sql to the Supabase DB. # Runs unconditionally (asset-only or full build) so the schema is always # current before the new code is deployed. - run: | - set -euo pipefail - python3 - << 'PYEOF' -import json, glob, subprocess, sys, os - -def gcloud_secret(name): - return subprocess.check_output([ - 'gcloud', 'secrets', 'versions', 'access', 'latest', - f'--secret={name}', '--project=neuron-785695' - ], text=True).strip() - -access_token = gcloud_secret('supabase-access-token') -project_id = 'ocojsghaonltunidkzpw' -api_url = f'https://api.supabase.com/v1/projects/{project_id}/database/query' - -def query(sql): - r = subprocess.run([ - 'curl', '-sf', '-X', 'POST', api_url, - '-H', f'Authorization: Bearer {access_token}', - '-H', 'Content-Type: application/json', - '-d', json.dumps({'query': sql}) - ], capture_output=True, text=True) - if r.returncode != 0: - raise RuntimeError(f'curl failed: {r.stderr}') - resp = json.loads(r.stdout) - if isinstance(resp, dict) and resp.get('message') and not isinstance(resp.get('message'), list): - raise RuntimeError(f'DB error: {resp}') - return resp - -query(""" -CREATE TABLE IF NOT EXISTS schema_migrations ( - id text PRIMARY KEY, - applied_at timestamptz DEFAULT now() -) -""") - -applied = {row['id'] for row in query('SELECT id FROM schema_migrations')} -print(f'Already applied: {sorted(applied)}') - -pending = [p for p in sorted(glob.glob('migrations/*.sql')) - if os.path.basename(p) not in applied] - -if not pending: - print('No pending migrations.') - sys.exit(0) - -for path in pending: - name = os.path.basename(path) - print(f'Applying {name}...') - query(open(path).read()) - query(f"INSERT INTO schema_migrations (id) VALUES ('{name}')") - print(f'Applied {name}') - -print(f'Done. Applied {len(pending)} migration(s).') -PYEOF + run: python3 scripts/run_migrations.py - name: Configure docker auth for Artifact Registry run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet diff --git a/scripts/run_migrations.py b/scripts/run_migrations.py new file mode 100644 index 0000000..c846e92 --- /dev/null +++ b/scripts/run_migrations.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +run_migrations.py — apply pending Supabase migrations via the Management API. + +Reads SUPABASE_ACCESS_TOKEN from env (injected by CI from GCP Secret Manager). +Migrations are tracked in a schema_migrations table (created if absent). +Files in migrations/*.sql are applied in lexicographic order; already-applied +files are skipped (idempotent). +""" + +import json +import glob +import os +import subprocess +import sys + +ACCESS_TOKEN = os.environ.get("SUPABASE_ACCESS_TOKEN", "") +if not ACCESS_TOKEN: + # Fall back to fetching from GCP Secret Manager (for use in CI without + # env var pre-injection). + result = subprocess.run( + [ + "gcloud", + "secrets", + "versions", + "access", + "latest", + "--secret=supabase-access-token", + "--project=neuron-785695", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"ERROR: could not fetch supabase-access-token: {result.stderr}", file=sys.stderr) + sys.exit(1) + ACCESS_TOKEN = result.stdout.strip() + +PROJECT_ID = "ocojsghaonltunidkzpw" +API_URL = f"https://api.supabase.com/v1/projects/{PROJECT_ID}/database/query" + + +def query(sql: str): + r = subprocess.run( + [ + "curl", + "-sf", + "-X", + "POST", + API_URL, + "-H", + f"Authorization: Bearer {ACCESS_TOKEN}", + "-H", + "Content-Type: application/json", + "-d", + json.dumps({"query": sql}), + ], + capture_output=True, + text=True, + ) + if r.returncode != 0: + raise RuntimeError(f"curl failed: {r.stderr}") + resp = json.loads(r.stdout) + # The Management API returns a list of rows on success, or a dict with + # "message" on error. + if isinstance(resp, dict) and resp.get("message") and not isinstance(resp.get("message"), list): + raise RuntimeError(f"DB error: {resp}") + return resp + + +# Ensure tracking table exists. +query( + """ +CREATE TABLE IF NOT EXISTS schema_migrations ( + id text PRIMARY KEY, + applied_at timestamptz DEFAULT now() +) +""" +) + +applied = {row["id"] for row in query("SELECT id FROM schema_migrations")} +print(f"Already applied: {sorted(applied)}") + +pending = [ + p + for p in sorted(glob.glob("migrations/*.sql")) + if os.path.basename(p) not in applied +] + +if not pending: + print("No pending migrations.") + sys.exit(0) + +for path in pending: + name = os.path.basename(path) + print(f"Applying {name}...") + with open(path) as f: + sql = f.read() + query(sql) + query(f"INSERT INTO schema_migrations (id) VALUES ('{name}')") + print(f"Applied {name}") + +print(f"Done. Applied {len(pending)} migration(s).") -- 2.52.0