Public: anyone can read this; the crew can change it.
Migration pins leave the release cut: pinning a migration never commits to main #6647
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6646-migration-pins-leave-the-release-cut-pin → main 11 commits fork 6 threads · 6 unresolved
CLAUDE.md +4 −2
| @@ -283,8 +283,10 @@ flag exists only in the stdio dev runner and is never reachable over SSH. | ||
| 283 | 283 | records each name), so two pulls' migrations land in either order, and a |
| 284 | 284 | database with one this build does not know boots and names it. Never edit |
| 285 | 285 | an existing migration — an edited one is a new one and runs again; once |
| 286 | pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run | |
| 287 | by the release agent, never by a pull) the schema test fails on an edit. It must only add | |
| 286 | pinned the schema test fails on an edit: the pull that adds a migration | |
| 287 | runs `bun scripts/pin-migrations.ts` and commits the one file it writes, | |
| 288 | `core/migration-pins/<name>.pin` (`core/migration-pins.json` is the frozen | |
| 289 | older pins); the release never touches pins. A migration must only add | |
| 288 | 290 | (`test/schema-additive.test.ts`). A release is an annotated tag whose |
| 289 | 291 | message is its record (`bun scripts/cut-release.ts <version> --tag`, |
| 290 | 292 | read by `release/shipped.ts`); no pull and no release touches |
scripts/pin-migrations.ts +22 −14
| @@ -1,33 +1,41 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `bun scripts/pin-migrations.ts`: pin every migration in |
| 3 | * `src/core/migrations/` that `src/core/migration-pins.json` does not | |
| 4 | * name yet, by its id (`migrationId`). An existing pin is never rewritten: | |
| 5 | * once pinned, a migration's text is history, and | |
| 6 | * `test/schema-migrations.test.ts` fails if it changes (#783). | |
| 3 | * `src/core/migrations/` that is not pinned yet, as its own file | |
| 4 | * `src/core/migration-pins/<name>.pin` holding its id (`migrationId`). | |
| 5 | * An existing pin is never rewritten: once pinned, a migration's text is | |
| 6 | * history, and `test/schema-migrations.test.ts` fails if it changes (#783). | |
| 7 | 7 | * |
| 8 | * The release agent runs it, never a pull, so two pulls adding a | |
| 9 | * migration each never meet in the file. The release record itself is a | |
| 10 | * tag and moves no branch (`scripts/cut-release.ts`). | |
| 8 | * The pull that adds a migration runs this and commits the pin beside it. | |
| 9 | * One file per migration means parallel pulls never meet in a shared file, | |
| 10 | * and nothing commits to main at release time (#6646). The release record | |
| 11 | * itself is a tag and moves no branch (`scripts/cut-release.ts`). | |
| 12 | * `src/core/migration-pins.json` is the frozen pins from before. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import { readFileSync, writeFileSync } from "node:fs"; |
| 14 | 16 | import { join } from "node:path"; |
| 15 | 17 | import { migrationFiles } from "../src/core/schema.ts"; |
| 16 | import { type MigrationPins, pinBreaks, pinned } from "../src/kernel/migrate.ts"; | |
| 18 | import { | |
| 19 | type MigrationPins, | |
| 20 | migrationId, | |
| 21 | pinBreaks, | |
| 22 | readPins, | |
| 23 | } from "../src/kernel/migrate.ts"; | |
| 17 | 24 | |
| 18 | const file = join(import.meta.dir, "../src/core/migration-pins.json"); | |
| 19 | const pins = JSON.parse(readFileSync(file, "utf8")) as MigrationPins; | |
| 25 | const core = join(import.meta.dir, "../src/core"); | |
| 26 | const legacy = JSON.parse(readFileSync(join(core, "migration-pins.json"), "utf8")) as MigrationPins; | |
| 27 | const dir = join(core, "migration-pins"); | |
| 28 | const pins = readPins(dir, legacy); | |
| 20 | 29 | const breaks = pinBreaks(migrationFiles, pins); |
| 21 | 30 | if (breaks.length > 0) { |
| 22 | 31 | for (const line of breaks) console.error(`pin-migrations: ${line}`); |
| 23 | 32 | process.exit(1); |
| 24 | 33 | } |
outdated
This replaces |
||
| 25 | const next = pinned(migrationFiles, pins); | |
| 26 | const added = Object.keys(next).filter((name) => !(name in pins)); | |
| 27 | writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`); | |
| 34 | const added = migrationFiles.filter((m) => !(m.name in pins)); | |
| 35 | for (const m of added) writeFileSync(join(dir, `${m.name}.pin`), `${migrationId(m.sql)}\n`); | |
Low: nothing tests this write path. A small test could run the script on a temporary core directory, or this line could be pulled into a pure |
||
| 28 | 36 | // CLI answer for the person at the terminal. |
| 29 | 37 | console.log( |
| 30 | 38 | added.length === 0 |
| 31 | 39 | ? "pin-migrations: every migration is already pinned" |
| 32 | : `pin-migrations: pinned ${added.join(", ")}`, | |
| 40 | : `pin-migrations: pinned ${added.map((m) => m.name).join(", ")}`, | |
| 33 | 41 | ); |
src/core/migration-pins/20261019130000-crew-teams.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 3f18731f13df744c3ca48127e79827c95d721dc3bbd1d5814ce3b8c597b1d1f8 |
src/core/migration-pins/20261019160000-app-vitals.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 5757b81a1e0aa6a5c81b414c5951fcedfab43e558acf13f4e598b425f25c8c38 |
src/core/migration-pins/20261019160000-issue-teams.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | bf4729c0ef2aa1f977e6f9c587c9a75684a73e6f5a65e408f2a77a78ff14daa9 |
src/core/migration-pins/20261019170000-edge-links.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | a22e6e8a1cf7463d9028d7603ed4ca52576f82dde6bfa61fbb8051d59ff90a7e |
src/core/migration-pins/20261019180000-board-pins.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c3c7a15ef0ae42ee8a79b480eaf882076f0b1f4a2714e1a3e09f695e2315d8be |
src/core/migration-pins/20261019180000-parent-link.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 84dd42203e143cec7cb4d7a413aacd2c32099e82ef481a0ff9472b5e0e74fd41 |
src/core/migration-pins/20261020010000-anon-spawns.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 2434275b464e0e83d7c0fda92949ff9697579cdd3ff443633eb3a4ee7ca7d94f |
src/core/migration-pins/20261020090000-webhook-refusal.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | d8a75be36c8fe1ca1eafc3de355873336ae0503b38e85753e3bcef4cdc27be70 |
src/core/migration-pins/20261020100000-preview-findings.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 984f36a2285b39c038a87369d6c81d0ae7f6fde0156fec5db3e1e15a09055db4 |
src/core/migration-pins/20261020110000-a-look-has-a-theme.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | b057087f7346f7ed7e156af7841be4f8fd0a7f121d4d4a3906554da139a30b93 |
src/core/migration-pins/20261020130000-machine-serving.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 09a9251efc56caf58661a606b3217639056ea3dca294d6c49412444fe1396a5a |
src/core/migration-pins/20261020180000-origin-repo-size.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 99ce32c0dd714006403ede235f4d1fbe4cab54860e0ac8421a068cfe7bfd17a9 |
src/core/migration-pins/20261020180000-repo-provenance.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | f8b27b72ef7a17a361a1d7b6c28d1d3ff7484e3b043edf896b718f811ab50822 |
src/core/migration-pins/20261021090000-egress-grants.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | b69e9d3a78bbfd87d3962817de5456e066b07473fdb897aac0984187666431d1 |
src/core/migration-pins/20261021100000-migrate-mirror-target.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | e0929ff22611bd6f9d152d311b02b1f389af38e0fc2df045e3d1764182603bbe |
src/core/migration-pins/20261022090000-milestone-origin.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 6bfe2d3c402da68827e26f5a8d330086bfa9d111ff8463d94fe5e60ef4e8d976 |
src/core/migration-pins/20261022100000-repo-upstream.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 53b4531f571bd5e51a04380c6f7a40bd23665f194131e74fe86b761f7d424ae6 |
src/core/migration-pins/20261022110000-run-tokens.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c4daa4de89b981e655fa3ca3808f755682e27081c3d5e4425f93869302c85e47 |
src/core/migration-pins/20261022120000-flow-indexes.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | ba98b5800fc060727013e01994ba36a6df8db10b1d5ec107119311f10d6df0bb |
src/core/migration-pins/20261022130000-app-notes.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | d92914e16b21da7fdb388925428b59a89137976bcf9d61a527fac2d1ff439394 |
src/core/migration-pins/20261022140000-note-pictures.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | e23b5399e7e3dcf527e7988b345ccc62b85520c2eb78423fa34dc284fea9a137 |
src/core/migration-pins/20261025090000-upstream-check-history.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 803f4f754a85a2d766f87529512dd31cb34a01486491886d31e1d83bcb7eeabe |
src/core/migration-pins/20261025120000-run-error-line.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 2509b10b0408df4e88af1e5046bed9017b06dbb03cfdf3ebc337a5a1c753f8c3 |
src/core/migration-pins/20261026090000-event-actor-index.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 11568c875c9394ec16a6a935072cf29ea76f0c52c5be37139b3696a2ba698546 |
src/core/migration-pins/20261026120000-front-epoch.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 2de9998e760be62a5bc1b237c8439f63883d15d306493ffd44e040f8d2f2fa4e |
src/core/migration-pins/20261026174505-queue-batch.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 45fd4c236c1fee0dcf0f2b107d36f851aecea9bd50f110241a6101841a1da551 |
src/core/migration-pins/20261026174649-check-gate.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 96aaf03e67f6e7290cabe77e25fcc8ad8af5f91227195a544bb3bb63ac82f530 |
src/core/migration-pins/20261026180000-file-hold.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 1826e2542eef31019a656e15fe68d6a9c33f3697dc78bb215fe0eb0d68e1fa8a |
src/core/migration-pins/20261026190000-check-flake.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | fed85e1907538d33a9307866c2f0f375371ffc1a507112e5c72709eabc7a64ce |
src/core/migration-pins/20261026215105-queue-gate.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | d243581b876543707589b59de585d360054c9c671f5c2b1ca230126f4f208939 |
src/core/migration-pins/20261027010000-clog-watch.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 45f989a67b7c0223f1e7bee9c6cde30e0a9e2d567737f7cbf0866e09e6342b43 |
src/core/migration-pins/20261027041521-gate-result.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c688381bbf55aa366272d2caa90671e593b6fc914c8316ab3ed8e37f0650b6f1 |
src/core/migration-pins/20261027090000-home-visit.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 931197e3c618fc90130d7de09001595e4f640f8d06d7cb42778ecad6be3ffd72 |
src/core/migration-pins/20261027100000-clog-peak.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 03775f3b8c539a002bb2359a4a693d9b4c7b60f913dd7f3302ff72261018cfa6 |
src/core/releases-before-tags.json +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "note": "The releases cut before #6642, newest first, and frozen: nothing adds to this file. A release since is an annotated tag `v<version>` on the forge's own repository whose message is its record, `{ \"version\", \"migrations\", \"ids\" }` (`bun scripts/cut-release.ts <version>` writes it; src/release/shipped.ts reads both). `migrations` counts the migration ids a release applied, and `ids` names the stamped ones among them, every file past 0090 by its name without .ts; a release from before #1837 has a count only: the first that many numbered files. test/schema-additive.test.ts holds every migration not in both of the two newest releases here to additive (notAdditive in src/kernel/migrate.ts).", | |
| 2 | "note": "The releases cut before #6642, newest first, and frozen: nothing adds to this file. A release since is an annotated tag `v<version>` on the forge's own repository whose message is its record, `{ \"version\", \"migrations\", \"ids\" }` (`bun scripts/cut-release.ts <version>` writes it; src/release/shipped.ts reads both). `migrations` counts the migration ids a release applied, and `ids` names the stamped ones among them, every file past 0090 by its name without .ts; a release from before #1837 has a count only: the first that many numbered files. test/schema-additive.test.ts holds every migration not in both of the two newest releases here to additive (notAdditive in src/kernel/migrate.ts). Pins are not the release's: each pull pins its own migration (`bun scripts/pin-migrations.ts`, src/core/migration-pins/).", | |
| 3 | 3 | "releases": [ |
| 4 | 4 | { |
| 5 | 5 | "version": "0.7.271", |
src/core/schema.ts +2 −2
| @@ -10,8 +10,8 @@ | ||
| 10 | 10 | * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two |
| 11 | 11 | * pulls that each add one add two files and never touch the same line |
| 12 | 12 | * (#1837). Each file is one `export default` of SQL. The database records |
| 13 | * each applied one by name (#783); `migration-pins.json` holds what each | |
| 14 | * said when it was released, and a test fails if one says something else. | |
| 13 | * each applied one by name (#783); `migration-pins/` (one file each, written by the | |
| 14 | * pull that adds it) and `migration-pins.json` (older) hold what each said, and a test fails if one says something else. | |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import type { Database } from "bun:sqlite"; |
src/kernel/migrate.ts +23 −17
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | 28 | import type { Database } from "bun:sqlite"; |
| 29 | import { readdirSync } from "node:fs"; | |
| 29 | import { readdirSync, readFileSync } from "node:fs"; | |
| 30 | 30 | import { join } from "node:path"; |
| 31 | 31 | import { defect } from "./error.ts"; |
| 32 | 32 | import { sha256 } from "./hash.ts"; |
| @@ -81,6 +81,28 @@ export function migrationId(sql: string): string { | ||
| 81 | 81 | /** Each pinned migration's id, by name: what it said when it was pinned. */ |
| 82 | 82 | export type MigrationPins = Readonly<Record<string, string>>; |
| 83 | 83 | |
| 84 | /** | |
| 85 | * Every pin: `legacy` (the one file that held them all, now frozen) and one | |
| 86 | * `<name>.pin` file in `dir` per migration since, holding its id. The pull | |
| 87 | * that adds a migration adds its pin file, so pins never meet in a shared | |
| 88 | * file and never wait for a release. | |
| 89 | */ | |
| 90 | export function readPins(dir: string, legacy: MigrationPins): MigrationPins { | |
| 91 | const all: Record<string, string> = { ...legacy }; | |
| 92 | for (const file of readdirSync(dir).sort()) { | |
Good: refusing a |
||
| 93 | if (!file.endsWith(".pin")) | |
| 94 | defect("db.migration_name", `${file} in ${dir} is not <name>.pin`); | |
| 95 | const name = file.slice(0, -".pin".length); | |
| 96 | if (name in legacy) | |
| 97 | defect("db.pin_repinned", `${file} would re-pin ${name}, which is already pinned`); | |
outdated
Nit: Good: this closes the hole where a |
||
| 98 | const id = readFileSync(join(dir, file), "utf8").trim(); | |
outdated
A |
||
| 99 | if (!/^[0-9a-f]{64}$/.test(id)) | |
| 100 | defect("db.pin_malformed", `${file} does not hold a sha256 id`); | |
| 101 | all[name] = id; | |
| 102 | } | |
| 103 | return all; | |
| 104 | } | |
| 105 | ||
| 84 | 106 | /** |
| 85 | 107 | * How `files` differ from what was pinned: one line for each pinned |
| 86 | 108 | * migration whose text says something else now, or that is gone. A |
| @@ -102,22 +124,6 @@ export function pinBreaks( | ||
| 102 | 124 | return breaks; |
| 103 | 125 | } |
| 104 | 126 | |
| 105 | /** | |
| 106 | * `pins` with every migration in `files` it lacks added; a pin already | |
| 107 | * there is never rewritten. Sorted by name, so the file it is written to | |
| 108 | * changes only where a migration was added. | |
| 109 | */ | |
| 110 | export function pinned( | |
| 111 | files: readonly MigrationFile[], | |
| 112 | pins: MigrationPins, | |
| 113 | ): MigrationPins { | |
| 114 | const next: Record<string, string> = { ...pins }; | |
| 115 | for (const m of files) next[m.name] ??= migrationId(m.sql); | |
| 116 | return Object.fromEntries( | |
| 117 | Object.entries(next).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | 127 | /** |
| 122 | 128 | * A migration as `migrate` takes it: its file, which names it, or bare SQL — |
| 123 | 129 | * a test's own list — which is known by its position alone. |
test/schema-migrations.test.ts +21 −15
| @@ -29,13 +29,15 @@ import { | ||
| 29 | 29 | migrate, |
| 30 | 30 | migrationId, |
| 31 | 31 | pinBreaks, |
| 32 | pinned, | |
| 33 | 32 | readMigrations, |
| 33 | readPins, | |
| 34 | 34 | statements, |
| 35 | 35 | } from "../src/kernel/migrate.ts"; |
| 36 | 36 | import { migrationFiles, migrations, NUMBERED } from "../src/core/schema.ts"; |
| 37 | 37 | import { failureReason } from "../src/apps/process.ts"; |
| 38 | import pins from "../src/core/migration-pins.json" with { type: "json" }; | |
| 38 | import legacyPins from "../src/core/migration-pins.json" with { type: "json" }; | |
| 39 | ||
| 40 | const pins = readPins(join(import.meta.dir, "../src/core/migration-pins"), legacyPins); | |
| 39 | 41 | |
| 40 | 42 | const root = mkdtempSync(join(tmpdir(), "forge-schema-")); |
| 41 | 43 | afterAll(() => rmSync(root, { recursive: true, force: true })); |
| @@ -425,8 +427,22 @@ describe("a migration on main is never edited (#783)", () => { | ||
| 425 | 427 | expect(pinBreaks(migrationFiles, pins)).toEqual([]); |
| 426 | 428 | }); |
| 427 | 429 | |
| 428 | test("every numbered migration is pinned", () => { | |
| 429 | for (const m of migrationFiles.slice(0, NUMBERED)) expect(Object.hasOwn(pins, m.name)).toBe(true); | |
| 430 | test("every migration is pinned by its own pull: run bun scripts/pin-migrations.ts", () => { | |
| 431 | const missing = migrationFiles.filter((m) => !Object.hasOwn(pins, m.name)).map((m) => m.name); | |
| 432 | expect(missing).toEqual([]); | |
| 433 | }); | |
| 434 | ||
| 435 | test("pin files are read beside the frozen list, and a bad one is a defect", () => { | |
| 436 | const dir = join(root, "pins"); | |
| 437 | mkdirSync(dir); | |
| 438 | const id = migrationId("CREATE TABLE a (x INTEGER)"); | |
| 439 | writeFileSync(join(dir, "20261015110000-a.pin"), `${id}\n`); | |
| 440 | expect(readPins(dir, { "0001-x": "f".repeat(64) })).toEqual({ "0001-x": "f".repeat(64), "20261015110000-a": id }); | |
| 441 | writeFileSync(join(dir, "20261015120000-b.pin"), "nope"); | |
| 442 | expect(() => readPins(dir, {})).toThrow(ForgeError); | |
| 443 | // A pin file cannot re-pin a frozen migration. | |
| 444 | rmSync(join(dir, "20261015120000-b.pin")); | |
| 445 | expect(() => readPins(dir, { "20261015110000-a": "f".repeat(64) })).toThrow(ForgeError); | |
| 430 | 446 | }); |
| 431 | 447 | |
| 432 | 448 | test("an edit after its pin is named, a removal too; re-indenting is not an edit", () => { |
| @@ -434,7 +450,7 @@ describe("a migration on main is never edited (#783)", () => { | ||
| 434 | 450 | { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }, |
| 435 | 451 | { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }, |
| 436 | 452 | ]; |
| 437 | const pin = pinned(files, {}); | |
| 453 | const pin = Object.fromEntries(files.map((f) => [f.name, migrationId(f.sql)])); | |
| 438 | 454 | expect(pinBreaks(files, pin)).toEqual([]); |
| 439 | 455 | expect(pinBreaks([files[0]!, { name: "20261015120000-b", sql: "CREATE TABLE b (y TEXT)" }], pin)).toEqual([ |
| 440 | 456 | "20261015120000-b was edited after it was pinned — add a new migration instead", |
| @@ -443,14 +459,4 @@ describe("a migration on main is never edited (#783)", () => { | ||
| 443 | 459 | expect(pinBreaks([{ name: "20261015110000-a", sql: "\n CREATE TABLE a\n (x INTEGER)\n" }, files[1]!], pin)).toEqual([]); |
| 444 | 460 | }); |
| 445 | 461 | |
| 446 | test("pinning adds what is new and never rewrites a pin", () => { | |
| 447 | const a = { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }; | |
| 448 | const b = { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }; | |
| 449 | const pin = pinned([b], {}); | |
| 450 | const edited = { ...b, sql: "CREATE TABLE b (y TEXT)" }; | |
| 451 | const next = pinned([a, edited], pin); | |
| 452 | expect(Object.keys(next)).toEqual([a.name, b.name]); | |
| 453 | expect(next[b.name]).toBe(pin[b.name]!); | |
| 454 | expect(next[a.name]).toBe(migrationId(a.sql)); | |
| 455 | }); | |
| 456 | 462 | }); |
Nit: after "the release never touches pins." the following "It must only add" reads as if it refers to the release. It meant the migration. Consider "A migration must only add".