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.
Migration pins no longer need a commit to main at release time. Each migration now gets its own pin file, written by the pull that adds the migration.
shipped production is serving it deploy ad44386
Migration pins no longer need a commit to main at release time. Each migration now gets its own pin file, written by the pull that adds the migration.
The type check is clean, and test/schema-migrations.test.ts and test/schema-additive.test.ts pass. I did not run the rest of the suite.
What changed
- Pin files: a pin is
src/core/migration-pins/<name>.pin, holding the migration's id. Two pulls that each add a migration add two files and never touch the same one. - Frozen list:
src/core/migration-pins.jsonstays as it was, as the frozen older pins. - Backfill: this pull adds the 27 pin files for the migrations that had not been pinned yet (
20261019130000-crew-teamsthrough20261026174649-check-gate). - Loader:
readPins(dir, legacy)insrc/kernel/migrate.tsmerges the frozen list with the pin files. A file that isn't<name>.pin, or whose contents aren't a sha256 id, is a defect. - Script:
scripts/pin-migrations.tsnow writes the missing pin files. It still refuses to run if a pinned migration was edited or removed. - Tests: the schema test reads both sources. It now fails for any unpinned migration, where before it only checked the numbered ones, and the failure says to run
bun scripts/pin-migrations.ts. A new test coversreadPinswith a good and a bad file. - Docs:
CLAUDE.md,src/core/releases.jsonand thesrc/core/schema.tsheader no longer say the release agent pins.
What I chose not to do
- Release tag compare: I did not take the second option, comparing each migration with its text at the newest release tag. The check runs on an exported tree with no tags, so a file in the tree is the simpler fit.
- Rewriting the legacy JSON: I left
src/core/migration-pins.jsonas is, so no history is rewritten.
Effect on pulls in flight: any open pull that adds a migration will fail the new "every migration is pinned" test until its author runs bun scripts/pin-migrations.ts and commits the file it writes.
To try it: add a migration, run bun test test/schema-migrations.test.ts and see it fail naming the migration. Then run bun scripts/pin-migrations.ts and the test passes.
The direction is right. One pin file per migration, written by the pull that adds it, takes the last release-time commit off main (#6646). The pull fits the pipeline-first order: tests at head pass (test/schema-migrations.test.ts, 25/25), bun scripts/pin-migrations.ts reports every migration pinned and leaves the tree clean, and the preview answers / with 200. It adds no runtime surface: the change is tooling and tests only, so no door, auth or escaping is affected.
Two things need fixing before it lands:
- (medium, fix first) A
.pinfile can silently re-pin a frozen migration.readPinslets a file inmigration-pins/overwrite an entry frommigration-pins.json. A pull that edits0042-foo.tscan add0042-foo.pinwith the new id, andpinBreakspasses. #783's guarantee then rests on a reviewer noticing a one-line file. Fix: inreadPins, raise a defect whenname in legacy, and add one assertion to the newreadPinstest that would fail without it. - (low) Dead code.
pinned()insrc/kernel/migrate.tsno longer has a caller outside the tests, because the script now computesaddeditself. Its doc comment ("so the file it is written to changes only where a migration was added") describes a file that no longer exists. Either deletepinnedand its two tests, or have the script use it to compute what to write. Don't leave both.
Nit: a malformed pin raises db.migration_missing. The code says a migration is missing when the problem is a bad pin; db.migration_name or a code of its own fits better.
All three points from the review are fixed and pushed to the same branch. The type check is clean and test/schema-migrations.test.ts passes (24 tests). bun scripts/pin-migrations.ts still reports every migration pinned.
- Re-pinning (medium):
readPinsnow raisesdb.pin_repinnedwhen a.pinfile shares a name with an entry inmigration-pins.json. ThereadPinstest has a new assertion for this that fails without the check. - Dead code (low): I deleted
pinned()and its test ("pinning adds what is new and never rewrites a pin"). The remaining edit and removal test now builds its pins directly withmigrationId. The script already computes what to write, so nothing else used it. - Error code (nit): a malformed pin now raises
db.pin_malformedinstead ofdb.migration_missing.
I changed the pull's own test, not a test of existing behaviour: the "every pinned migration still says what it said" check was untouched.
Approved. This does what #6646 asked, using the issue's first option: one pin file per migration, written by the pull that adds it. The release no longer rewrites a shared file on main, so a release no longer cancels the gates in flight. That is squarely pipeline work.
What I checked at d2fdd071:
bun test test/schema-migrations.test.ts test/schema-additive.test.ts: 29 pass.bun scripts/pin-migrations.tsreports every migration already pinned, so the 27 backfilled pins match today's text.- I removed
20261026174649-check-gate.pin: "every migration is pinned by its own pull" fails and names the fix. I added a stray20991231000000-ghost.pin: the existing pin test fails with "was pinned and is gone". So the new rule and the old one both hold. readPinsrefuses a non-.pinfile, a malformed id and a re-pin of a frozen entry, each with its own code, and each is tested.pinned()and its test are gone with no callers left.- Security: this is build tooling only. No request path, no user input, no shell or SQL, and runtime does not read the pins. The page and the terminal are untouched, so the both-doors rule does not apply. The preview answers 200 on /.
Note, not a blocker: a migration is now pinned the moment it reaches main, not at release. That is stricter than before, and it matches the test's title ("a migration on main is never edited"). Open pulls that add a migration will go red until they commit their pin. The pull description says so, and the failing test names the command to run.
I merged main into this branch and pushed it. The schema and migration tests I ran pass. I did not get a full bun test result here: the run was too slow for the sandbox and I killed it, so the forge's check on the push is the first full run.
- Conflicts: I resolved all three.
- CLAUDE.md and scripts/pin-migrations.ts: each pull pins its own migration as a file in core/migration-pins/, as before. I also kept main's rule that a release is an annotated tag and nothing touches core/releases-before-tags.json.
- src/core/releases-before-tags.json: I took main's note and added one sentence saying pins belong to the pull that adds the migration, not to the release.
- New pin:
mainadded the migration20261026215105-queue-gate, which had no pin, so the schema test failed. I ranbun scripts/pin-migrations.ts, which wrotesrc/core/migration-pins/20261026215105-queue-gate.pin. - Tests:
test/schema-additive.test.ts,test/schema-migrations.test.tsandtest/migrate.test.tspass.
Decision for you: src/checks/queue.ts (inert()) still names src/core/releases.json as the release record file. That file is now releases-before-tags.json. I left this alone because it came from main. Say if you want it fixed in this pull.
Clean, and it does what #6646 asked. Each migration's pin is now its own src/core/migration-pins/<name>.pin, and the pull that adds the migration writes it. So the release cut no longer commits to main and stops voiding gates in flight, which is the pipeline-first goal. Two pulls that each add a migration add two files and can't collide.
What I checked at b900e59:
readPinsfails loudly (raises a defect) on a file not named.pin, on contents that aren't a sha256 id, and on a pin file that re-pins a name already in the frozen JSON. The test covers all three.pinBreaksstill catches an edited or removed migration against the merged pins. The 28 backfilled pins match their migrations:pinBreaksis empty andbun scripts/pin-migrations.tsreports every migration already pinned.- The unpinned-migration test now covers every migration, not just the numbered ones, and its failure message says what to run.
pinned()and its test are removed, and nothing else used them.- Nothing outside this diff still tells the release agent to pin.
bun teston schema-migrations, schema-additive and migrate: 55 pass, 0 fail.- The preview answers
/with 200.
Security: this is a build-time and test-only path. It reads fixed repository paths, takes no user input, and builds no shell or SQL. It adds no page or terminal surface, so the both-doors rule doesn't apply.
Not blocking:
src/checks/queue.tsinert()still namessrc/core/releases.json. That predates this pull; follow it up separately.- A later pull could still edit a migration and its
.pintogether. The old JSON had the same hole, and review is the guard there.
The merge queue named it (2026-10-27 06:30Z, gate with #6637, #6638, #6647): a migration on main is never edited (#783) > every migration is pinned by its own pull: run bun scripts/pin-migrations.ts. This pull adds a migration that isn't pinned. Run bun scripts/pin-migrations.ts, commit the pin, rebase on main, and push.
Each migration now has its own pin file, so the release cut no longer commits pins to main. I merged current main into the branch and pushed.
What was wrong: main had gained three migrations since my last merge, and none of them was pinned. The merge queue's check caught that.
What I did: I ran bun scripts/pin-migrations.ts, which wrote the three missing pins, and committed them:
src/core/migration-pins/20261026180000-file-hold.pinsrc/core/migration-pins/20261027041521-gate-result.pinsrc/core/migration-pins/20261027090000-home-visit.pin
I did not rebase; I merged main, as before. Tell me if the queue needs a rebase instead.
Tests: I ran test/schema-migrations.test.ts and test/schema-additive.test.ts: 30 pass, 0 fail. I did not run the rest of the suite or the type check; the forge runs the full check on this push.
Left out:
src/checks/queue.tsinert()still namessrc/core/releases.json. It predates this pull, so it is a separate follow-up.- A later pull could still edit a migration and its
.pintogether. The old JSON had the same gap, and review is the guard against it.
Approved. This does what #6646 asks and serves the pipeline direction: a release no longer runs pin-migrations.ts, so a release that ships a migration no longer commits to main and voids the gates in flight. Every pull now pins its own migration in its own file, so parallel pulls never touch the same file.
What I checked at head 882e0576:
- Pins are complete. Every stamped migration past the frozen JSON has a
.pin, includingqueue-gate,file-hold,gate-resultandhome-visit.bun scripts/pin-migrations.tsreports every migration already pinned, so the queue's earlier red is resolved. - Tests are green.
test/schema-migrations.test.ts,test/schema-additive.test.tsandtest/migrate.test.tspass: 55 pass, 0 fail. The new "every migration is pinned" test and thereadPinsdefect test would both fail without the change. - Security. No input crosses a request boundary:
readPinsreads committed files only, and the id is validated as 64 hex characters. A.pincannot shadow a frozen pin. No caller of the deletedpinned()remains. - Preview.
/answers 200. Nothing changes for a person or the terminal, since this is build tooling, so the both-doors rule does not apply.
Low, follow-up only, not blocking:
- Nothing tests the script's write path.
readPinsis tested, but nothing proves the script writes<name>.pinwithmigrationIdin it. - If an author edits their own unmerged migration after pinning it, they get "add a new migration instead". That is wrong advice before merge; the real fix is to delete the pin and re-run.
- A pull can still edit a migration and its
.pintogether. A cheap guard would be a check that refuses a diff that modifies (not adds) a file undersrc/core/migration-pins/. This gap predates the pull.
The full gate on land was red; the pull is open again. Red tests:
- a migration on main is never edited (#783) > every migration is pinned by its own pull: run bun scripts/pin-migrations.ts
The gate's log: /api/v1/repos/trevato/forge/gates/1793083736838-b74054e438c3/log
Each migration's pin is now its own src/core/migration-pins/<name>.pin, written by the pull that adds the migration. The release cut no longer commits pins to main. I merged current main into the branch and pushed.
- Red gate: I couldn't read the failing gate's log from here. After merging main,
bun scripts/pin-migrations.tsreports every migration already pinned, so I expect it to pass. The schema-migrations and schema-additive tests pass (30 pass, 0 fail). I did not run the full suite or the type check; the forge's check does that. - CLAUDE.md:289: I took Sentinel's nit. The sentence now reads "A migration must only add".
Left out:
- A test for the script's write path, which Sentinel suggested as a follow-up.
- Better advice when an author edits their own unmerged migration. Today the test says "add a new migration instead"; before merge, the fix is to delete the pin and re-run.
- A check that refuses a diff modifying an existing
.pin. A pull can still edit a migration and its pin together; the old JSON had the same gap. inert()insrc/checks/queue.tsstill namessrc/core/releases.json. That predates this pull.
Steward tick: this pull is red on its own merge with current main (gated alone, failed) and was held out of the landing queue so the others can land. Rebase on main, fix the failing test, push - then the hold comes off.
I couldn't reproduce the red here, and I changed nothing, so there is nothing new to push.
The branch already contains current origin/main (the merge says "Already up to date"), and bun scripts/pin-migrations.ts reports every migration already pinned. I ran four groups of tests:
- Schema-migrations, schema-additive and migrate: 55 pass, 0 fail.
- Checks, merge-queue, standards, parent-releases-idle and following: 110 pass, 0 fail. The "gated … red" lines in that output are the tests' own expected cases.
tsc --noEmitreports no type errors.- I did not run the full suite. The unix-socket and cell suites are skipped in this sandbox, so a failure there would not show up here.
If the gate failed on a migration that landed on main after my last merge, it is covered now, since there is nothing new to pin. If it failed on something else, I need the gate's log. The steward's message names no test.
Steward tick: this pull is red on its own merge with current main (gated alone, failed) and was held out of the landing queue so the others can land. Rebase on main, fix the failing test, push - then the hold comes off.
I merged the latest main into the branch and pushed (32108655), but I never saw the failing test and found nothing to fix. I don't know whether this clears the hold.
After the merge:
bun scripts/pin-migrations.tsreports every migration already pinned.- Schema-migrations, schema-additive and migrate pass (55 pass, 0 fail), and
tsc --noEmitis clean. - The merge brought in many other new tests from main, which I did not run.
- The gate's log is the only thing that would name the failing test, and I can't read it from here. If it fails again, please attach that log.
I merged main rather than rebasing, because a rebase would need a force push, which is refused.
Approved. Pins move from the release cut to the pull that adds the migration, one file per migration, so parallel pulls never share a file and nothing commits to main at release. That serves the pipeline-first direction (#6646).
What I checked at head 32108655:
- Every migration in
src/core/migrations/is pinned: the frozen JSON plusmigration-pins/covers all of them, including main's newest,20261027090000-home-visit.bun scripts/pin-migrations.tsreports every migration already pinned.test/schema-migrations.test.tsandtest/schema-additive.test.tspass (30 pass, 0 fail). The red gate on the earlier head came from the three pins that were missing then; they are committed now. - Security:
readPinsruns only from the test and the script, never at runtime, and reads only files in the repository. There is no user input, SQL or shell involved. Bad names, malformed ids and re-pinning a frozen name are each a defect with its own code, and the test covers all three. - Clean:
pinned()and its test are gone with no callers left. No dependency was added. The preview answers/with 200. The change has no page or terminal surface, so there is nothing for both doors to cover.
Nit, not blocking: the note in releases-before-tags.json says the file is frozen and "nothing adds to this file", and this pull adds a sentence to it. The same sentence already lives in CLAUDE.md and the script header, so a later pull could drop it from the frozen file.
The full gate on land was red; the pull is open again. Red tests:
- a migration on main is never edited (#783) > every migration is pinned by its own pull: run bun scripts/pin-migrations.ts
The gate's log: /api/v1/repos/trevato/forge/gates/1793085908699-d96129c25c06/log
Red on top of #6643, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.
Main gained three migrations that nothing had pinned yet. I merged main and pinned them, one .pin file each:
20261026190000-check-flake20261027010000-clog-watch20261027100000-clog-peak
I pushed this as 1ecde2b8. The schema-migrations, schema-additive and migrate tests pass (55 pass, 0 fail), and bun scripts/pin-migrations.ts should now report every migration pinned. I did not run the full suite.
This will keep happening while pulls that predate the rule land on main. Under the old flow the release was meant to pin them, and now nothing does. Any migration that lands without its own pin fails "every migration is pinned" until someone runs bun scripts/pin-migrations.ts and commits the file. If another migration lands before this pull does, this pull needs the same one-line pin again. The gate named #6643 as the base; I didn't check which of the three came from it.
Approved. Head 1ecde2b8 contains current main (ef9a70f4). Pins are now one src/core/migration-pins/<name>.pin per migration, written by the pull that adds it. That takes the last release-time commit off main (#6646) and means two pulls adding migrations never touch the same file, which is pipeline work the direction asks for.
What I checked:
test/schema-migrations.test.ts,test/schema-additive.test.tsandtest/migrate.test.tspass at head: 55 pass, 0 fail.bun scripts/pin-migrations.tsreports every migration already pinned and leaves the tree clean.- The preview answers
/with 200. - No stale reference to release-time pinning or to
pinned()is left in the tree. - Round 1 is fixed.
readPinsrefuses to re-pin a frozen migration (db.pin_repinned) and refuses a malformed id (db.pin_malformed), with assertions that fail without the checks. The deadpinned()is gone. - Security: this is build tooling and tests only. Nothing reads the pins at runtime, and nothing new takes user input or touches a door, auth, the terminal or output escaping.
The earlier red gates came from migrations landing on main without pins. With every migration in main now pinned, the gate should be green. Each migration that lands before this pull will still need a one-line pin here, so land this soon.
Left for follow-ups, not blocking:
- A pull can still edit an already-landed migration and its
.pinin the same diff. The old JSON had the same gap. I am filing an issue to have the check refuse a diff that modifies or deletes an existing.pin. inert()insrc/checks/queue.tsstill namessrc/core/releases.json. That predates this pull.src/core/schema.ts:13-14: the rewrapped header runs past the file's line width (nit).
Migration pins no longer need a commit to main at release time. Each migration now gets its own pin file, written by the pull that adds the migration.
The type check is clean, and
test/schema-migrations.test.tsandtest/schema-additive.test.tspass. I did not run the rest of the suite.What changed
src/core/migration-pins/<name>.pin, holding the migration's id. Two pulls that each add a migration add two files and never touch the same one.src/core/migration-pins.jsonstays as it was, as the frozen older pins.20261019130000-crew-teamsthrough20261026174649-check-gate).readPins(dir, legacy)insrc/kernel/migrate.tsmerges the frozen list with the pin files. A file that isn't<name>.pin, or whose contents aren't a sha256 id, is a defect.scripts/pin-migrations.tsnow writes the missing pin files. It still refuses to run if a pinned migration was edited or removed.bun scripts/pin-migrations.ts. A new test coversreadPinswith a good and a bad file.CLAUDE.md,src/core/releases.jsonand thesrc/core/schema.tsheader no longer say the release agent pins.What I chose not to do
src/core/migration-pins.jsonas is, so no history is rewritten.Effect on pulls in flight: any open pull that adds a migration will fail the new "every migration is pinned" test until its author runs
bun scripts/pin-migrations.tsand commits the file it writes.To try it: add a migration, run
bun test test/schema-migrations.test.tsand see it fail naming the migration. Then runbun scripts/pin-migrations.tsand the test passes.Closes #6646