|
| 1 | +#!/usr/bin/env node |
| 2 | +// A migration that has shipped in a release is IMMUTABLE (#9420 regression). |
| 3 | +// |
| 4 | +// THE INCIDENT THIS EXISTS FOR: #9420 updated a doc COMMENT inside migrations/0180_decision_ledger.sql -- |
| 5 | +// no DDL change at all -- and shipped it in orb-v3.5.0. But runSelfHostMigrations (src/selfhost/migrate.ts) |
| 6 | +// records a sha256 of each applied migration's FULL TEXT and, on every boot, re-hashes the on-disk file and |
| 7 | +// throws `selfhost_migration_content_drift` if it differs. Comments are part of that text. So the moment any |
| 8 | +// already-upgraded deployment pulled the new image it would refuse to start -- not degrade, not warn: fail |
| 9 | +// to boot, with the review pipeline down until a human restored the file. Nothing caught it: the existing |
| 10 | +// db:migrations:check guards NUMBERING (collisions, gaps, filenames), and git reports a clean one-file diff |
| 11 | +// because editing a file is not a conflict. |
| 12 | +// |
| 13 | +// The rule is therefore mechanical, and this is the check that enforces it: once a migration file exists in |
| 14 | +// any released `orb-v*` tag, its bytes may never change again. Not the DDL, not a typo, not a comment. |
| 15 | +// Forward-only means forward-only -- to change what a migration DID, add a new one; to change what it SAYS, |
| 16 | +// put the prose in the source module that reads the table (see src/review/decision-record.ts's header for |
| 17 | +// exactly this split). |
| 18 | +// |
| 19 | +// Deleting a released migration is likewise refused. migrate.ts tolerates a ledger row whose file has |
| 20 | +// vanished (it skips unknown names), but a fresh deployment would then build a different schema than every |
| 21 | +// existing one -- a silent divergence this check would rather stop at the PR. |
| 22 | +import { execFileSync } from "node:child_process"; |
| 23 | +import { readdirSync } from "node:fs"; |
| 24 | +import { join } from "node:path"; |
| 25 | +import { MIGRATION_REBASELINE } from "./migration-rebaseline"; |
| 26 | + |
| 27 | +/** A released tag and the migration blobs it shipped, as `name -> blob sha`. */ |
| 28 | +export type ReleasedTagManifest = { tag: string; files: ReadonlyMap<string, string> }; |
| 29 | + |
| 30 | +export type MigrationViolation = { file: string; tag: string; kind: "modified" | "deleted" }; |
| 31 | + |
| 32 | +/** |
| 33 | + * PURE core: any file whose blob sha differs from (or is missing versus) the one it was FIRST released with. |
| 34 | + * |
| 35 | + * The baseline is deliberately the EARLIEST tag that shipped each file, not every tag. A file's first |
| 36 | + * release is when it froze: that is the content the oldest deployments applied and recorded a hash for, and |
| 37 | + * they are both the most numerous and the ones with the most history to lose. Checking against every tag |
| 38 | + * would be wrong here for a concrete reason -- when a released migration HAS been mutated (the #9420 |
| 39 | + * incident), the released tags themselves disagree with each other, so no content could satisfy all of them |
| 40 | + * and the check could never go green again, not even after the correct repair. |
| 41 | + * |
| 42 | + * `released` must be ordered oldest-first; first sighting of a file wins. |
| 43 | + */ |
| 44 | +export function findMutatedReleasedMigrations( |
| 45 | + released: readonly ReleasedTagManifest[], |
| 46 | + current: ReadonlyMap<string, string>, |
| 47 | +): MigrationViolation[] { |
| 48 | + const frozen = new Map<string, { tag: string; blob: string }>(); |
| 49 | + for (const { tag, files } of released) { |
| 50 | + for (const [file, blob] of files) if (!frozen.has(file)) frozen.set(file, { tag, blob }); |
| 51 | + } |
| 52 | + // Files edited before this guard existed are re-frozen at their current content instead of their first |
| 53 | + // release -- see migration-rebaseline.ts for why that is safe here and why the table must never grow. |
| 54 | + for (const [file, blob] of MIGRATION_REBASELINE) { |
| 55 | + const existing = frozen.get(file); |
| 56 | + if (existing) frozen.set(file, { tag: existing.tag, blob }); |
| 57 | + } |
| 58 | + |
| 59 | + const violations: MigrationViolation[] = []; |
| 60 | + for (const [file, { tag, blob }] of frozen) { |
| 61 | + const currentBlob = current.get(file); |
| 62 | + if (currentBlob === undefined) violations.push({ file, tag, kind: "deleted" }); |
| 63 | + else if (currentBlob !== blob) violations.push({ file, tag, kind: "modified" }); |
| 64 | + } |
| 65 | + return violations.sort((a, b) => a.file.localeCompare(b.file)); |
| 66 | +} |
| 67 | + |
| 68 | +function git(...args: string[]): string { |
| 69 | + return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); |
| 70 | +} |
| 71 | + |
| 72 | +/** `migrations/*.sql` at a given rev, as `name -> blob sha`. */ |
| 73 | +function migrationsAt(rev: string): Map<string, string> { |
| 74 | + const files = new Map<string, string>(); |
| 75 | + for (const line of git("ls-tree", "-r", rev, "--", "migrations/").split("\n")) { |
| 76 | + // `<mode> blob <sha>\t<path>` |
| 77 | + const match = /^\d+ blob ([0-9a-f]+)\t(migrations\/.+\.sql)$/.exec(line); |
| 78 | + if (match?.[1] && match[2]) files.set(match[2].slice("migrations/".length), match[1]); |
| 79 | + } |
| 80 | + return files; |
| 81 | +} |
| 82 | + |
| 83 | +/** Released ORB tags, oldest first, so the earliest tag to freeze a file is the one reported. */ |
| 84 | +export function releasedOrbTags(): string[] { |
| 85 | + return git("tag", "-l", "orb-v*", "--sort=creatordate").split("\n").filter(Boolean); |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * The migrations as they exist ON DISK, hashed with git's own blob algorithm so they compare directly |
| 90 | + * against `ls-tree` output. |
| 91 | + * |
| 92 | + * Deliberately NOT `ls-tree HEAD`: that reads the committed tree and is blind to uncommitted edits, so the |
| 93 | + * check would go green locally on exactly the change it exists to reject and only fail later in CI. Reading |
| 94 | + * the working tree makes it usable as a pre-commit check and makes what it reports match what the author is |
| 95 | + * actually about to ship. |
| 96 | + */ |
| 97 | +function migrationsOnDisk(): Map<string, string> { |
| 98 | + const files = new Map<string, string>(); |
| 99 | + const names = readdirSync("migrations").filter((name) => name.endsWith(".sql")).sort(); |
| 100 | + if (names.length === 0) return files; |
| 101 | + // One batched hash-object call: 200 separate spawns is the difference between instant and noticeable. |
| 102 | + const hashes = git("hash-object", "--", ...names.map((name) => join("migrations", name))).split("\n").filter(Boolean); |
| 103 | + names.forEach((name, index) => { |
| 104 | + const hash = hashes[index]; |
| 105 | + if (hash) files.set(name, hash); |
| 106 | + }); |
| 107 | + return files; |
| 108 | +} |
| 109 | + |
| 110 | +function main(): void { |
| 111 | + const tags = releasedOrbTags(); |
| 112 | + if (tags.length === 0) { |
| 113 | + // A shallow clone or a fork with no tags cannot evaluate this rule. Say so rather than passing silently: |
| 114 | + // a check that quietly becomes a no-op is how the thing it guards comes back. |
| 115 | + console.error("released-migrations-immutable: no orb-v* tags visible — fetch tags (`git fetch --tags`) so this check can run."); |
| 116 | + process.exit(1); |
| 117 | + } |
| 118 | + const released = tags.map((tag) => ({ tag, files: migrationsAt(tag) })); |
| 119 | + const violations = findMutatedReleasedMigrations(released, migrationsOnDisk()); |
| 120 | + |
| 121 | + if (violations.length > 0) { |
| 122 | + console.error("A migration that already shipped in a release was changed. Released migrations are immutable:\n"); |
| 123 | + for (const { file, tag, kind } of violations) { |
| 124 | + console.error(` migrations/${file} — ${kind} (first released in ${tag})`); |
| 125 | + } |
| 126 | + console.error( |
| 127 | + "\n Every deployment that already applied one of these recorded a sha256 of its FULL text (comments\n" + |
| 128 | + " included). src/selfhost/migrate.ts re-hashes on every boot and throws selfhost_migration_content_drift\n" + |
| 129 | + " on a mismatch, so shipping this would make every already-upgraded ORB FAIL TO BOOT.\n\n" + |
| 130 | + " To change what a migration DID: add a new migrations/NNNN_*.sql.\n" + |
| 131 | + " To change what it SAYS: put the prose in the source module that reads the table, not the .sql.\n" + |
| 132 | + " To undo an accidental edit: git checkout <tag> -- migrations/<file>", |
| 133 | + ); |
| 134 | + process.exit(1); |
| 135 | + } |
| 136 | + const frozen = new Set(released.flatMap(({ files }) => [...files.keys()])).size; |
| 137 | + console.log(`released-migrations-immutable: OK — ${frozen} released migration(s) unchanged across ${tags.length} orb-v tag(s).`); |
| 138 | +} |
| 139 | + |
| 140 | +if (process.argv[1]?.endsWith("check-released-migrations-immutable.ts")) main(); |
0 commit comments