Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions scripts/migration-pr-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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' }
Expand Down
10 changes: 9 additions & 1 deletion scripts/publish-migration-readiness.cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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) })
Expand Down
5 changes: 4 additions & 1 deletion scripts/publish-migration-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
Expand Down
Loading