436 lines
12 KiB
JavaScript
Executable File
436 lines
12 KiB
JavaScript
Executable File
#!/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}`)
|