import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' export const COMMENT_MARKER = '' const MIGRATIONS_PREFIX = 'supabase/migrations/' const MIGRATION_FILE_PATTERN = /\/(\d{14})_[^/]+\.sql$/ const RECENT_ACTIVITY_HOURS = 72 export function migrationTimestamp(path) { return path.match(MIGRATION_FILE_PATTERN)?.[1] ?? null } export function latestMigrationPath(paths) { return paths .filter(path => migrationTimestamp(path)) .toSorted((left, right) => { const timestampOrder = migrationTimestamp(left).localeCompare(migrationTimestamp(right)) return timestampOrder || left.localeCompare(right) }) .at(-1) ?? null } function migrationPathMarkup(path) { const escaped = path .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll('\'', ''') return `${escaped}` } export function stalePullRequestMigrations(files, latestMainMigrationPath, mainMigrationPaths = [latestMainMigrationPath]) { const latestMainTimestamp = migrationTimestamp(latestMainMigrationPath) if (!latestMainTimestamp) return [] const migrationFiles = files .filter(file => file.status === 'added' || file.status === 'renamed') .filter(file => file.filename.startsWith(MIGRATIONS_PREFIX) && file.filename.endsWith('.sql')) const addedBaselines = migrationFiles.filter(file => file.status === 'added' && file.filename.endsWith('_baseline.sql'), ) const removedMigrationCount = files.filter(file => file.status === 'removed' && file.filename.startsWith(MIGRATIONS_PREFIX) && file.filename.endsWith('.sql'), ).length const squashBaselinePath = addedBaselines.length === 1 && removedMigrationCount >= 50 ? addedBaselines[0].filename : null const mainTimestamps = new Set(mainMigrationPaths.map(migrationTimestamp).filter(Boolean)) return [...new Set(migrationFiles .map(file => file.filename) .filter((path) => { const timestamp = migrationTimestamp(path) if (!timestamp || timestamp > latestMainTimestamp) return false const isAllowedSquashBaseline = path === squashBaselinePath && timestamp < latestMainTimestamp && !mainTimestamps.has(timestamp) return !isAllowedSquashBaseline }))].toSorted() } export function buildWarningBody({ mainMigrationPaths, latestMainMigrationPath, staleMigrationPaths, commitUrl, }) { const mainList = mainMigrationPaths.map(path => `- ${migrationPathMarkup(path)}`).join('\n') const staleList = staleMigrationPaths.map(path => `- ${migrationPathMarkup(path)}`).join('\n') return `${COMMENT_MARKER} ## ⚠️ Supabase migration order is now stale [A push to \`main\`](${commitUrl}) added or restamped these migrations: ${mainList} This PR still adds migrations that sort at or before the latest migration on \`main\` (${migrationPathMarkup(latestMainMigrationPath)}): ${staleList} The migration-order check will reject this ordering, and Supabase will not deploy this PR correctly as-is. Update the branch from \`main\`, then recreate the pending migration with a timestamp newer than the latest migration on \`main\`. If the migration has never been applied and its SQL must stay identical, use a content-preserving restamp.` } function localMainMigrationPaths(workspace) { return readdirSync(join(workspace, 'supabase', 'migrations')) .filter(file => file.endsWith('.sql')) .map(file => `${MIGRATIONS_PREFIX}${file}`) } function loadCurrentMainMigrationPaths(workspace, providedPaths) { if (providedPaths) return providedPaths const snapshotPath = process.env.CURRENT_MAIN_MIGRATION_PATHS_FILE if (snapshotPath) { return readFileSync(snapshotPath, 'utf8') .split('\0') .map(path => path.trim()) .filter(Boolean) } return localMainMigrationPaths(workspace) } export async function warnStalePrMigrations({ github, context, core, currentMainMigrationPaths, mainMigrationPaths, now = new Date(), }) { const changedMainMigrations = mainMigrationPaths .map(path => path.trim()) .filter(path => migrationTimestamp(path)) .toSorted() if (changedMainMigrations.length === 0) { core.info('No valid Supabase migration additions were found in the main push.') return 0 } const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd() const currentMainPaths = loadCurrentMainMigrationPaths(workspace, currentMainMigrationPaths) const latestMain = latestMigrationPath(currentMainPaths) if (!latestMain) throw new Error('Could not determine the latest Supabase migration on main.') const { owner, repo } = context.repo const base = context.payload.repository?.default_branch ?? 'main' const cutoff = new Date(now.getTime() - RECENT_ACTIVITY_HOURS * 60 * 60 * 1000) const pullRequests = await github.paginate(github.rest.pulls.list, { owner, repo, base, state: 'open', sort: 'updated', direction: 'desc', per_page: 100, }) const recentPullRequests = pullRequests.filter((pullRequest) => { const updatedAt = new Date(pullRequest.updated_at) return !Number.isNaN(updatedAt.getTime()) && updatedAt >= cutoff }) let warned = 0 for (const pullRequest of recentPullRequests) { try { const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pullRequest.number, per_page: 100, }) const staleMigrationPaths = stalePullRequestMigrations(files, latestMain, currentMainPaths) const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pullRequest.number, per_page: 100, }) const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(COMMENT_MARKER), ) if (staleMigrationPaths.length === 0) { if (existing) { await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id, }) core.notice(`Removed resolved migration-order warning from PR #${pullRequest.number}.`) } core.info(`PR #${pullRequest.number} has no stale Supabase migrations.`) continue } const body = buildWarningBody({ mainMigrationPaths: changedMainMigrations, latestMainMigrationPath: latestMain, staleMigrationPaths, commitUrl: `https://github.com/${owner}/${repo}/commit/${context.sha}`, }) if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body, }) core.notice(`Updated migration-order warning on PR #${pullRequest.number}.`) } else { await github.rest.issues.createComment({ owner, repo, issue_number: pullRequest.number, body, }) core.notice(`Posted migration-order warning on PR #${pullRequest.number}.`) } warned += 1 } catch (error) { const message = error instanceof Error ? error.message : String(error) core.warning(`Could not process migration warning for PR #${pullRequest.number}: ${message}`) } } core.info(`Checked ${recentPullRequests.length} recently active PRs and warned ${warned}.`) return warned }