diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 1c0a439..5bb01bf 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -10,6 +10,7 @@ on: - 'src/**' - 'dist/**' - 'runtime/**' + - 'migrations/**' - 'Dockerfile.stage' - 'build-stage.sh' - '.gitea/workflows/deploy.yaml' @@ -80,6 +81,66 @@ jobs: with: project_id: neuron-785695 + - name: Run database migrations + # 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 + - 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 19d3724..8ccd796 100644 --- a/.gitea/workflows/stage.yaml +++ b/.gitea/workflows/stage.yaml @@ -12,6 +12,7 @@ on: - 'dist/**' - 'runtime/**' - 'tests/**' + - 'migrations/**' - 'playwright.config.ts' - 'package.json' - 'Dockerfile.stage' @@ -97,6 +98,66 @@ jobs: with: project_id: neuron-785695 + - name: Run database migrations + # 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 + - name: Configure docker auth for Artifact Registry run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet