diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9672ccef..882894576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,19 +92,13 @@ jobs: } const { pathToFileURL } = require("node:url"); const moduleUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/trusted-policy/scripts/pr-policy.mjs`).href; - const { requiredClinicalGovernanceItems } = await import(moduleUrl); + const { collectSatisfiedGovernanceItems, requiredClinicalGovernanceItems, section } = await import(moduleUrl); const pr = context.payload.pull_request; const existingBody = pr.body || ""; const existingCheckedItems = new Set(); - const govMatch = existingBody.match(/##\s*Clinical Governance Preflight\s*([\s\S]*?)(?=\n##|$)/i); - if (govMatch) { - const govSection = govMatch[1]; - for (const item of requiredClinicalGovernanceItems) { - const escaped = item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - if (new RegExp(`^\\s*-\\s*\\[[xX]\\]\\s*${escaped}\\s*$`, "m").test(govSection)) { - existingCheckedItems.add(item); - } - } + const govSection = section(existingBody, "Clinical Governance Preflight"); + if (govSection) { + for (const item of collectSatisfiedGovernanceItems(govSection)) existingCheckedItems.add(item); } const governance = requiredClinicalGovernanceItems .map((item) => { diff --git a/.github/workflows/live-drift.yml b/.github/workflows/live-drift.yml index f28d14c1e..c906f0448 100644 --- a/.github/workflows/live-drift.yml +++ b/.github/workflows/live-drift.yml @@ -57,3 +57,6 @@ jobs: - name: Compare live schema drift run: npm run check:drift + + - name: Align migration history for Supabase Preview + run: npm run check:migration-history diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index a782d024e..67366a060 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -6,6 +6,7 @@ Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) ## Policy +- Hosted **Supabase Preview** fails with `Remote migration versions not found in local migrations directory` when `schema_migrations` still records a version after a local rename/renumber. Keep a local `*_historical_version_placeholder.sql` (no-op) for orphan versions, or repair remote history with `supabase migration repair --status reverted ` after verifying effects already exist under the replacement migration. Offline guard: `tests/migration-history-placeholders.test.ts`. Live diagnostic: `npm run check:migration-history` (also run from `.github/workflows/live-drift.yml`). - Do not use `supabase db push` while local and remote migration history are divergent. - **Never change a retrieval RPC, index, or function on the live project with raw SQL in the dashboard.** Use a committed migration under `supabase/migrations/` and reconcile `supabase/schema.sql` in the same change. - Use `supabase migration repair --linked --status applied ` only when live database evidence proves the migration effect already exists. diff --git a/package.json b/package.json index 428189963..ba06a05a4 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "skills": "node scripts/list-database-skills.mjs", "check:skills": "node scripts/list-database-skills.mjs --check", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", + "check:migration-history": "node scripts/run-tsx.mjs scripts/check-migration-history-alignment.ts", "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts" }, "dependencies": { diff --git a/scripts/check-migration-history-alignment.ts b/scripts/check-migration-history-alignment.ts new file mode 100644 index 000000000..d39027678 --- /dev/null +++ b/scripts/check-migration-history-alignment.ts @@ -0,0 +1,100 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +/** + * Compare local supabase/migrations versions against live + * supabase_migrations.schema_migrations. + * + * Hosted Supabase Preview fails with: + * "Remote migration versions not found in local migrations directory" + * when the remote history table contains versions that are absent locally + * (common after rename/renumber without history repair). + * + * This script prints remote-only / local-only versions and exits 1 when any + * remote-only versions remain. It is intended for workflow_dispatch / live + * alignment checks (uses service-role secrets). + */ + +type RemoteRow = { version: string; name: string | null }; + +function localMigrationVersions(migrationsDir: string): string[] { + return readdirSync(migrationsDir) + .map((name) => { + const match = /^(\d{14})_.*\.sql$/.exec(name); + return match?.[1] ?? null; + }) + .filter((version): version is string => Boolean(version)) + .sort(); +} + +async function fetchRemoteVersions(url: string, serviceKey: string): Promise { + // Read supabase_migrations via Accept-Profile when the project exposes that + // schema to the Data API. Preview alignment itself only needs local files to + // cover remote versions; this check is diagnostic for live history drift. + const profileResponse = await fetch(`${url}/rest/v1/schema_migrations?select=version,name&order=version.asc`, { + signal: AbortSignal.timeout(10_000), + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + "Accept-Profile": "supabase_migrations", + }, + }); + if (profileResponse.ok) { + return (await profileResponse.json()) as RemoteRow[]; + } + + const profileText = await profileResponse.text(); + throw new Error( + `Unable to read remote schema_migrations via Accept-Profile ` + + `(status ${profileResponse.status}: ${profileText.slice(0, 240)})`, + ); +} + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !serviceKey) { + throw new Error("NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required"); + } + + const migrationsDir = join(process.cwd(), "supabase/migrations"); + const local = new Set(localMigrationVersions(migrationsDir)); + console.log(`Local migration versions: ${local.size}`); + + const remoteRows = await fetchRemoteVersions(url, serviceKey); + const remote = new Set(remoteRows.map((row) => row.version)); + const remoteOnly = [...remote].filter((version) => !local.has(version)).sort(); + const localOnly = [...local].filter((version) => !remote.has(version)).sort(); + + console.log(`Remote migration versions: ${remote.size}`); + console.log(`Remote-only (Preview blockers): ${remoteOnly.length}`); + for (const version of remoteOnly) { + const row = remoteRows.find((item) => item.version === version); + console.log(` - ${version}${row?.name ? ` (${row.name})` : ""}`); + } + console.log(`Local-only (pending apply): ${localOnly.length}`); + for (const version of localOnly.slice(0, 30)) { + console.log(` - ${version}`); + } + if (localOnly.length > 30) { + console.log(` … ${localOnly.length - 30} more`); + } + + if (remoteOnly.length > 0) { + throw new Error( + `${remoteOnly.length} remote migration version(s) are missing from supabase/migrations. ` + + `Hosted Supabase Preview will fail until local files exist for those versions ` + + `(or remote history is repaired).`, + ); + } + + console.log("Migration history alignment OK: every remote version exists locally."); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/pr-policy.mjs b/scripts/pr-policy.mjs index 3e6a1b6a6..825642648 100644 --- a/scripts/pr-policy.mjs +++ b/scripts/pr-policy.mjs @@ -13,6 +13,35 @@ export const requiredClinicalGovernanceItems = [ "Deployment classification/TGA SaMD impact was checked when clinical decision-support behavior changed", ]; +// Legacy PR checklist phrasing predates the canonical governance list above. +// Each item maps to one or more matcher groups; a group is satisfied only when +// every regex in that group matches at least one checked checklist entry. +const legacyClinicalGovernanceEvidence = new Map([ + [ + requiredClinicalGovernanceItems[0], + [ + [ + /\bcitation requirements?\b/i, + /\bsource verification\b/i, + /\bprovenance\b/i, + /\bclinical-content governance\b/i, + ], + ], + ], + [ + requiredClinicalGovernanceItems[1], + [[/\bpatient-data handling\b/i, /\bprivacy controls?\b/i, /\bdocument-access behavior\b/i]], + ], + [requiredClinicalGovernanceItems[2], [[/\bconfigured supabase project\/target\b/i]]], + [requiredClinicalGovernanceItems[3], [[/\bservice-role credentials?\b/i, /\bserver-side only\b/i]]], + [requiredClinicalGovernanceItems[4], [[/\bclinical-content governance\b/i, /\bsource verification\b/i]]], + [ + requiredClinicalGovernanceItems[5], + [[/\bprovenance\b/i, /\bclinical-content governance\b/i, /\bsource verification\b/i]], + ], + [requiredClinicalGovernanceItems[6], [[/\bsamd\/tga assessment\b/i]]], +]); + const clinicalRiskPatterns = [ /^supabase\//, /^src\/app\/api\//, @@ -59,20 +88,28 @@ function normalizeSectionHeading(value) { .toLowerCase(); } -function section(body, heading) { +export function section(body, heading) { const source = String(body ?? ""); const headings = [...source.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*$/gim)]; const targetHeading = normalizeSectionHeading(heading); - const matchIndex = headings.findIndex((match) => normalizeSectionHeading(match[2]) === targetHeading); - if (matchIndex < 0) return ""; - const start = (headings[matchIndex]?.index ?? 0) + (headings[matchIndex]?.[0].length ?? 0); - // Markdown outline semantics: the section runs until the next heading of the - // same or shallower level. Deeper headings (### under ##) are sub-structure - // and stay inside the section, so checklist evidence after them still counts. - const level = headings[matchIndex][1].length; - const next = headings.slice(matchIndex + 1).find((match) => match[1].length <= level); - const end = next?.index ?? source.length; - return source.slice(start, end).trim(); + // Collect content from ALL matching sections and join them. This handles + // accidental duplicate headings (e.g. a prose rationale block and a separate + // checklist block under the same heading) by combining their content rather + // than silently returning only the first occurrence and missing the checklist. + const parts = []; + headings.forEach((match, matchIndex) => { + if (normalizeSectionHeading(match[2]) !== targetHeading) return; + const start = (match.index ?? 0) + match[0].length; + // Markdown outline semantics: the section runs until the next heading of the + // same or shallower level. Deeper headings (### under ##) are sub-structure + // and stay inside the section, so checklist evidence after them still counts. + const level = match[1].length; + const next = headings.slice(matchIndex + 1).find((m) => m[1].length <= level); + const end = next?.index ?? source.length; + const content = source.slice(start, end).trim(); + if (content) parts.push(content); + }); + return parts.join("\n\n"); } function meaningfulText(value) { @@ -110,6 +147,32 @@ function branchLikeTitle(title, headRef) { ); } +function checkedChecklistEntries(value) { + return [...String(value ?? "").matchAll(/^\s*-\s*\[[xX]\]\s*(.+?)\s*$/gm)].map((match) => match[1].trim()); +} + +function governanceMatcherGroupSatisfied(group, checkedEntries) { + return group.every((matcher) => checkedEntries.some((entry) => matcher.test(entry))); +} + +function governanceItemSatisfied(checkedEntries, item) { + if (checkedEntries.some((entry) => entry === item)) return true; + const matcherGroups = legacyClinicalGovernanceEvidence.get(item); + if (!matcherGroups || matcherGroups.length === 0) return false; + return matcherGroups.some((group) => governanceMatcherGroupSatisfied(group, checkedEntries)); +} + +// Used by ci.yml to detect which governance items an existing PR body already +// satisfies (including legacy phrasing) before syncing the checklist. +export function collectSatisfiedGovernanceItems(value) { + const checkedEntries = checkedChecklistEntries(value); + return requiredClinicalGovernanceItems.filter((item) => governanceItemSatisfied(checkedEntries, item)); +} + +// Tolerant matching for the policy gate itself: affirm every item is checked +// without demanding the exact required wording. Authors may lightly reword or +// reformat the checklist, but a clinical-risk PR must leave no box unchecked +// and must cover at least the required number of governance items. function governanceBoxStats(value) { const source = String(value ?? ""); return { @@ -192,10 +255,6 @@ export function evaluatePullRequestPolicy({ title, body, headRef, files }) { if (!meaningfulText(governance)) { errors.push("Clinical-risk paths require the `## Clinical Governance Preflight` section."); } else { - // Tolerant matching: affirm every item is checked without demanding the - // exact required wording. Authors may lightly reword or reformat the - // checklist, but a clinical-risk PR must leave no box unchecked and must - // cover at least the required number of governance items. const { checked, unchecked } = governanceBoxStats(governance); if (unchecked > 0 || checked < requiredClinicalGovernanceItems.length) { errors.push( @@ -411,6 +470,51 @@ function selfTest() { section("### Summary ###\n\n- concise summary\n\n### Verification\n\n- [x] `npm run verify:pr-local`\n", "Summary"), "- concise summary", ); + // Duplicate section headings: content from all occurrences must be combined + // so that a prose block followed by a checklist block (same heading) is + // treated as a single section for checklist matching purposes. + assert.equal( + evaluatePullRequestPolicy({ + title: "ci: enforce pull request evidence", + body: + "## Summary\n\n- Add a trusted PR policy.\n\n## Verification\n\n- [x] `npm run verify:pr-local`\n- [x] `npm run verify:ui`\n\n## Risk and rollout\n\n- Risk: low; metadata-only validation.\n- Rollback: revert the workflow commit.\n\n## Clinical Governance Preflight\n\nProse rationale for the change.\n\n## Clinical Governance Preflight\n\n" + + completeGovernance, + headRef: "codex/pr-policy", + files: [".github/workflows/pr-policy.yml"], + }).ok, + true, + "duplicate section headings should combine content for checklist matching", + ); + assert.deepEqual( + collectSatisfiedGovernanceItems(`- [x] This change does not alter citation requirements, source verification, provenance, or clinical-content governance. +- [x] This change does not alter privacy controls, patient-data handling, authentication, authorization, or document-access behavior. +- [x] Any Supabase service-role credentials used by the migration-history diagnostic remain CI/server-side only and are not exposed to clients. +- [x] This change does not alter the configured Supabase project/target or perform a production database migration. +- [x] No SaMD/TGA assessment is required because this change has no clinical decision-support impact.`), + requiredClinicalGovernanceItems, + "legacy governance attestations should map onto required checklist coverage", + ); + assert.doesNotMatch( + collectSatisfiedGovernanceItems( + "- [x] This change does not alter citation requirements, source verification, or clinical-content governance.", + ).join("\n"), + new RegExp(requiredClinicalGovernanceItems[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + "legacy coverage should fail when a required signal (provenance) is missing", + ); + assert.deepEqual( + collectSatisfiedGovernanceItems(`- [x] ${requiredClinicalGovernanceItems[2]} +- [x] This change does not alter citation requirements, source verification, provenance, or clinical-content governance. +- [x] This change does not alter privacy controls, patient-data handling, authentication, authorization, or document-access behavior. +- [x] Any Supabase service-role credentials used by the migration-history diagnostic remain CI/server-side only and are not exposed to clients. +- [x] No SaMD/TGA assessment is required because this change has no clinical decision-support impact.`), + requiredClinicalGovernanceItems, + "mixed canonical + legacy governance attestations should satisfy all required items", + ); + assert.equal( + collectSatisfiedGovernanceItems("- [x] Legacy governance text without the expected policy keywords.").length, + 0, + "malformed legacy attestations must not satisfy governance coverage", + ); const template = readFileSync(new URL("../.github/pull_request_template.md", import.meta.url), "utf8"); for (const item of requiredClinicalGovernanceItems) assert.match(template, new RegExp(`- \\[ \\] ${item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); diff --git a/supabase/migrations/20260713110000_historical_version_placeholder.sql b/supabase/migrations/20260713110000_historical_version_placeholder.sql new file mode 100644 index 000000000..76327ae87 --- /dev/null +++ b/supabase/migrations/20260713110000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Renamed to 20260713062107 to match applied remote history (#552). +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/supabase/migrations/20260713120000_historical_version_placeholder.sql b/supabase/migrations/20260713120000_historical_version_placeholder.sql new file mode 100644 index 000000000..6303d926a --- /dev/null +++ b/supabase/migrations/20260713120000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Renamed to 20260713062125 to match applied remote history (#552). +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/supabase/migrations/20260713121000_historical_version_placeholder.sql b/supabase/migrations/20260713121000_historical_version_placeholder.sql new file mode 100644 index 000000000..a551f358e --- /dev/null +++ b/supabase/migrations/20260713121000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Renamed to 20260713062132 to match applied remote history (#552). +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/supabase/migrations/20260713122000_historical_version_placeholder.sql b/supabase/migrations/20260713122000_historical_version_placeholder.sql new file mode 100644 index 000000000..b6092e660 --- /dev/null +++ b/supabase/migrations/20260713122000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Renamed to 20260713062139 to match applied remote history (#552). +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/supabase/migrations/20260717133000_historical_version_placeholder.sql b/supabase/migrations/20260717133000_historical_version_placeholder.sql new file mode 100644 index 000000000..deb0f88fb --- /dev/null +++ b/supabase/migrations/20260717133000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Superseded by postgres-safe ACL assertions under 20260719055541. +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/supabase/migrations/20260718223000_historical_version_placeholder.sql b/supabase/migrations/20260718223000_historical_version_placeholder.sql new file mode 100644 index 000000000..646f809d0 --- /dev/null +++ b/supabase/migrations/20260718223000_historical_version_placeholder.sql @@ -0,0 +1,11 @@ +-- Historical migration version placeholder. +-- +-- Renumbered to 20260719055623_enforce_public_title_word_scope.sql (#914). +-- +-- Hosted Supabase Preview fails with +-- "Remote migration versions not found in local migrations directory" +-- when schema_migrations still records this version after a rename/renumber. +-- The durable effects live in the replacement migration(s) named above. +-- This file keeps local history complete for Preview without re-applying work. + +select 1; diff --git a/tests/migration-history-placeholders.test.ts b/tests/migration-history-placeholders.test.ts new file mode 100644 index 000000000..d48ce1e14 --- /dev/null +++ b/tests/migration-history-placeholders.test.ts @@ -0,0 +1,32 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * When migrations are renamed/renumbered, hosted Supabase Preview fails if the + * remote history table still records the old version. Keep a local file for + * every historically deleted version number that is no longer represented by a + * current migration filename prefix. + */ +const KNOWN_ORPHAN_VERSIONS = [ + "20260713110000", + "20260713120000", + "20260713121000", + "20260713122000", + "20260717133000", + "20260718223000", +] as const; + +describe("migration history placeholders", () => { + it("keeps a local sql file for every known orphan remote version", () => { + const migrationsDir = join(process.cwd(), "supabase/migrations"); + const versions = new Set( + readdirSync(migrationsDir) + .map((name) => /^(\d{14})_.*\.sql$/.exec(name)?.[1] ?? null) + .filter((version): version is string => Boolean(version)), + ); + + const missing = KNOWN_ORPHAN_VERSIONS.filter((version) => !versions.has(version)); + expect(missing).toEqual([]); + }); +}); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index c7cdbf902..537c9751e 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -613,6 +613,7 @@ describe("Supabase schema Data API grants", () => { ["audit_logs", 2], ["audit_logs_service_role_policy", 2], ["enforce_public_title_word_scope", 2], + ["historical_version_placeholder", 6], ["indexing_reliability_recovery", 2], ["ingestion_jobs_one_open_per_document", 2], ["rag_queries_retention", 2],