diff --git a/.github/scripts/check-pr-migration-order.mjs b/.github/scripts/check-pr-migration-order.mjs new file mode 100644 index 0000000000..b67dfb3fab --- /dev/null +++ b/.github/scripts/check-pr-migration-order.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const MIGRATIONS_DIRECTORY = 'packages/db/src/migrations/'; +const MIGRATION_FILE_PATTERN = /^packages\/db\/src\/migrations\/(\d{4})_[^/]+\.sql$/; + +function parseMigration(file) { + const match = file.match(MIGRATION_FILE_PATTERN); + return match ? { file, number: Number.parseInt(match[1], 10) } : null; +} + +function formatMigrationNumber(number) { + return String(number).padStart(4, '0'); +} + +export function checkMigrationOrder(baseMigrationFiles, prMigrationFiles) { + const invalidFiles = [...baseMigrationFiles, ...prMigrationFiles] + .filter((file) => !parseMigration(file)); + + if (invalidFiles.length > 0) { + return { + passed: false, + message: [ + 'Migration SQL files must start with a 4-digit number:', + ...invalidFiles.map((file) => `- ${file}`), + ].join('\n'), + }; + } + + if (prMigrationFiles.length === 0) { + return { passed: true, message: 'No new migrations in this PR.' }; + } + + const baseMigrations = baseMigrationFiles.map(parseMigration); + const prMigrations = prMigrationFiles.map(parseMigration); + const latestBaseMigration = baseMigrations.reduce( + (latest, migration) => migration.number > latest.number ? migration : latest, + { file: '(none)', number: -1 }, + ); + const outOfOrder = prMigrations.filter( + (migration) => migration.number <= latestBaseMigration.number, + ); + + if (outOfOrder.length === 0) { + return { + passed: true, + message: `All new migrations follow ${latestBaseMigration.file}.`, + }; + } + + const nextNumber = formatMigrationNumber(latestBaseMigration.number + 1); + return { + passed: false, + message: [ + `The target branch already contains migrations through ${latestBaseMigration.file}.`, + 'This PR adds migration numbers that would be inserted into or collide with that history:', + ...outOfOrder.map((migration) => `- ${migration.file}`), + '', + `Update from the target branch, then renumber this PR's migrations starting at ${nextNumber}`, + 'in their intended order. Keep each SQL filename, matching meta snapshot, and', + 'packages/db/src/migrations/meta/_journal.json entry aligned, then push again.', + 'Migration numbers are append-only and cannot reuse a number already present on the target branch.', + ].join('\n'), + }; +} + +function gitPaths(args) { + return execFileSync('git', args, { encoding: 'utf8' }) + .split('\0') + .filter(Boolean); +} + +function escapeWorkflowCommand(message) { + return message + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); +} + +function main() { + const [baseSha, headSha] = process.argv.slice(2); + const shaPattern = /^[0-9a-f]{40}$/i; + if (!shaPattern.test(baseSha ?? '') || !shaPattern.test(headSha ?? '')) { + console.error('Usage: check-pr-migration-order.mjs <40-character base SHA> <40-character head SHA>'); + process.exit(2); + } + + const baseMigrationFiles = gitPaths([ + 'ls-tree', '-r', '--name-only', '-z', baseSha, '--', MIGRATIONS_DIRECTORY, + ]).filter((file) => file.endsWith('.sql')); + const prMigrationFiles = gitPaths([ + 'diff', '--name-only', '--diff-filter=A', '-z', `${baseSha}...${headSha}`, '--', + MIGRATIONS_DIRECTORY, + ]).filter((file) => file.endsWith('.sql')); + const result = checkMigrationOrder(baseMigrationFiles, prMigrationFiles); + + if (result.passed) { + console.log(result.message); + return; + } + + console.error( + `::error title=Migration numbers must follow the target branch::${escapeWorkflowCommand(result.message)}`, + ); + console.error(result.message); + process.exit(1); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/.github/scripts/tests/check-pr-migration-order.test.mjs b/.github/scripts/tests/check-pr-migration-order.test.mjs new file mode 100644 index 0000000000..48c1b2db8f --- /dev/null +++ b/.github/scripts/tests/check-pr-migration-order.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkMigrationOrder } from '../check-pr-migration-order.mjs'; + +const migration = (name) => `packages/db/src/migrations/${name}.sql`; + +test('passes when a PR has no new migrations', () => { + const result = checkMigrationOrder([migration('0230_on_master')], []); + + assert.equal(result.passed, true); +}); + +test('passes when every PR migration follows the target branch', () => { + const result = checkMigrationOrder( + [migration('0230_on_master')], + [migration('0231_first_in_pr'), migration('0232_second_in_pr')], + ); + + assert.equal(result.passed, true); +}); + +test('fails with renumbering guidance when a PR reuses the target branch number', () => { + const result = checkMigrationOrder( + [migration('0230_on_master')], + [migration('0230_from_stale_branch')], + ); + + assert.equal(result.passed, false); + assert.match(result.message, /already contains migrations through .*0230_on_master\.sql/); + assert.match(result.message, /renumber this PR's migrations starting at 0231/); + assert.match(result.message, /meta\/_journal\.json/); +}); + +test('fails when a PR inserts a migration before the target branch tip', () => { + const result = checkMigrationOrder( + [migration('0230_on_master')], + [migration('0229_from_stale_branch'), migration('0231_valid_but_after_stale')], + ); + + assert.equal(result.passed, false); + assert.match(result.message, /0229_from_stale_branch\.sql/); + assert.doesNotMatch(result.message, /- packages\/db\/src\/migrations\/0231_valid_but_after_stale\.sql/); +}); diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e356bb8a09..f80958c7f1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -46,6 +46,12 @@ jobs: with: node-version: 24 + - name: Validate migration ordering against target branch + run: >- + node .github/scripts/check-pr-migration-order.mjs + "${{ github.event.pull_request.base.sha }}" + "${{ github.event.pull_request.head.sha }}" + - name: Validate Dockerfile deps stage run: node ./scripts/check-docker-deps-stage.mjs