From fc221d1b7362c0b145f456ca1d306ad8a6f7229d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:31:18 +0000 Subject: [PATCH] Migration readiness: a no-SQL PR no longer needs its base to be the live main tip The trusted checkout is the PR's own base, but both the collector and the publisher also required GITHUB_SHA (live main tip at trigger time) to equal it. Main moves every few minutes, so any PR whose base was one merge behind failed the runner before the migration-readiness check was even created. SQL PRs keep the strict current-main requirement. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012BMmZVXXXXXXXXXXXXXXX --- scripts/migration-pr-readiness.mjs | 5 +++-- scripts/publish-migration-readiness.cases.mjs | 10 +++++++++- scripts/publish-migration-readiness.mjs | 5 ++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/migration-pr-readiness.mjs b/scripts/migration-pr-readiness.mjs index e9bf7094d0..db22aafcd7 100644 --- a/scripts/migration-pr-readiness.mjs +++ b/scripts/migration-pr-readiness.mjs @@ -109,7 +109,7 @@ export async function collectReadiness({ env, event, git, api, ledger, collector requireFact(sha.test(mainSha) && sha.test(baseSha) && sha.test(headSha) && headSha === event.pull_request.head.sha, 'stale or moving event/base/head') for (const revision of [baseSha, headSha]) git(['fetch', '--no-tags', '--filter=blob:none', 'origin', revision]) // This job checks out main on an isolated runner and never executes candidate code. - requireFact(env.GITHUB_WORKFLOW_REF === `${REPOSITORY}/.github/workflows/migration-readiness.yml@refs/heads/main` && env.GITHUB_SHA === baseSha && String(git(['rev-parse', 'HEAD'])).trim() === baseSha, 'trusted current-main workflow checkout required') + requireFact(env.GITHUB_WORKFLOW_REF === `${REPOSITORY}/.github/workflows/migration-readiness.yml@refs/heads/main` && String(git(['rev-parse', 'HEAD'])).trim() === baseSha, 'trusted current-main workflow checkout required') const blob = (revision, path) => git(['show', `${revision}:${path}`], true) const inventory = revision => { const names = String(git(['ls-tree', '-r', '--name-only', revision, '--', 'supabase/migrations/'])).trim().split('\n') @@ -140,7 +140,8 @@ export async function collectReadiness({ env, event, git, api, ledger, collector requireFact(finalPr.state === 'open' && !finalPr.draft && finalPr.head.sha === headSha && finalPr.base.sha === baseSha, 'branch moved during no-SQL fallback') return { ok: true, status: 'not_applicable', proof: { baseSha, headSha, collectorSha256 } } } - requireFact(baseSha === mainSha, 'stale or moving event/base/head') + // Only a PR that adds SQL must sit on the current main tip (and the workflow run must be that tip's). + requireFact(baseSha === mainSha && env.GITHUB_SHA === baseSha, 'stale or moving event/base/head') const paths = reviewedChanges.map(change => change.file) const baseModes = gitModes(git, baseSha, paths), headModes = gitModes(git, headSha, paths) for (const change of reviewedChanges) { change.baseMode = baseModes.get(change.file) ?? '000000'; change.headMode = headModes.get(change.file) ?? '000000' } diff --git a/scripts/publish-migration-readiness.cases.mjs b/scripts/publish-migration-readiness.cases.mjs index 8333bcecbf..9aa3f6ac7d 100644 --- a/scripts/publish-migration-readiness.cases.mjs +++ b/scripts/publish-migration-readiness.cases.mjs @@ -38,6 +38,14 @@ test('no-SQL success is posted to candidate head with trusted base/source identi assert.equal(receipt.sources['scripts/publish-migration-readiness.mjs'], f.options.publisherSha256) assert.ok(!JSON.stringify(f.writes).includes('DO_NOT_SERIALIZE_SECRET')) }) +test('no-SQL success does not need the PR base to be the live main tip', async () => { + const f = fixture(); f.options.env.GITHUB_SHA = 'e'.repeat(40); await publishReadiness(f.options) + assert.equal(f.writes.at(-1).payload.conclusion, 'success') +}) +test('a SQL result still fails closed when the run is not on the PR base', async () => { + const f = fixture('registered_pending_apply'); f.options.env.GITHUB_SHA = 'e'.repeat(40) + await assert.rejects(() => publishReadiness(f.options)); assert.equal(f.writes.at(-1).payload.conclusion, 'failure') +}) test('registered migration result binds policy, registration and staging run/attempt to exact candidate-head check', async () => { const f = fixture('registered_pending_apply'); await publishReadiness(f.options) const receipt = JSON.parse(f.writes.at(-1).payload.output.summary.split('\n\n')[1]) @@ -51,7 +59,7 @@ for (const [name, mutate] of [ ['draft', f => { f.pr.draft = true }], ['closed PR', f => { f.pr.state = 'closed' }], ['stale event head', f => { f.options.event.pull_request.head.sha = 'e'.repeat(40) }], - ['stale main checkout', f => { f.options.env.GITHUB_SHA = 'e'.repeat(40) }], + ['checkout that is not the PR base', f => { const git = f.options.git; f.options.git = (args, ...rest) => args[0] === 'rev-parse' ? 'e'.repeat(40) : git(args, ...rest) }], ['publisher source mismatch', f => { f.options.publisherSha256 = 'e'.repeat(64) }], ['unrecognized workflow ref', f => { f.options.env.GITHUB_WORKFLOW_REF = `${repo}/.github/workflows/migration-readiness.yml@refs/heads/candidate` }], ]) test(`refuses ${name} before provider facts or check publication`, async () => { const f = fixture(); mutate(f); await assert.rejects(() => publishReadiness(f.options)); assert.equal(f.evaluateCalls, 0); assert.equal(f.writes.length, 0) }) diff --git a/scripts/publish-migration-readiness.mjs b/scripts/publish-migration-readiness.mjs index 6b4c3ee770..b4b7d34c38 100644 --- a/scripts/publish-migration-readiness.mjs +++ b/scripts/publish-migration-readiness.mjs @@ -14,7 +14,9 @@ export async function publishReadiness({ env, event, api, git, publisherSha256, return { head: pr.head.sha, base: pr.base.sha, main: main.commit.sha } } const binding = await read() - requireFact(env.GITHUB_SHA === binding.base && String(git(['rev-parse', 'HEAD'])).trim() === binding.base, 'current main checkout required') + // The checkout is always the PR's own base. A no-SQL result does not need that base to be the live main tip + // (main moves every few minutes); a SQL result does, and is refused below when GITHUB_SHA is not the base. + requireFact(String(git(['rev-parse', 'HEAD'])).trim() === binding.base, 'current main checkout required') const sources = Object.fromEntries(files.map(file => [file, hash(git(['show', `${binding.base}:${file}`], true))])) requireFact(digest.test(publisherSha256 ?? '') && sources[files[1]] === publisherSha256, 'publisher differs from trusted main') // A no-SQL result needs only the exact head; a SQL result also needs the PR's base to still be current main. @@ -31,6 +33,7 @@ export async function publishReadiness({ env, event, api, git, publisherSha256, try { const result = await evaluate() requireFact(result?.ok === true && ['not_applicable', 'strict_parity', 'registered_pending_apply'].includes(result.status), 'evaluation refused') + requireFact(result.status === 'not_applicable' || env.GITHUB_SHA === binding.base, 'current main checkout required for SQL PRs') const proof = result.proof requireFact(proof?.baseSha === binding.base && proof.headSha === binding.head && proof.collectorSha256 === sources[files[0]], 'unbound evaluator result') if (result.status === 'registered_pending_apply') requireFact(proof.policySha256 === sources[files[3]] && digest.test(proof.registrationSha256 ?? '') && digest.test(proof.workflowSha256 ?? '') && Number.isSafeInteger(proof.stagingRunId) && proof.stagingRunId > 0 && Number.isSafeInteger(proof.stagingAttempt) && proof.stagingAttempt > 0, 'incomplete registered staging proof')