import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' const migrationsDirectory = 'packages/app-lib/migrations' const repository = process.env.GITHUB_REPOSITORY ?? 'Mystic-Stars/Axolotl' // v1.7.5 is the canonical snapshot immediately before the v1.7.6 incident. const canonicalBootstrap = { ref: '7ddbfb8e57db4b0044a04cf28f25fb29e08c3279', tag: 'v1.7.5', } // Published divergences remain evidence of an incident and never replace the canonical bytes. const knownPublishedDivergences = new Set([ [ 'v1.7.6', 'packages/app-lib/migrations/20260714120000_translation.sql', 'c6fdf52790db7e67905003216ee7c099ec9ac29df1ee1b62602eb791881f321470f7e2b965c39fc8733b10ad114eace5', ].join('\0'), ]) function git(args, encoding = 'utf8') { return execFileSync('git', args, { encoding, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }) } function checksum(contents) { return createHash('sha384').update(contents).digest('hex') } function migrationChecksum(migration) { migration.checksum ??= checksum(git(['cat-file', 'blob', migration.blob], null)) return migration.checksum } function migrationVersion(file) { const name = file.slice(file.lastIndexOf('/') + 1) const match = /^(\d+)_([a-z0-9][a-z0-9_-]*)\.sql$/.exec(name) return match ? Number(match[1]) : null } function migrationMapAt(ref) { const migrations = new Map() const output = git(['ls-tree', '-r', '-z', ref, '--', migrationsDirectory]) for (const record of output.split('\0')) { if (!record) continue const tabIndex = record.indexOf('\t') const metadata = record.slice(0, tabIndex).split(' ') const file = record.slice(tabIndex + 1) if (!file.endsWith('.sql')) continue migrations.set(file, { blob: metadata[2], checksum: null, version: migrationVersion(file), }) } return new Map([...migrations].sort(([left], [right]) => left.localeCompare(right))) } function validateMigrationSet(migrations, failures) { const versions = new Map() for (const [file, migration] of migrations) { if (migration.version === null) { failures.push(`INVALID NAME ${file}`) continue } const existing = versions.get(migration.version) if (existing) { failures.push(`DUPLICATE VERSION ${migration.version}: ${existing}, ${file}`) } else { versions.set(migration.version, file) } } } function compareCurrentWithCanonical(canonical, currentRef, failures) { const current = migrationMapAt(currentRef) validateMigrationSet(current, failures) for (const [file, expected] of canonical) { const actual = current.get(file) if (!actual) { failures.push(`DELETED ${file}`) continue } if (actual.blob !== expected.blob) { failures.push( `MODIFIED ${file}\n Expected SHA-384: ${migrationChecksum(expected)}\n Actual SHA-384: ${migrationChecksum(actual)}`, ) } } const maximumCanonicalVersion = Math.max( ...Array.from(canonical.values(), (migration) => migration.version ?? 0), ) for (const [file, migration] of current) { if ( !canonical.has(file) && migration.version !== null && migration.version <= maximumCanonicalVersion ) { failures.push( `OUT-OF-ORDER ${file}: new migration version must be greater than ${maximumCanonicalVersion}`, ) } } } function parseVersion(tag) { const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(tag) if (!match) return null return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null, } } function compareVersions(left, right) { for (const key of ['major', 'minor', 'patch']) { if (left[key] !== right[key]) return left[key] - right[key] } if (left.prerelease === right.prerelease) return 0 if (left.prerelease === null) return 1 if (right.prerelease === null) return -1 return left.prerelease.localeCompare(right.prerelease, 'en', { numeric: true }) } function publishedReleases() { const args = [ 'release', 'list', '--limit', '1000', '--json', 'tagName,isDraft,publishedAt', '--repo', repository, ] const releases = JSON.parse(execFileSync('gh', args, { encoding: 'utf8' })) const bootstrapVersion = parseVersion(canonicalBootstrap.tag) return releases .filter((release) => !release.isDraft) .map((release) => ({ ...release, version: parseVersion(release.tagName) })) .filter((release) => release.version && compareVersions(release.version, bootstrapVersion) >= 0) .sort((left, right) => Date.parse(left.publishedAt) - Date.parse(right.publishedAt)) } function auditPublishedReleases(currentRef) { const failures = [] const warnings = [] const canonical = migrationMapAt(canonicalBootstrap.ref) validateMigrationSet(canonical, failures) for (const release of publishedReleases()) { if (release.tagName === canonicalBootstrap.tag) continue const released = migrationMapAt(release.tagName) for (const file of canonical.keys()) { if (!released.has(file)) { failures.push(`PUBLISHED DELETE ${release.tagName}: ${file}`) } } for (const [file, migration] of released) { const expected = canonical.get(file) if (!expected) { canonical.set(file, migration) continue } if (migration.blob === expected.blob) continue const releasedChecksum = migrationChecksum(migration) const divergence = [release.tagName, file, releasedChecksum].join('\0') if (knownPublishedDivergences.has(divergence)) { warnings.push( `${release.tagName} contains the known historical migration divergence in ${file}`, ) continue } failures.push( `UNRECOGNIZED PUBLISHED DIVERGENCE ${release.tagName}: ${file}\n` + ` Canonical SHA-384: ${migrationChecksum(expected)}\n` + ` Released SHA-384: ${releasedChecksum}`, ) } } compareCurrentWithCanonical(canonical, currentRef, failures) finish(failures, warnings, `published release history from ${canonicalBootstrap.tag}`) } function resolveBaseRef(baseRef) { const isAvailable = () => { try { git(['rev-parse', '--verify', `${baseRef}^{commit}`]) return true } catch { return false } } if (isAvailable()) return baseRef for (const remote of ['origin', 'AXL']) { try { git(['fetch', remote, baseRef]) if (isAvailable()) return baseRef } catch { // Try the next remote. } } try { const upstream = git([ 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}', ]).trim() const upstreamCommit = git(['rev-parse', `${upstream}^{commit}`]).trim() const headCommit = git(['rev-parse', 'HEAD^{commit}']).trim() if (upstream && upstreamCommit !== headCommit) return upstream } catch { // Fall through to HEAD^. } console.warn( `Migration guard: base ref ${baseRef} is not available locally; falling back to HEAD^`, ) return 'HEAD^' } function compareWithBase(baseRef, currentRef) { const failures = [] const canonical = migrationMapAt(baseRef) validateMigrationSet(canonical, failures) compareCurrentWithCanonical(canonical, currentRef, failures) finish(failures, [], baseRef) } function finish(failures, warnings, baseline) { for (const warning of warnings) console.warn(`Migration guard notice: ${warning}`) if (failures.length > 0) { console.error( `Migration guard failed against ${baseline}:\n\n${failures.join('\n\n')}\n\n` + 'Historical migrations are immutable. Add a new forward migration instead.', ) process.exit(1) } console.log(`Migration guard passed against ${baseline}.`) } const args = process.argv.slice(2) const currentIndex = args.indexOf('--current') const currentRef = currentIndex === -1 ? 'HEAD' : args[currentIndex + 1] const baseIndex = args.indexOf('--base') if (args.includes('--release')) { auditPublishedReleases(currentRef) } else if (baseIndex !== -1 && args[baseIndex + 1]) { compareWithBase(resolveBaseRef(args[baseIndex + 1]), currentRef) } else { console.error( 'Usage: node check-migrations.mjs (--release | --base ) [--current ]', ) process.exit(2) }