Skip to content

Commit 95d08d2

Browse files
authored
fix(ci): stop the migration safety audit from passing on a branch it never read (#7022)
* fix(ci): stop the migration safety audit from passing on a branch it never read The zero-downtime audit reports the same empty file list for 'this branch adds no migrations' and 'I could not diff against the base', and the second prints as `✓ No new migrations to check` with exit 0. Reproduced on this checkout: $ bun run scripts/check-migrations-safety.ts origin/does-not-exist-branch ✓ No new migrations to check. exit=0 `changedMigrationFiles` returned `[]` whenever `git diff` failed, with a comment deferring the decision to the caller — but the caller only recognised a missing git binary (`git rev-parse HEAD === null`), never an unusable ref. CI supplied exactly that input. `git fetch --depth=1 … 2>/dev/null || true` hid a failed fetch, leaving `origin/<base>` absent, so a PR adding a destructive `DROP COLUMN` would clear the only guard on production DDL with a green check. Two halves: - The audit now distinguishes the cases. Absent git is still the one legitimate skip and is checked before the diff; a diff that fails with git present raises `BaseRefUnusableError` and exits 1. - The fetch is its own step with no `|| true`, so a failure fails the job. Depth stays 1: without a merge-base the audit diffs the two tips, which under `--diff-filter=AM` is exactly the migrations new on the branch. Covered by a test that runs the script end to end, since the defect was in the exit code rather than in any function's return value. Verified it fails when the throw is reverted to `return []`. * fix(ci): fetch the base ref once, and stop swallowing the failure The same `git fetch --depth=1 … 2>/dev/null || true` appeared in both base-ref audits. Fixing only the migration one would have left the identical defect a few steps above it. Neither audit can tell an absent base ref apart from a branch that changed nothing. The block-registry check at least degrades to a visible `⚠ Could not diff against base ref — skipping`; the migration audit printed `✓ No new migrations to check` and exited 0. Both now share one fetch step that fails the job when it fails.
1 parent cc08749 commit 95d08d2

3 files changed

Lines changed: 90 additions & 7 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,22 @@ jobs:
104104
105105
echo "✅ All env flags are properly configured"
106106
107+
# One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves
108+
# the base ref absent, which neither audit can tell apart from a branch that
109+
# changed nothing. The block-registry check at least degrades to a visible
110+
# `⚠ … skipping` line; the migration audit printed `✓ No new migrations to
111+
# check` and exited 0, clearing the only guard on production DDL.
112+
#
113+
# Depth stays at 1 — without a merge-base the migration audit diffs the two
114+
# tips, which under `--diff-filter=AM` is exactly the migrations new here.
115+
- name: Fetch base ref for diff-based audits
116+
if: github.event_name == 'pull_request'
117+
run: git fetch --depth=1 origin "${{ github.base_ref }}"
118+
107119
- name: Check block registry invariants
108120
run: |
109121
if [ "${{ github.event_name }}" = "pull_request" ]; then
110122
BASE_REF="origin/${{ github.base_ref }}"
111-
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
112123
else
113124
BASE_REF="HEAD~1"
114125
fi
@@ -130,7 +141,6 @@ jobs:
130141
run: |
131142
if [ "${{ github.event_name }}" = "pull_request" ]; then
132143
BASE_REF="origin/${{ github.base_ref }}"
133-
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
134144
else
135145
BASE_REF="HEAD~1"
136146
fi
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { execFile } from 'node:child_process'
2+
import path from 'node:path'
3+
import { promisify } from 'node:util'
4+
import { describe, expect, it } from 'vitest'
5+
6+
const execFileAsync = promisify(execFile)
7+
const ROOT = path.resolve(import.meta.dirname, '..')
8+
const SCRIPT = path.join(ROOT, 'scripts/check-migrations-safety.ts')
9+
10+
async function runAudit(
11+
baseRef: string
12+
): Promise<{ code: number; stdout: string; stderr: string }> {
13+
try {
14+
const { stdout, stderr } = await execFileAsync('bun', ['run', SCRIPT, baseRef], { cwd: ROOT })
15+
return { code: 0, stdout, stderr }
16+
} catch (error) {
17+
const failure = error as { code?: number; stdout?: string; stderr?: string }
18+
return { code: failure.code ?? 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? '' }
19+
}
20+
}
21+
22+
describe('migration safety audit', () => {
23+
/**
24+
* The regression this guards: an unresolvable base ref made `git diff` fail, the
25+
* failure was read as an empty file list, and the audit printed
26+
* `✓ No new migrations to check` and exited 0 — green on a branch it never read.
27+
* CI reached that state whenever its `git fetch ... || true` swallowed a failure.
28+
*/
29+
it('fails loudly when the base ref cannot be diffed', async () => {
30+
const { code, stderr } = await runAudit('origin/branch-that-does-not-exist')
31+
32+
expect(code).toBe(1)
33+
expect(stderr).toContain('could not run')
34+
expect(stderr).not.toContain('No new migrations to check')
35+
}, 30_000)
36+
37+
it('passes against a real base ref with no new migrations', async () => {
38+
const { code, stdout } = await runAudit('HEAD')
39+
40+
expect(code).toBe(0)
41+
expect(stdout).toContain('No new migrations to check')
42+
}, 30_000)
43+
})

scripts/check-migrations-safety.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,24 @@ function git(args: string[]): string | null {
390390
}
391391
}
392392

393+
/**
394+
* Raised when the base ref cannot be compared against `HEAD`.
395+
*
396+
* Distinct from "no migrations changed", which is the same empty list. Conflating
397+
* the two is how this check came to pass on a branch it had never read: an
398+
* unresolvable base made `git diff` fail, the failure became `[]`, and `[]`
399+
* printed as `✓ No new migrations to check`.
400+
*/
401+
class BaseRefUnusableError extends Error {
402+
constructor(readonly baseRef: string) {
403+
super(
404+
`Cannot diff against '${baseRef}'. The ref is missing, or was fetched without enough ` +
405+
`history for a merge-base. Fetch it with full history before running this check.`
406+
)
407+
this.name = 'BaseRefUnusableError'
408+
}
409+
}
410+
393411
/** New migration files on this branch vs base, plus uncommitted ones locally. */
394412
function changedMigrationFiles(baseRef: string): string[] {
395413
const files = new Set<string>()
@@ -405,7 +423,9 @@ function changedMigrationFiles(baseRef: string): string[] {
405423
'--',
406424
MIGRATIONS_DIR,
407425
])
408-
if (committed === null) return [] // git unavailable → fail open (handled by caller)
426+
/* Only a missing git binary is a legitimate skip, and `resolveFiles` detects that
427+
separately. A diff that fails with git present means the ref is unusable. */
428+
if (committed === null) throw new BaseRefUnusableError(baseRef)
409429
for (const f of committed.split('\n')) if (inDir(f)) files.add(f)
410430

411431
const status = git(['status', '--porcelain', '--', MIGRATIONS_DIR])
@@ -442,16 +462,26 @@ async function resolveFiles(argv: string[]): Promise<string[] | null> {
442462
return (await listSqlFiles(path.resolve(dir))).map((f) => path.relative(ROOT, f))
443463
}
444464
const baseRef = argv.find((a) => !a.startsWith('--')) ?? 'origin/staging'
445-
const files = changedMigrationFiles(baseRef)
446-
if (files.length === 0 && git(['rev-parse', 'HEAD']) === null) {
465+
/* Checked before the diff: without git there is nothing to compare, and that is the
466+
one case where skipping is right. Every other failure must be loud. */
467+
if (git(['rev-parse', 'HEAD']) === null) {
447468
console.warn('⚠ git unavailable — skipping migration safety check.')
448469
return null
449470
}
450-
return files
471+
return changedMigrationFiles(baseRef)
451472
}
452473

453474
async function main() {
454-
const files = await resolveFiles(process.argv.slice(2))
475+
let files: string[] | null
476+
try {
477+
files = await resolveFiles(process.argv.slice(2))
478+
} catch (error) {
479+
if (error instanceof BaseRefUnusableError) {
480+
console.error(`✗ Migration safety check could not run.\n ${error.message}`)
481+
process.exit(1)
482+
}
483+
throw error
484+
}
455485
if (files === null) process.exit(0)
456486

457487
if (files.length === 0) {

0 commit comments

Comments
 (0)