Skip to content

Commit cb74e15

Browse files
committed
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 []`.
1 parent 49593b3 commit cb74e15

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,18 @@ jobs:
126126
- name: Verify docs manifest is in sync
127127
run: bun run docs-manifest:check
128128

129+
# Its own step, and no `|| true`: a swallowed fetch leaves the base ref absent,
130+
# which the audit cannot distinguish from a branch that changed no migrations.
131+
# The depth stays at 1 — without a merge-base the audit diffs the two tips,
132+
# which under `--diff-filter=AM` is exactly the migrations new on this branch.
133+
- name: Fetch base ref for migration diff
134+
if: github.event_name == 'pull_request'
135+
run: git fetch --depth=1 origin "${{ github.base_ref }}"
136+
129137
- name: Migration safety (zero-downtime) audit
130138
run: |
131139
if [ "${{ github.event_name }}" = "pull_request" ]; then
132140
BASE_REF="origin/${{ github.base_ref }}"
133-
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
134141
else
135142
BASE_REF="HEAD~1"
136143
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)