#!/usr/bin/env node import { execFileSync, spawnSync } from 'node:child_process' import { lstatSync, readFileSync } from 'node:fs' import { basename } from 'node:path' const migrationDirectory = 'apps/api/migrations/' const baselineFile = 'deploy/ci/production-migration-base' const base = process.argv[2] || process.env.PRODUCTION_MIGRATION_BASE_SHA || '' const immutableRef = process.argv[3] || process.env.MIGRATION_IMMUTABILITY_REF || '' function fail(message) { console.error(`migration_safety=FAIL ${message}`) process.exit(1) } function git(args, options = {}) { try { return execFileSync('git', args, { encoding: options.encoding ?? 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) } catch (error) { const stderr = error.stderr?.toString().trim() fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed') } } if (!/^[0-9a-f]{40}$/.test(base)) { fail('PRODUCTION_MIGRATION_BASE_SHA must be a full lowercase commit SHA') } const root = git(['rev-parse', '--show-toplevel']).trim() process.chdir(root) const resolvedBase = git([ 'rev-parse', '--verify', '--end-of-options', `${base}^{commit}`, ]).trim() if (resolvedBase !== base) { fail('production migration baseline did not resolve exactly') } const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], { stdio: 'ignore', }) if (ancestry.status !== 0) { fail('production migration baseline is not an ancestor of HEAD') } const head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim() const baselineTreeEntry = git(['ls-tree', 'HEAD', '--', baselineFile]).trim() let baselineState try { baselineState = lstatSync(baselineFile) } catch { fail('production migration baseline file is unavailable in the worktree') } if ( !/^100644 blob [0-9a-f]+\t/.test(baselineTreeEntry) || !baselineState.isFile() || baselineState.isSymbolicLink() ) { fail('production migration baseline must be a mode 100644 regular file') } if (readFileSync(baselineFile, 'utf8').trim() !== base) { fail('production migration baseline argument does not match the reviewed file') } function changedMigrations(from) { const rawChanges = git( [ 'diff', '--name-status', '-z', '--no-renames', `${from}..HEAD`, '--', migrationDirectory, ], { encoding: null }, ) const fields = rawChanges.toString('utf8').split('\0') if (fields.at(-1) === '') fields.pop() if (fields.length % 2 !== 0) { fail('could not parse changed migration paths') } return fields } const fields = changedMigrations(base) function executableSql(sql, source = 'SQL input') { let output = '' for (let index = 0; index < sql.length; index += 1) { const current = sql[index] const next = sql[index + 1] if (current === '-' && next === '-') { index += 2 while (index < sql.length && sql[index] !== '\n') index += 1 output += '\n' continue } if (current === '/' && next === '*') { let depth = 1 index += 2 while (index < sql.length && depth > 0) { if (sql[index] === '/' && sql[index + 1] === '*') { depth += 1 index += 2 continue } if (sql[index] === '*' && sql[index + 1] === '/') { depth -= 1 index += 2 continue } index += 1 } if (depth !== 0) fail('unterminated SQL block comment') index -= 1 output += ' ' continue } if (current === "'" || current === '"') { const prefix = sql.slice(Math.max(0, index - 2), index).toUpperCase() const previous = sql[index - 1]?.toUpperCase() const beforePrevious = sql[index - 2] || '' const beforeUnicodePrefix = sql[index - 3] || '' const escapeString = current === "'" && previous === 'E' && !/[A-Za-z0-9_$]/.test(beforePrevious) const unicodeString = prefix === 'U&' && !/[A-Za-z0-9_$]/.test(beforeUnicodePrefix) if (escapeString || unicodeString) { fail(`${source}: PostgreSQL escape/unicode strings are not allowed in migrations`) } const quote = current index += 1 let closed = false while (index < sql.length) { if (sql[index] === quote && sql[index + 1] === quote) { index += 2 continue } if (sql[index] === quote) { closed = true break } index += 1 } if (!closed) fail('unterminated SQL quoted value') output += ' ' continue } if (current === '$') { const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0] if (delimiter) { const bodyStart = index + delimiter.length const bodyEnd = sql.indexOf(delimiter, bodyStart) if (bodyEnd === -1) fail('unterminated SQL dollar-quoted body') // PostgreSQL function bodies may contain executable DDL. Scan their // contents recursively instead of treating them as inert strings. const bodySql = executableSql(sql.slice(bodyStart, bodyEnd), source) // A bare END terminates a PL/pgSQL block inside a dollar-quoted body, // but at the top level PostgreSQL treats END as COMMIT. Mask only the // block terminator here so the top-level transaction rule stays exact. const bodyWithoutBlockEnd = bodySql .split(';') .map((statement) => (/^\s*END\s*$/i.test(statement) ? ' ' : statement)) .join(';') output += ` ${bodyWithoutBlockEnd} ` index = bodyEnd + delimiter.length - 1 continue } } output += current } return output } const destructiveRules = [ { name: 'procedural SQL body', pattern: /^\s*(?:DO\b|CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b)/i, }, { name: 'dynamic SQL execution', pattern: /\bEXECUTE\b/i, }, { name: 'destructive DROP operation', pattern: /\bDROP\b/i, }, { name: 'MERGE operation', pattern: /\bMERGE\b/i }, { name: 'CREATE OR REPLACE operation', pattern: /\bCREATE\s+OR\s+REPLACE\b/i, }, { name: 'TRUNCATE operation', pattern: /\bTRUNCATE(?:\s+TABLE)?\b/i }, { name: 'DELETE FROM operation', pattern: /\bDELETE\s+FROM\b/i }, { name: 'permission revocation', pattern: /\bREVOKE\b/i }, { name: 'transaction control operation', pattern: /\b(?:COMMIT|ROLLBACK|ABORT|SAVEPOINT)\b|\bRELEASE\s+SAVEPOINT\b|\bPREPARE\s+TRANSACTION\b|^\s*END(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*$/i, }, { name: 'SQL string semantics override', pattern: /\bSET\s+(?:LOCAL\s+|SESSION\s+)?STANDARD_CONFORMING_STRINGS\b/i, }, { name: 'object or column rename', pattern: /\bRENAME\b/i, }, { name: 'SET SCHEMA operation', pattern: /\bSET\s+SCHEMA\b/i }, { name: 'column type change', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\b(?:SET\s+DATA\s+)?TYPE\b/i, }, { name: 'SET NOT NULL operation', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bSET\s+NOT\s+NULL\b/i, }, { name: 'non-null column addition', pattern: /\bALTER\s+TABLE\b[\s\S]*\bADD\s+(?:COLUMN\s+)?[\s\S]*\bNOT\s+NULL\b/i, }, { name: 'DROP DEFAULT operation', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bDROP\s+DEFAULT\b/i, }, ] const violations = [] let addedFiles = 0 let immutableBase = '' const orderingCandidates = new Set() if (immutableRef) { if ( !/^[0-9a-f]{40}$/.test(immutableRef) && immutableRef !== 'refs/remotes/origin/main' ) { fail('MIGRATION_IMMUTABILITY_REF must be a full commit SHA or refs/remotes/origin/main') } const immutableTip = git([ 'rev-parse', '--verify', '--end-of-options', `${immutableRef}^{commit}`, ]).trim() const baseAtEvent = spawnSync( 'git', ['show', `${immutableTip}:${baselineFile}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, ) if (baseAtEvent.status === 0) { const previousBaselineTreeEntry = git([ 'ls-tree', immutableTip, '--', baselineFile, ]).trim() if (!/^100644 blob [0-9a-f]+\t/.test(previousBaselineTreeEntry)) { fail('previous production migration baseline must be a mode 100644 regular file') } const previousProductionBase = baseAtEvent.stdout.trim() if (!/^[0-9a-f]{40}$/.test(previousProductionBase)) { fail('previous production migration baseline is invalid') } const baselineAdvance = spawnSync( 'git', ['merge-base', '--is-ancestor', previousProductionBase, base], { stdio: 'ignore' }, ) if (baselineAdvance.status !== 0) { fail('production migration baseline must only move forward') } } const baselineWasAlreadyReviewed = spawnSync( 'git', ['merge-base', '--is-ancestor', base, immutableTip], { stdio: 'ignore' }, ) if (baselineWasAlreadyReviewed.status !== 0) { fail('production migration baseline must belong to the immutable event base history') } if (immutableTip === head) { const parent = spawnSync( 'git', ['rev-parse', '--verify', `${immutableTip}^1^{commit}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, ) immutableBase = parent.status === 0 ? parent.stdout.trim() : '' } else { const immutableAncestry = spawnSync( 'git', ['merge-base', '--is-ancestor', immutableTip, head], { stdio: 'ignore' }, ) if (immutableAncestry.status !== 0) { fail('migration immutability base is not an ancestor of HEAD') } immutableBase = immutableTip } if (immutableBase) { const immutableChanges = changedMigrations(immutableBase) for (let index = 0; index < immutableChanges.length; index += 2) { const status = immutableChanges[index] const file = immutableChanges[index + 1] if (status === 'A') { orderingCandidates.add(file) } else { violations.push( `${file}: migration present on main is immutable (status ${status})`, ) } } } } function migrationNamesAt(ref) { const output = git( ['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory], { encoding: null }, ) return output .toString('utf8') .split('\0') .filter(Boolean) .filter((file) => { const relative = file.slice(migrationDirectory.length) return file.startsWith(migrationDirectory) && !relative.includes('/') }) .map((file) => basename(file)) .filter((name) => /^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(name)) .sort() } const orderingBase = immutableBase || base const existingNames = migrationNamesAt(orderingBase) const previousMaximumName = existingNames.at(-1) || '' if (!immutableBase) { for (let index = 0; index < fields.length; index += 2) { if (fields[index] === 'A') orderingCandidates.add(fields[index + 1]) } } for (let index = 0; index < fields.length; index += 2) { const status = fields[index] const file = fields[index + 1] if (status !== 'A') { violations.push(`${file}: applied migrations are immutable (status ${status})`) continue } addedFiles += 1 const filename = basename(file) if ( file !== `${migrationDirectory}${filename}` || !/^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(filename) ) { violations.push(`${file}: new migration must use NNNN_lowercase_name.sql`) continue } if ( orderingCandidates.has(file) && previousMaximumName && filename <= previousMaximumName ) { violations.push( `${file}: new migration name must sort after ${previousMaximumName}`, ) continue } const treeEntry = git(['ls-tree', 'HEAD', '--', file]).trim() let fileState try { fileState = lstatSync(file) } catch { violations.push(`${file}: new migration is unavailable in the worktree`) continue } if ( !/^100644 blob [0-9a-f]+\t/.test(treeEntry) || !fileState.isFile() || fileState.isSymbolicLink() ) { violations.push(`${file}: new migration must be a mode 100644 regular file`) continue } const statements = executableSql(readFileSync(file, 'utf8'), file).split(';') for (const statement of statements) { for (const rule of destructiveRules) { if (rule.pattern.test(statement)) { violations.push(`${file}: ${rule.name}`) } } } } if (violations.length > 0) { for (const violation of [...new Set(violations)]) { console.error(`migration_safety_violation=${JSON.stringify(violation)}`) } fail(`base=${base} violations=${new Set(violations).size}`) } console.log(`migration_safety=PASS base=${base} added_files=${addedFiles}`)