ci: a merge to prod releases the control plane, migrator included

`release-prod.yml` for api and auth, mirroring the edge's. The gate is not
"you may not merge" — it is that merging does not deploy: tests run against a
real Postgres, both binaries are executed, and a failure anywhere means no
release exists, so the machine keeps serving what it has.

The new piece is `nestri-migrate`, because the deploy runs migrations *before*
it swaps a release into place and had nothing to run.

`drizzle-kit migrate` reads the migrations folder at runtime, which is right on
a laptop and wrong on a server: the deploy ships flat, checksummed files into
bin/, and a migrator that needs a directory beside it can be pointed at the
wrong directory. So the folder is baked into the binary — generated on every
build rather than committed, so it cannot drift — and the artefact's checksum
then covers every statement it will run.

It reimplements drizzle's bookkeeping in thirty lines of SQL rather than
calling into `db.dialect.migrate`: same table, same schema, same sha256 over
the whole file, same high-water-mark comparison. That equivalence is the one
thing that must not rot, because the production database was first migrated by
drizzle-kit and a disagreement means a migration applied twice. Two checks
hold it: CI applies the migrations with drizzle-kit and then asserts the
embedded set reports nothing pending, and the hashes were verified by hand
against the live database — all fourteen match to the byte.

Proven before shipping, against the real database: nothing pending on the
deployed schema, 14 unchanged rows, and a scratch database migrated from empty
to the same 22 tables and the same high-water mark, idempotent on a second run.
Refuses with exit 2 when DATABASE_URL is absent rather than defaulting to
localhost, which would be a migrator reporting success having migrated nothing.

setup-bun is pinned to a commit and not to `v2`. A moving major tag is fine
everywhere else in this repository; this workflow is the only thing between a
merge and a process serving users, and there is no first-party bun action to
prefer instead.
This commit is contained in:
Wanjohi
2026-09-17 01:44:16 +03:00
parent b00f1064ae
commit 4668c98785
4 changed files with 448 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
/**
* Bake `migrations/` into a TypeScript module so the migrator can be a single
* self-contained binary.
*
* `drizzle-kit migrate` reads the folder at runtime, which is the right shape
* on a laptop and the wrong one on a server: the deploy agent ships flat,
* checksummed files into `bin/`, and a migrator that needs a directory beside
* it is a migrator that can be run against the wrong directory. Embedding
* removes the question — the binary *is* the migration set, and its checksum
* covers every statement it will run.
*
* Run before `bun build --compile`. It runs on every build rather than being
* committed, so the embedded set cannot drift from the folder.
*
* bun run packages/core/scripts/embed-migrations.ts
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const here = path.dirname(new URL(import.meta.url).pathname);
const folder = path.join(here, '..', 'migrations');
const out = path.join(here, '..', 'src', 'migrations.generated.ts');
type JournalEntry = { idx: number; when: number; tag: string; breakpoints: boolean };
const journal = JSON.parse(
fs.readFileSync(path.join(folder, 'meta', '_journal.json'), 'utf8')
) as { entries: JournalEntry[] };
// Deliberately identical to drizzle-orm's own `readMigrationFiles`: the same
// `--> statement-breakpoint` split, the same sha256 over the whole file, the
// same `when` as the ordering key. The live database was migrated by
// drizzle-kit, so anything else here would re-apply migrations that have
// already run.
const migrations = journal.entries.map((e) => {
const sql = fs.readFileSync(path.join(folder, `${e.tag}.sql`), 'utf8');
return {
tag: e.tag,
sql: sql.split('--> statement-breakpoint'),
bps: e.breakpoints,
folderMillis: e.when,
hash: crypto.createHash('sha256').update(sql).digest('hex')
};
});
fs.writeFileSync(
out,
`// Generated by scripts/embed-migrations.ts. Do not edit.\n` +
`// ${migrations.length} migrations, ${migrations.at(-1)?.tag ?? 'none'} last.\n` +
`export type Embedded = {\n` +
`\ttag: string;\n\tsql: string[];\n\tbps: boolean;\n\tfolderMillis: number;\n\thash: string;\n};\n\n` +
`export const MIGRATIONS: Embedded[] = ${JSON.stringify(migrations, null, '\t')};\n`
);
console.log(`embedded ${migrations.length} migrations -> ${path.relative(process.cwd(), out)}`);