A push to master now builds and restarts the application on the server: a Gitea Actions workflow on a self-hosted runner writes .env from the repository secrets, applies pending migrations, rebuilds the compose stack against the host's Docker daemon, and waits for the container's healthcheck before calling the run green. Without that last step a deploy counts as successful the moment the container *starts*, even if the app inside it dies immediately. Switching migrations on automatically turned up something that had to be fixed first: supabase_migrations.schema_migrations did not exist at all. Every one of the 65 migrations was unrecorded, because they have been applied by hand all along. An automatic `db push` would therefore have replayed all 65 against the live database — initial_schema and the OM cutover included. The database was checked against a spread of migrations first (it is at head), then baselined: all 65 recorded as applied without executing them. The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs only `pg`, which the project already ships, instead of downloading a CLI whose version drifts independently of this repository; and it does one thing — the missing files, in order, each in its own transaction — where `db push` also diffs schemas and may do more than that. Bookkeeping goes in the same table in the same shape the CLI uses, so `supabase db push` from a workstation still works and still skips what already ran. The workflow lives in .github/workflows, not .gitea/. Gitea reads .gitea/workflows and falls back to .github/workflows only when the former is absent — creating .gitea/ would have silently switched off ci.yml, with the run simply never appearing. Verified: both workflow files parse; the secret check names what is missing and refuses; values starting with "-" or containing "=" survive being written to .env; and the runner was exercised against the real database with a throwaway migration — applied once, skipped on a second run, and on a deliberate syntax error rolled back whole, recording nothing. Both probes were removed; the count is back to 65. Not verified: nothing has run on an actual Gitea runner — none is registered yet. DEPLOYMENT.md §5 covers registering one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
5.9 KiB
JavaScript
149 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Spielt ausstehende Migrationen ein — und führt Buch darüber.
|
|
//
|
|
// ═══ Warum ein eigener Läufer und nicht `supabase db push` ═══
|
|
//
|
|
// Zwei Gründe, beide praktisch:
|
|
//
|
|
// 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Die
|
|
// Supabase-CLI müsste im Runner-Container bei jedem Lauf heruntergeladen
|
|
// werden, und ihre Version driftet unabhängig von diesem Repository.
|
|
// 2. **Er tut genau eine Sache.** `db push` vergleicht Schemata, erzeugt
|
|
// Diffs und kann bei einer Abweichung mehr tun als nur die fehlenden
|
|
// Dateien anzuwenden. Beim automatischen Ausrollen ist „nur die fehlenden
|
|
// Dateien, in Reihenfolge, jede in ihrer eigenen Transaktion" genau das
|
|
// gewünschte Verhalten und nichts darüber hinaus.
|
|
//
|
|
// Die Buchführung liegt bewusst in `supabase_migrations.schema_migrations` —
|
|
// derselben Tabelle in derselben Form, die die CLI benutzt. Damit bleibt
|
|
// `supabase db push` weiterhin möglich, etwa von der Arbeitsstation aus: es
|
|
// sieht dieselben Einträge und überspringt, was hier schon lief.
|
|
//
|
|
// ═══ Aufrufe ═══
|
|
//
|
|
// node scripts/migrate.mjs ausstehende anwenden
|
|
// node scripts/migrate.mjs --dry-run nur zeigen, was anstünde
|
|
// node scripts/migrate.mjs --baseline alles als angewendet verbuchen,
|
|
// **ohne** es auszuführen
|
|
//
|
|
// `--baseline` ist für den einen Fall gedacht, in dem eine Datenbank schon auf
|
|
// dem Stand ist, die Buchführung aber fehlt — genau die Lage dieses Projekts
|
|
// im August 2026, weil die Migrationen bis dahin von Hand eingespielt wurden.
|
|
// Ohne diesen Schritt hielte der Läufer alle 65 für ausstehend und würde sie
|
|
// gegen eine bereits migrierte Datenbank laufen lassen.
|
|
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import pg from "pg";
|
|
|
|
const VERZEICHNIS = "supabase/migrations";
|
|
|
|
const argumente = new Set(process.argv.slice(2));
|
|
const nurZeigen = argumente.has("--dry-run");
|
|
const baseline = argumente.has("--baseline");
|
|
|
|
// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus).
|
|
// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543),
|
|
// und der verträgt kein `create schema` in einer Transaktion mit mehreren
|
|
// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung.
|
|
const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL;
|
|
if (!verbindung) {
|
|
console.error(
|
|
"SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" +
|
|
"nicht der Transaktions-Pooler aus DATABASE_URL."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
/** Dateiname → { version, name }; „20260818100000_offboarding.sql". */
|
|
function zerlegen(datei) {
|
|
const punkt = datei.indexOf("_");
|
|
return punkt === -1
|
|
? { version: datei.replace(/\.sql$/, ""), name: "" }
|
|
: { version: datei.slice(0, punkt), name: datei.slice(punkt + 1).replace(/\.sql$/, "") };
|
|
}
|
|
|
|
const dateien = readdirSync(VERZEICHNIS)
|
|
.filter((f) => f.endsWith(".sql"))
|
|
.sort();
|
|
|
|
const client = new pg.Client({
|
|
connectionString: verbindung,
|
|
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
|
});
|
|
await client.connect();
|
|
|
|
try {
|
|
// Dieselbe Form, die die Supabase-CLI anlegt und erwartet.
|
|
await client.query(`create schema if not exists supabase_migrations`);
|
|
await client.query(`
|
|
create table if not exists supabase_migrations.schema_migrations (
|
|
version text primary key,
|
|
statements text[],
|
|
name text
|
|
)`);
|
|
|
|
const verbucht = new Set(
|
|
(await client.query(`select version from supabase_migrations.schema_migrations`)).rows.map((r) => r.version)
|
|
);
|
|
|
|
const ausstehend = dateien.filter((f) => !verbucht.has(zerlegen(f).version));
|
|
|
|
if (ausstehend.length === 0) {
|
|
console.log(`Nichts anzuwenden — ${verbucht.size} Migrationen verbucht, ${dateien.length} Dateien vorhanden.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (nurZeigen) {
|
|
console.log(`${ausstehend.length} ausstehend:`);
|
|
for (const f of ausstehend) console.log(" " + f);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (baseline) {
|
|
// Nur verbuchen. Der Inhalt wird trotzdem mitgeschrieben, damit später
|
|
// nachvollziehbar ist, welcher Text als angewendet galt.
|
|
for (const datei of ausstehend) {
|
|
const { version, name } = zerlegen(datei);
|
|
const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8");
|
|
await client.query(
|
|
`insert into supabase_migrations.schema_migrations (version, statements, name)
|
|
values ($1, $2, $3) on conflict (version) do nothing`,
|
|
[version, [inhalt], name]
|
|
);
|
|
}
|
|
console.log(`${ausstehend.length} Migrationen als angewendet verbucht, ohne sie auszuführen.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(`${ausstehend.length} ausstehend, werden angewendet:`);
|
|
for (const datei of ausstehend) {
|
|
const { version, name } = zerlegen(datei);
|
|
const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8");
|
|
const start = Date.now();
|
|
|
|
// Jede Datei in ihrer eigenen Transaktion: eine, die scheitert, hinterlässt
|
|
// nichts Halbes, und die davor bleiben angewendet und verbucht. Der Lauf
|
|
// bricht danach ab — weiterzumachen hiesse, auf einem Stand aufzubauen,
|
|
// den es nicht gibt.
|
|
await client.query("begin");
|
|
try {
|
|
await client.query(inhalt);
|
|
await client.query(
|
|
`insert into supabase_migrations.schema_migrations (version, statements, name) values ($1, $2, $3)`,
|
|
[version, [inhalt], name]
|
|
);
|
|
await client.query("commit");
|
|
console.log(` ok ${datei} (${Date.now() - start} ms)`);
|
|
} catch (fehler) {
|
|
await client.query("rollback");
|
|
console.error(` FEHLGESCHLAGEN ${datei}`);
|
|
console.error(" " + (fehler instanceof Error ? fehler.message : String(fehler)));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
console.log("Fertig.");
|
|
} finally {
|
|
await client.end();
|
|
}
|