Skip to content

Commit fd00e2c

Browse files
Merge pull request #1163 from mikewheeleer/codex/issue-1148-callora-migrations
[#1148] Make migration drift protection blocking and explicit
2 parents 909488d + 3715534 commit fd00e2c

5 files changed

Lines changed: 143 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ on:
77
branches: ["main", "develop"]
88

99
jobs:
10+
migration-policy:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v4
15+
- name: Setup Node.js
16+
uses: actions/setup-node@v4
17+
with:
18+
node-version: 20
19+
cache: npm
20+
- name: Install dependencies
21+
run: npm ci
22+
- name: Verify migration layout and schema drift
23+
run: npx tsx scripts/check-migrations.ts
24+
env:
25+
CHECKSUM_CI_SKIP_MISSING: "1"
26+
1027
build:
1128
runs-on: ubuntu-latest
1229
continue-on-error: true
@@ -65,7 +82,6 @@ jobs:
6582
echo "✅ Build artifacts verified"
6683
6784
- name: Run Schema Versioning Check
68-
continue-on-error: true
6985
run: npx tsx scripts/check-migrations.ts
7086
env:
7187
CHECKSUM_CI_SKIP_MISSING: "1"

migrations/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,18 @@ Roll back in **reverse** order (highest prefix first).
7676
4. Run `npm test -- src/migrate.runner.test.ts` to verify the runner still passes.
7777
5. Run `npm run db:check-migrations` to verify the checksum gate passes.
7878
6. Commit both migration files.
79+
80+
### CI policy for new migrations
81+
82+
Historical migrations through `0021` are frozen because their names and
83+
versions are already recorded in deployed databases. New migrations must start
84+
at `0022`, use a unique four-digit prefix, and continue without gaps. A new
85+
forward migration containing `DROP`, `TRUNCATE`, or `DELETE FROM` must include
86+
an explicit approval marker on its own line:
87+
88+
```sql
89+
-- destructive-approved: #1148
90+
```
91+
92+
The schema-versioning CI step runs this layout check even when no local
93+
database exists, and its result is blocking.

scripts/check-migrations.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env tsx
22
/**
3-
* check-migrations.ts Schema Versioning CI Gate
3+
* check-migrations.ts Schema Versioning CI Gate
44
*
55
* Verifies that every migration file on disk matches its recorded checksum in the
66
* schema_versions table. Any mismatch means a migration was modified *after* being
@@ -20,6 +20,7 @@ import Database from 'better-sqlite3';
2020
import { readFileSync, readdirSync, existsSync } from 'fs';
2121
import path from 'path';
2222
import { createHash } from 'node:crypto';
23+
import { validateMigrationLayout } from './migrationPolicy.js';
2324

2425
const rootDir = process.cwd();
2526
const dbPath = path.join(rootDir, 'database.db');
@@ -41,6 +42,12 @@ function main() {
4142
console.log('Schema Versioning Drift Check');
4243
console.log('================================');
4344
console.log('');
45+
const layoutErrors = validateMigrationLayout(migrationDir);
46+
if (layoutErrors.length > 0) {
47+
console.error('Migration layout policy failed:');
48+
layoutErrors.forEach(function(error) { console.error(' ' + error); });
49+
process.exit(1);
50+
}
4451
if (!existsSync(dbPath)) {
4552
console.log('No database file found. Skipping checksum verification.');
4653
console.log('(Expected on fresh checkout before running migrations.)');
@@ -112,4 +119,4 @@ function main() {
112119
process.exit(0);
113120
} finally { db.close(); }
114121
}
115-
main();
122+
main();

scripts/migrationPolicy.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { describe, expect, it } from '@jest/globals';
5+
import { validateMigrationLayout } from './migrationPolicy.js';
6+
7+
function withMigrations(files: Record<string, string>, test: (dir: string) => void): void {
8+
const dir = mkdtempSync(path.join(os.tmpdir(), 'callora-migrations-'));
9+
try {
10+
Object.entries(files).forEach(([name, body]) => writeFileSync(path.join(dir, name), body));
11+
test(dir);
12+
} finally {
13+
rmSync(dir, { recursive: true, force: true });
14+
}
15+
}
16+
17+
describe('migration layout policy', () => {
18+
it('accepts a new contiguous migration after the frozen history', () => {
19+
withMigrations({ '0022_add_limits.sql': 'CREATE TABLE limits (id INTEGER);' }, (dir) => {
20+
expect(validateMigrationLayout(dir)).toEqual([]);
21+
});
22+
});
23+
24+
it('rejects duplicate, gapped, and unnumbered new migrations', () => {
25+
withMigrations({
26+
'0022_first.sql': 'SELECT 1;',
27+
'0022_second.sql': 'SELECT 1;',
28+
'0024_gap.sql': 'SELECT 1;',
29+
'new_feature.sql': 'SELECT 1;',
30+
}, (dir) => {
31+
const errors = validateMigrationLayout(dir).join('\n');
32+
expect(errors).toContain('Duplicate new migration prefix 22');
33+
expect(errors).toContain('new_feature.sql');
34+
expect(errors).toContain('sequence must continue');
35+
});
36+
});
37+
38+
it('requires an issue marker for destructive SQL', () => {
39+
withMigrations({ '0022_remove_legacy.sql': 'DROP TABLE legacy;' }, (dir) => {
40+
expect(validateMigrationLayout(dir).join('\n')).toContain('destructive-approved');
41+
});
42+
withMigrations({ '0022_remove_legacy.sql': '-- destructive-approved: #1148\nDROP TABLE legacy;' }, (dir) => {
43+
expect(validateMigrationLayout(dir)).toEqual([]);
44+
});
45+
});
46+
});

scripts/migrationPolicy.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { readdirSync, readFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
// Historical migrations use two numbering schemes and contain deployed
5+
// duplicate prefixes. They are frozen; new migrations begin at 0022.
6+
const LEGACY_MAX_PREFIX = 21;
7+
const LEGACY_UNNUMBERED = new Set([
8+
'add_refresh_token_family.sql', 'add_refresh_tokens.down.sql', 'add_refresh_tokens.sql',
9+
'auth_index.down.sql', 'auth_index.sql', 'billing_index.down.sql', 'billing_index.sql',
10+
'credits_index.down.sql', 'credits_index.sql',
11+
]);
12+
13+
function prefix(filename: string): number | null {
14+
const match = filename.match(/^(\d{4})_/);
15+
return match ? Number(match[1]) : null;
16+
}
17+
18+
function isUpMigration(filename: string): boolean {
19+
return (filename.endsWith('.sql') || filename.endsWith('.up.sql')) && !filename.endsWith('.down.sql');
20+
}
21+
22+
/** Return policy violations without mutating the migration directory. */
23+
export function validateMigrationLayout(migrationDir: string): string[] {
24+
const files = readdirSync(migrationDir).filter(isUpMigration);
25+
const violations: string[] = [];
26+
const future = files.filter((file) => {
27+
const number = prefix(file);
28+
return number !== null ? number > LEGACY_MAX_PREFIX : !LEGACY_UNNUMBERED.has(file);
29+
});
30+
31+
for (const file of future) {
32+
const number = prefix(file);
33+
if (number === null) {
34+
violations.push(`Migration file "${file}" must use NNNN_description.sql naming.`);
35+
continue;
36+
}
37+
if (!/^\d{4}_[a-z0-9][a-z0-9_-]*\.sql$/.test(file) && !/^\d{4}_[a-z0-9][a-z0-9_-]*\.up\.sql$/.test(file)) {
38+
violations.push(`Migration file "${file}" must use four digits and a lowercase description.`);
39+
}
40+
const content = readFileSync(path.join(migrationDir, file), 'utf8');
41+
if (/\b(?:DROP|TRUNCATE)\b|\bDELETE\s+FROM\b/i.test(content) && !/^\s*--\s*destructive-approved:\s*#[0-9]+\s*$/im.test(content)) {
42+
violations.push(`Destructive migration "${file}" requires -- destructive-approved: #<issue>.`);
43+
}
44+
}
45+
46+
const numbers = future.map(prefix).filter((number): number is number => number !== null).sort((a, b) => a - b);
47+
for (let index = 0; index < numbers.length; index += 1) {
48+
const expected = LEGACY_MAX_PREFIX + 1 + index;
49+
if (numbers[index] !== expected) {
50+
violations.push(`Migration sequence must continue at ${String(expected).padStart(4, '0')}; found ${String(numbers[index]).padStart(4, '0')}.`);
51+
break;
52+
}
53+
if (index > 0 && numbers[index] === numbers[index - 1]) violations.push(`Duplicate new migration prefix ${numbers[index]}.`);
54+
}
55+
return violations;
56+
}

0 commit comments

Comments
 (0)