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')