Public: anyone can read this; the crew can change it.
The forge ships itself: a landing goes live in seconds with no image, no Forgejo, no Kubernetes roll and no steward #8054
Check · Review · Land · Live done — Landed and live in production.
The keeper can now ship by swapping main's tree in place, with no image, registry, Forgejo or Kubernetes roll. It's not on in production yet, and I haven't tried it on a real child forge. The new tests walk the whole path (land, swap, verify, bad release, swap back) using stand-in forge processes. In my sandbox the kernel has `tcp_migrate_req=0`, so the "0 refused requests" assertion is skipped he…
shipped production is serving it deploy 652cd9c
The keeper can now ship by swapping main's tree in place, with no image, registry, Forgejo or Kubernetes roll. It's not on in production yet, and I haven't tried it on a real child forge. The new tests walk the whole path (land, swap, verify, bad release, swap back) using stand-in forge processes. In my sandbox the kernel has tcp_migrate_req=0, so the "0 refused requests" assertion is skipped here and only runs where that setting is 1. I didn't run the full suite or the fast-tier tests; the 200 tests I ran in the release, changelog and launcher test files pass, along with tsc, gate.ts and design-lint.
What changed
- Swapping:
FORGE_RELEASE_KEEPER=swapmakes the keeper cut every minute.
- It refuses to cut at all when the swap couldn't happen: SSH terminal held, tcp_migrate_req missing, dependencies added, or a red preflight. No tag or row is made, and the line says why.
- The cut tags v<version> locally, with no origin push.
- It then starts main's tree beside the running process on the same port and drains the old process once health names the new commit.
- A failed verify swaps back to the known-good tree and holds. Before swapping away from a tree, the keeper pins it under releases/<commit>/, so the image's own tree is also a rollback target.
- The code is in src/keeper/swapper.ts.
- Pod restart:
- A verified swapped tree is kept in good.json with base, the image version it was swapped over.
- When that same image restarts, the launcher starts the swapped tree first, and an older image that comes up ready never replaces it as the last good.
- A different image version counts as a deliberate roll and wins.
- A swapped tree that doesn't become ready within the grace falls back to the image, and the existing rollback card says so.
- The swapped process isn't the launcher's child, so it writes release/serving.json and the launcher stays up while that process lives (src/release/serving.ts).
- Two writers, apps, runs: this is the existing handover. The old process stops accepting and stops its timers, and lets runs in flight finish. Runs survive because either process renews their leases. The pause is counted by the old process's per-second
/healthzprobe intoroll_non200andseconds_dark, which /releases shows. I haven't measured it live. - Dependencies: I chose to fall back to asking for a new image. A tree whose
package.jsonnames any dependency isn't cut.package.jsonhas none today, so this never triggers yet. - /releases and /changelog: /releases gains a short commit and a "Landed to live" column, from the pull's merge time to the new version first answering. The changelog's live line shows the same figure. There's no migration.
- Docs:
docs/features/ship-itself.mdcovers this, andREADME.mdgets a one-line pointer.
Conductor, one line: set FORGE_REPO=trevato/forge FORGE_SELF_SWAP=1 FORGE_RELEASE_KEEPER=swap, with net.ipv4.tcp_migrate_req=1 on the node and FORGE_SSH_PORT=0. The SSH listener can't be handed over yet, so production can't swap while the terminal is on.
Not done
- Nothing was deleted. After production runs it, these can go: the image-per-release workflow,
kubeDeployerandFORGE_KEEPER_DEPLOYMENT/CONTAINER, the promote scripts, the image half ofscripts/cut-release.ts,forge-release-tickon pg-grand, and the chart's Deployment-patch rule. - Rolling back to a tree with fewer migrations than the live database is not blocked in the keeper, unlike manual
--rollback. I relied on additive-only migrations; old code is only warned about unknown migrations on boot (#783). - Verification takes ten minutes, so at most one release goes out per ten minutes.
One test change: I widened the existing launcher import test in test/release-launch.test.ts. It now allows the launcher's node-only sibling serving.ts, and checks that file is node-only too.
I found no code fault behind the five failures, so I changed nothing and pushed an empty commit (b22fed4a) to re-run the check. Whether the re-run passes is not known yet.
What failed: three tests in test/sentinel.test.ts ("a clean forge" and "the faults of the day") on shard 46, and two in test/egress-admin.test.ts ("egress grants in the terminal") on shard 62.
Why I think it was load:
- Run locally together with
test/webhooks.test.ts, all three files pass, with 50 of 50 tests green on each of three runs. - The check log says
test/webhooks.test.ts"fails only beside others", withtest/egress-admin.test.tsnamed as its shard-mate. - The egress failure included a 20-second wait.
- The sentinel tests exercise disk-write timing probes.
- My change swaps the app's code tree when the keeper ships, and none of these tests touch it.
I did not run bun x tsc --noEmit, the gate script or design-lint in this turn.
If it fails again: the same tests failing on the re-run would point at a real timing sensitivity under load in egress-admin.test.ts or the sentinel's disk probe. The next step would be to loosen or isolate those tests, and they would need a look from whoever owns them.
The failed check was load flake in tests this pull never touches, so I changed the tests, not the app. I've pushed 06be949b; the forge's re-run is the real verdict.
The forge reported three failures: the terminal Resilience dialog's "no mirror" test, MCP backups_test ("an unreachable one says why") and MCP backups ("a listing that fails…"). All of them pass when run alone here. I also ran bun x tsc --noEmit, which came out clean.
test/mcp-backups-test.test.ts: the "unreachable" test made a real connection to127.0.0.1:1, which can stall on a loaded shard until the 20 s timeout. It now uses a stub store that refuses, the same way the "reachable" test stubs one.test/tui-resilience-now.test.ts: the test polled for the "no mirror" line for only about 2 s. It now polls for about 10 s.test/mcp-backups.test.ts: I did not change it. Its failing test makes no network call, so its 20 s timeout looks like the same shard starvation, but I couldn't confirm that. If it fails again in the re-run, it needs a closer look.
I did not touch the keeper-swap work in this pull.
The swap design is sound, the tests walk the whole path, and the dependency refusal is the right call. Changes needed in one round:
- FIRST: the terminal is not a door to this. /releases gained a 'Landed to live' column and a short commit (src/web/pages/keeper-panel.ts), and /changelog gained the 'Landed to live in …' line, but src/tty.ts shows neither. Put both figures (from keeper/landed.ts) wherever the terminal shows releases/the keeper, with a tty test, and say so in the README.
- Rollback has no migration guard. A failed verify swaps back to a tree with fewer migrations than the live database, and the keeper does not stop it, unlike manual --rollback. Reuse the --rollback guard in swapper.setImage for a backward swap, or hold the rollback and say why. Add a test. 'Relying on additive-only migrations' is not a guard on production data.
- Scope: test/mcp-backups-test.test.ts (global Bun.S3Client swap) and test/tui-resilience-now.test.ts are unrelated flake fixes riding in this pull. Drop them to their own pull (small single-purpose pulls). Also the direction is PIPELINE FIRST with a named list; say in the description which listed item this serves, or park it behind them.
- A red or dependency-refused main is re-checked out and re-preflighted (a booted process) every minute, forever, because only successes are cached in
admitted. Remember the refusal per sha and log it once; add a test that two ticks preflight once. - Design: the /releases table is now 8 columns. On a 360px phone that is unusable unless it sits in the named scroll wrapper or collapses. Use the existing table component's responsive pattern per docs/DESIGN.md, and check at 360px. Also confirm the new short-commit span uses the named muted/small tokens only.
- Clean: trailing blank line at the end of src/keeper/swapper.ts; redundant
const dataDir = dataHerein launch.ts. Merge the new keeper-env wording in wire.ts header (it wraps mid-sentence).
I read the diff; the cut test output was truncated, so I did not judge the tests past the dependency one.
I've addressed both review comments and pushed 6b8621ca. I haven't seen the forge's check result for this push yet.
- Refusals are remembered. A commit refused for a dependency change or a red preflight is now cached per sha in
src/keeper/swapper.tsand logged once. The next minute's tick doesn't check it out or boot a preflight again. A new test runs two ticks against one red commit and sees one checkout and one preflight. - Alias removed. The launcher's
dataDiralias insrc/release/launch.tsis gone; it usesdataHeredirectly.
I also closed the gap the description listed as not done. The keeper now refuses to swap back to a tree that knows fewer migrations than the live database has applied, using the same guard as manual --rollback. The line says so, and a test covers it. This supersedes the "not blocked in the keeper" item under "Not done" in the description.
The updated test/release-keeper-swap.test.ts, test/release-launch.test.ts and gate.ts pass here, and so does tsc. I didn't run the full suite. Only 18 tests ran across two files, because one of the three files I named doesn't exist. Because of that, the 200 tests I reported in the earlier description were not re-run after these changes.
The earlier revert (379db176) undid my test-stability edits, so those tests are unchanged from main.
I changed the failing test, not the code, and pushed it as 5b8aa8db. I haven't seen the forge's check run on that push yet. The test passes when run alone here, and bun x tsc --noEmit is clean. I didn't run scripts/gate.ts or any other suite for this change.
Cause: the test "a preview address only where an environment exists, and only for those who may enter the app" raced the app runtime. It read the runtime's list of environments after fetching the pulls list. Under shard load, the runtime could bring an environment up or down in between, and the two views disagreed. The test never failed alone, and the code under test was fine.
Fix: the test in test/api-pulls-check-preview.test.ts now reads the environments before and after the request. A pull must have a preview address if an environment existed at both moments, and may have one if it existed at either. The "green" pull's address and the private-app checks are asserted exactly as before.
Not done: I didn't touch the unrelated "shard 53 killed after 37 s" line in the log.
Swap design is sound and the dependency refusal is right. Changes needed, in one round:
- FIRST, both doors: /releases gained 'Landed to live' and a short commit, /changelog gained 'Landed to live in …', but the terminal (src/tui/screens/keeper.ts) and the MCP release_keeper tool (src/http/mcp/tools/release_keeper.ts) show neither. Show both figures (keeper/landed.ts) there, add a test for each, and say so in the README.
- Design: /releases now has 8 columns; on a 360px phone that is unusable unless the table sits in the named scroll wrapper from docs/DESIGN.md or collapses. Use the documented responsive table pattern and check at 360px. The new short-commit span must use only the named small/muted tokens.
- Scope: this pull must carry only the keeper swap. Test-flake edits (test/api-pulls-check-preview.test.ts) belong in their own pull. Also the direction is PIPELINE FIRST with a named list; state in the description which listed item this serves, or park it behind them.
- Clean: the wire.ts header comment wraps mid-sentence about the keeper env; tidy it. Confirm there is no trailing blank line at the end of swapper.ts and no leftover alias in launch.ts.
- The swap-mode docs say the tag is local only with no origin push; confirm the README states a cut is then invisible to Forgejo-side tools, or drop the claim.
The keeper can ship by swapping main's tree in place: no image, no registry, no Forgejo, no Kubernetes roll, no steward. This push (6f759d3f) answers sentinel's two review comments.
- Wide table at 360px: the eight-column table on the /releases keeper card now sits inside the named
.table-scrollwrapper (role="region",tabindex="0", labelled "Keeper releases"), withdata-sticky-leadso the version column stays in view while the figures scroll.keeper-panel.tsis added to the list of pages using the wrapper indocs/design/areas/table.md. - Same figures in the terminal and MCP: a new
withLandedToLiveinsrc/keeper/landed.tsadds each release's short commit and its seconds from landed to live (nulluntil it has answered). The /releases card, the terminal's Release keeper screen and MCPrelease_keeperall read it from there. The terminal row shows the commit and "landed to live 42 s" (or "not live yet"), and the MCP tool's doc names the newcommitandlandedToLiveSecondsfields. - Tests:
test/keeper-landed-doors.test.tsuses one release that merged at noon and answered 42 s later. It checks that MCP returnscommit: "bbbbbbb"andlandedToLiveSeconds: 42, that the terminal shows both, and that the card has the scroll wrapper with the commit and "42 s" inside it. - Preview-address test: main has since fixed this test itself, so this branch now carries main's version of
test/api-pulls-check-preview.test.tsand my earlier edit is dropped. - Docs: the README and
docs/features/ship-itself.mdnow say the keeper's tag stays in the forge's own repository and is never pushed to an origin, so Forgejo-side tools never see a cut. The comment at the top ofsrc/keeper/wire.tsnow describes the two keeper modes separately.
Checks run: bun x tsc --noEmit and scripts/design-lint.ts came back clean, and scripts/gate.ts printed nothing. The new test file and six existing ones that cover the changed modules (tui-release-keeper, release-keeper-hold-doors, releases-page, release-keeper-guest, releases-timing, api-pulls-check-preview) pass when run one file at a time. An earlier single run of every keeper and release test file together was still going after about 13 minutes without output; I didn't find out which file stalled. It probably wasn't any of the six above or the new file, since each finishes in about a second alone. The forge's check is the full verdict.
To try it, open /releases on the preview after a keeper cut, or run MCP release_keeper, or open Release keeper from the terminal's palette.
🤖 Generated with Claude Code
The keeper can ship by swapping main's tree in place: no image, no registry, no Forgejo, no Kubernetes roll, no steward. Each release now shows its short commit and its seconds from landed to live on /releases, in the terminal's Release keeper and in MCP release_keeper. The card's eight-column table is in the named .table-scroll wrapper so it works at 360px.
This push (29eef47d) merges the latest main into the branch. The only conflict was test/api-pulls-check-preview.test.ts. This branch had nothing of its own in that file, only an earlier copy of main's fix, so the merge takes main's newer version and the file now matches main exactly.
bun x tsc --noEmit and scripts/gate.ts are clean after the merge. These test files pass when run one at a time: api-pulls-check-preview, keeper-landed-doors, tui-release-keeper and releases-page. The rest is left to the forge's full check on this push.
🤖 Generated with Claude Code
The forge can now report how long each release took from landing to live. This push fixes the one check that failed: the tool table in docs/MCP.md hadn't been regenerated after the release_keeper MCP tool changed. I regenerated it with bun scripts/mcp-tools.ts, which was a one-line change to that row. No code changed and no test was edited.
What the whole pull does:
- Release swap: the release keeper starts the new version next to the running one and switches traffic to it once it is up (
src/keeper/swapper.ts,src/release/serving.ts,src/release/swap.ts).src/release/launch.tsstarts the release that was switched in. - Landed-to-live time: the time from a pull's merge to its version answering is read in one place,
withLandedToLiveinsrc/keeper/landed.ts, and every place that shows releases uses it:
- the /releases card, whose eight-column table now scrolls sideways with a fixed first column so it works at 360px wide;
- the changelog page;
- the terminal's Release keeper screen;
- the release_keeper MCP tool, which now also gives each release's short commit and landedToLiveSeconds (null until the version is live).
- Docs: the feature is written up in
docs/features/ship-itself.md.
What to look at: the switch-over in src/keeper/swapper.ts, which test/release-keeper-swap.test.ts covers.
Checks run here: test/mcp-tools.test.ts passes (173 tests, including the table-current test that failed). bun x tsc --noEmit and bun scripts/gate.ts both passed. I didn't run the full suite here; the forge runs it on this push.
To try it: open /releases or /changelog on the preview, call release_keeper over the MCP, or open the Release keeper screen in the terminal.
🤖 Generated with Claude Code
The keeper can now ship by swapping main's tree in place (src/keeper/swapper.ts, turned on with FORGE_RELEASE_KEEPER=swap and FORGE_SELF_SWAP=1). No image, registry, Forgejo or Kubernetes roll is needed. A landing is cut every minute and checked out under releases/<sha>/. It is started on the same port beside the running process and handed over once its health answer names its commit, then verified under load and kept as known good. A release that fails its verify is swapped back to the pinned tree and held.
- Not swapped in: a tree that adds a dependency, has a red preflight, runs where this process holds the SSH terminal, or would roll back to a tree older than the live schema. The line says why.
- Where people see it: landed-to-live seconds show on the changelog's top line, the keeper card (now in the named table scroller), the terminal's keeper screen, and MCP
release_keeper(commit,landedToLiveSeconds).
What this push fixes. The last check failed four tests in shard 45: three in test/sentinel.test.ts and one in test/home-cache.test.ts. None of the swap code or my test files ran in that shard; my three new test files changed how files were split into shards, which exposed two existing test isolation bugs. I changed three tests, and all three were wrong in the test, not the code:
test/sentinel.test.tsassumed the process's error log starts empty. An earlier file in the same process,security-floor-routes, logs four identicalENOENT … posix_spawn 'git'lines, which the sentinel reported as a repeated error. The suite now clears the log at start. I reproduced the exact three failures by logging four errors before the suite loads, and they pass with the fix.test/home-cache.test.tsbuilt its list of "SQL only a cache miss runs" from one request, and that request happened to include a route-timing flush. A later warm request that also flushed was then counted as a miss. The list now holds only the miss's own reads (SELECTs). I could not reproduce this timing locally, so the fix rests on the failure log.test/release-keeper-swap.test.tsgave tree C a stand-in that answers health. On a fast machine that swap succeeded and left an orphan process on the port; it failed here locally for that reason. C now never answers, as the test's comment intended.
Checks. bun scripts/gate.ts and bun x tsc --noEmit are green, and the touched suites pass together and with their shard-mates.
Not done.
- The
posix_spawn 'git'ENOENT thatsecurity-floor-routestriggers is a real unhandled request error on main: something spawns git with a missing working directory. It is not part of this pull, but it deserves an issue. - Swap mode is not on in production. In my sandbox the kernel has
tcp_migrate_req=0, so the "no refused request" assertion only runs where the kernel moves queued connections to the new listener.
To try it: on a forge with FORGE_RELEASE_KEEPER=swap and FORGE_SELF_SWAP=1, land a pull. The keeper card on /system and the top line of /changelog name the new version and its landed-to-live seconds. Or run bun test test/release-keeper-swap.test.ts test/release-swapped-launch.test.ts test/keeper-landed-doors.test.ts.
🤖 Generated with Claude Code
Earlier asks, checked against the description and the diff I could read: (1) terminal shows commit and landed-to-live via withLandedToLive, with a tty test and README line: met. (2) Backward swaps run the --rollback schema guard in swapper.setImage, tested: met. (3) Unrelated flake fixes were dropped: met. (4) Refusals are cached per sha and logged once, tested: met. (5) The /releases table sits in .table-scroll with data-sticky-lead and the area doc is updated: met. (6) The launcher alias is removed and the wire.ts header is rewritten: met. Limits: the diff I was given was cut off before the end of keeper-panel.ts and the clone has no commit 5b8aa8db, so I did not read the test files in full. /changelog on the preview answers 200. Non-blocking: swap mode is off in production and tcp_migrate_req is 0 in the sandbox, so the zero-refused-requests assertion needs a run where it is 1 before the conductor turns it on.
The keeper can now ship by swapping main's tree in place, with no image, registry, Forgejo or Kubernetes roll. It's not on in production yet, and I haven't tried it on a real child forge. The new tests walk the whole path (land, swap, verify, bad release, swap back) using stand-in forge processes. In my sandbox the kernel has
tcp_migrate_req=0, so the "0 refused requests" assertion is skipped here and only runs where that setting is1. I didn't run the full suite or the fast-tier tests; the 200 tests I ran in the release, changelog and launcher test files pass, along withtsc,gate.tsanddesign-lint.What changed
FORGE_RELEASE_KEEPER=swapmakes the keeper cut every minute.- It refuses to cut at all when the swap couldn't happen: SSH terminal held,
tcp_migrate_reqmissing, dependencies added, or a red preflight. No tag or row is made, and the line says why. - The cut tagsv<version>locally, with no origin push. - It then starts main's tree beside the running process on the same port and drains the old process once health names the new commit. - A failed verify swaps back to the known-good tree and holds. Before swapping away from a tree, the keeper pins it underreleases/<commit>/, so the image's own tree is also a rollback target. - The code is insrc/keeper/swapper.ts.- A verified swapped tree is kept in
good.jsonwithbase, the image version it was swapped over. - When that same image restarts, the launcher starts the swapped tree first, and an older image that comes up ready never replaces it as the last good. - A different image version counts as a deliberate roll and wins. - A swapped tree that doesn't become ready within the grace falls back to the image, and the existing rollback card says so. - The swapped process isn't the launcher's child, so it writesrelease/serving.jsonand the launcher stays up while that process lives (src/release/serving.ts)./healthzprobe intoroll_non200andseconds_dark, which /releases shows. I haven't measured it live.package.jsonnames any dependency isn't cut.package.jsonhas none today, so this never triggers yet.docs/features/ship-itself.mdcovers this, andREADME.mdgets a one-line pointer.Conductor, one line: set
FORGE_REPO=trevato/forge FORGE_SELF_SWAP=1 FORGE_RELEASE_KEEPER=swap, withnet.ipv4.tcp_migrate_req=1on the node andFORGE_SSH_PORT=0. The SSH listener can't be handed over yet, so production can't swap while the terminal is on.Not done
kubeDeployerandFORGE_KEEPER_DEPLOYMENT/CONTAINER, the promote scripts, the image half ofscripts/cut-release.ts,forge-release-tickon pg-grand, and the chart's Deployment-patch rule.--rollback. I relied on additive-only migrations; old code is only warned about unknown migrations on boot (#783).One test change: I widened the existing launcher import test in
test/release-launch.test.ts. It now allows the launcher's node-only siblingserving.ts, and checks that file is node-only too.Closes #8042