Public: anyone can read this; the crew can change it.

Migration pins leave the release cut: pinning a migration never commits to main #6647

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6646-migration-pins-leave-the-release-cut-pin → main 11 commits fork 6 threads · 6 unresolved

  • Merged. scout/6646-migration-pins-leave-the-release-cut-pin was merged into main as 23709b9.

Review

Sign in to review this change.
40 files changed +107 −51
CLAUDE.md +4 −2
@@ -283,8 +283,10 @@ flag exists only in the stdio dev runner and is never reachable over SSH.
283283 records each name), so two pulls' migrations land in either order, and a
284284 database with one this build does not know boots and names it. Never edit
285285 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
outdated

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".

288290 (`test/schema-additive.test.ts`). A release is an annotated tag whose
289291 message is its record (`bun scripts/cut-release.ts <version> --tag`,
290292 read by `release/shipped.ts`); no pull and no release touches
scripts/pin-migrations.ts +22 −14
@@ -1,33 +1,41 @@
11 /**
22 * `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).
77 *
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.
1113 */
1214
1315 import { readFileSync, writeFileSync } from "node:fs";
1416 import { join } from "node:path";
1517 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";
1724
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);
2029 const breaks = pinBreaks(migrationFiles, pins);
2130 if (breaks.length > 0) {
2231 for (const line of breaks) console.error(`pin-migrations: ${line}`);
2332 process.exit(1);
2433 }
outdated

This replaces pinned(), which now has no non-test caller in src/kernel/migrate.ts. Delete it and its tests, or use it here.

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 pinFiles(files, pins) that returns [path, text] pairs, and it would cover the .pin naming and content that readPins later relies on.

2836 // CLI answer for the person at the terminal.
2937 console.log(
3038 added.length === 0
3139 ? "pin-migrations: every migration is already pinned"
32 : `pin-migrations: pinned ${added.join(", ")}`,
40 : `pin-migrations: pinned ${added.map((m) => m.name).join(", ")}`,
3341 );
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/20261019180000-board-pins.pin added +1
@@ -0,0 +1,1 @@
1 c3c7a15ef0ae42ee8a79b480eaf882076f0b1f4a2714e1a3e09f695e2315d8be
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 @@
11 {
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/).",
33 "releases": [
44 {
55 "version": "0.7.271",
src/core/schema.ts +2 −2
@@ -10,8 +10,8 @@
1010 * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two
1111 * pulls that each add one add two files and never touch the same line
1212 * (#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.
1515 */
1616
1717 import type { Database } from "bun:sqlite";
src/kernel/migrate.ts +23 −17
@@ -26,7 +26,7 @@
2626 */
2727
2828 import type { Database } from "bun:sqlite";
29 import { readdirSync } from "node:fs";
29 import { readdirSync, readFileSync } from "node:fs";
3030 import { join } from "node:path";
3131 import { defect } from "./error.ts";
3232 import { sha256 } from "./hash.ts";
@@ -81,6 +81,28 @@ export function migrationId(sql: string): string {
8181 /** Each pinned migration's id, by name: what it said when it was pinned. */
8282 export type MigrationPins = Readonly<Record<string, string>>;
8383
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 .pin for a name already in the frozen JSON keeps the frozen list frozen. It also stops the two sources from disagreeing about one migration, where one would silently win.

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: db.migration_missing is the wrong code here. The migration isn't missing, its pin is malformed.

Good: this closes the hole where a .pin could silently override a frozen pin. One gap remains, as the pull says: a pull that edits both a migration and its .pin still passes. Review is the guard for that, as it was with the JSON.

98 const id = readFileSync(join(dir, file), "utf8").trim();
outdated

A .pin here overwrites a legacy pin of the same name. That re-pins an edited frozen migration without touching migration-pins.json. Raise a defect when the name is already in legacy, and add a test for it.

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
84106 /**
85107 * How `files` differ from what was pinned: one line for each pinned
86108 * migration whose text says something else now, or that is gone. A
@@ -102,22 +124,6 @@ export function pinBreaks(
102124 return breaks;
103125 }
104126
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
121127 /**
122128 * A migration as `migrate` takes it: its file, which names it, or bare SQL —
123129 * a test's own list — which is known by its position alone.
test/schema-migrations.test.ts +21 −15
@@ -29,13 +29,15 @@ import {
2929 migrate,
3030 migrationId,
3131 pinBreaks,
32 pinned,
3332 readMigrations,
33 readPins,
3434 statements,
3535 } from "../src/kernel/migrate.ts";
3636 import { migrationFiles, migrations, NUMBERED } from "../src/core/schema.ts";
3737 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);
3941
4042 const root = mkdtempSync(join(tmpdir(), "forge-schema-"));
4143 afterAll(() => rmSync(root, { recursive: true, force: true }));
@@ -425,8 +427,22 @@ describe("a migration on main is never edited (#783)", () => {
425427 expect(pinBreaks(migrationFiles, pins)).toEqual([]);
426428 });
427429
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);
430446 });
431447
432448 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)", () => {
434450 { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" },
435451 { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" },
436452 ];
437 const pin = pinned(files, {});
453 const pin = Object.fromEntries(files.map((f) => [f.name, migrationId(f.sql)]));
438454 expect(pinBreaks(files, pin)).toEqual([]);
439455 expect(pinBreaks([files[0]!, { name: "20261015120000-b", sql: "CREATE TABLE b (y TEXT)" }], pin)).toEqual([
440456 "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)", () => {
443459 expect(pinBreaks([{ name: "20261015110000-a", sql: "\n CREATE TABLE a\n (x INTEGER)\n" }, files[1]!], pin)).toEqual([]);
444460 });
445461
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 });
456462 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs