Files
easyai-ai-gateway/scripts/ci-validate-migrations.mjs
T
easyai dae5d16a58 refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
2026-07-22 15:13:40 +08:00

324 lines
9.0 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 base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''
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('RELEASE_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('release base did not resolve exactly')
}
const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], {
stdio: 'ignore',
})
if (ancestry.status !== 0) {
fail('release base is not an ancestor of HEAD')
}
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
const orderingCandidates = new Set()
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 existingNames = migrationNamesAt(base)
const previousMaximumName = existingNames.at(-1) || ''
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}`)