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

Release keeper: cut on cadence, verify under page load, roll back to the named known-good version, hold with a reason #7876

Merged opened this in answer to

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

Merged

torque/forge:torque/7557-release-keeper-cut-on-cadence-verify-und → main 17 commits fork

  • Merged. torque/7557-release-keeper-cut-on-cadence-verify-und was merged into main as 111d548.

Review

Sign in to review this change.
38 files changed +2260 −7
README.md +10
@@ -455,6 +455,16 @@ What the crew's tokens bought is on the board (`/board/returns`), in MCP `return
455455
456456 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in").
457457
458 The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases on the forge's own one-minute timer, with no model. It is off until `FORGE_RELEASE_KEEPER=on`, and the pod's service account may `get` and `patch` the forge Deployment.
459
460 - **Cut:** it tags main's head as `v<patch + 1>` when main has moved past a live tag, nothing is in flight, the merge queue is still, 30 minutes have passed since its last cut and no hold stands.
461 - **Verify:** it records the known-good version and image before the tag, sets the Deployment's image, and has the new version spend ten minutes under page load probing `/healthz` each second.
462 - **Roll back and hold:** three bad probes in a row hold further cuts with the numbers and set the recorded known-good image back by name, never "undo". A pull saying `release hold <N>` lets the fix through, and that release verifying lifts the hold.
463 - **Owner's hold:** set and lifted from the card, the terminal (`h`) or MCP `release_hold`, by a person who may roll back, never an agent.
464 - **Where to see it:** the **Release keeper** card on `/releases`, `Release keeper` in the terminal's palette, the **Maintenance** board under `/boards`, and MCP `release_keeper`, each gated as `/releases` is: a guest sees none of it.
465
466 How it works and how to turn it on: [`docs/features/release-keeper.md`](docs/features/release-keeper.md).
467
458468 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list. In the terminal, `/` takes a word and keeps the releases whose version or pull titles contain it (`esc` clears it); Enter on a release lists its pulls, and Enter on a pull opens it (`docs/features/changelog.md`).
459469
460470 A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing.
docs/MCP.md +2
@@ -138,6 +138,8 @@ for a tunnel and a forge that sleeps.
138138 | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | |
139139 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
140140 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | |
141 | `release_hold` | `{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent | yes |
142 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
141143 | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
142144 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
143145 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
docs/features/release-keeper.md added +102
@@ -0,0 +1,102 @@
1 # Release keeper
2
3 The release keeper (#7557) cuts, promotes, verifies and, when it has to,
4 rolls back the forge's own releases. It is plain code on the forge's own
5 timer, with no model, so releases keep going out when no one is at the
6 keyboard. It does what the steward's `release.sh`, `verify-after.sh` and
7 `verify-release.sh` did. The code is `src/keeper/`.
8
9 ## What it does, each minute
10
11 1. **Cut on cadence** (`keeper/cadence.ts`). It cuts when all of these hold:
12 - main has moved past the newest `v*` tag;
13 - no keeper release is rolling or verifying;
14 - the newest tag is live, or the keeper rolled it back;
15 - no merge-queue gate is running;
16 - 30 minutes have passed since the last cut;
17 - no hold stands.
18
19 Before anything moves, it records the known-good version and the exact
20 image the Deployment runs. Then it makes the annotated tag
21 `v<version>` (patch + 1) on main's head, with the same record
22 `scripts/cut-release.ts` writes, and pushes the tag to the repository's
23 origin when it has one. The cut is a tag, never a commit, so it voids no
24 gate in flight (#6640). The row is written before the tag: if the forge
25 stops between the two, the next tick sends the tag, and a refused tag
26 drops the row (and the local tag) so nothing waits on a release that
27 never left.
28 2. **Promote.** It sets the Deployment's forge container to
29 `<same image repository>:<version>`. The chart rolls with
30 `maxSurge 1, maxUnavailable 0`. If the image is not built yet, the new
31 pod waits on its pull while the old one keeps serving. While the roll
32 runs, the old forge probes `/healthz` through the public URL once a
33 second and counts every probe that got no 200.
34 3. **Verify under page load** (`keeper/verify.ts`). The new forge sees
35 that it is the version in flight and spends 10 minutes on two things at
36 once:
37 - it fetches `/boards`, the forge's repository page, `/explore` and `/`,
38 one at a time, signed in as the first site admin (a session made for
39 the verify and deleted after it);
40 - it probes `/healthz` once a second.
41
42 A probe is bad when it is not a 200 or takes over 10 s. Three bad probes
43 in a row fail the release.
44 4. **Verified live**, or **roll back and hold**. On a failure, the keeper
45 first writes a hold with the version, the reason and the probe numbers.
46 Then it sets the Deployment to the known-good image recorded at the cut,
47 by name (`keeper/rollback.ts`). There is no "undo to previous": on
48 2026-10-27 a `rollout undo` put the broken version back for two minutes.
49 A release that is not live 3 h after its cut is rolled back the same way.
50
51 Every step is an event on the outbox: `release.cut`, `release.released`,
52 `release.verified_live`, `release.rolled_back` and `release.held`.
53
54 **Seconds dark** counts the probe-seconds without a 200, through the roll
55 and the verify together. The target is 0. A release with any dark seconds
56 files one issue, `Release <version> was dark for <n> s`.
57
58 ## Holds
59
60 - **A failed verify's hold** blocks every cut. Land a pull whose title or
61 body says the hold by number (`release hold 3`). Naming the version alone
62 (`0.7.284`) does not count: changelogs and revert notes mention it too.
63 The keeper then cuts the fix, and the hold is lifted when that release
64 verifies.
65 - **An owner's hold** is set with MCP `release_hold {reason}`, with the Hold form on the `/releases` card, or with `h` on the terminal screen, and lifted
66 (`release_hold {lift: <id>}`, the card's Lift button, `h` then `lift N`) — one function, `holdAs`/`liftAs`, behind all three.
67 It needs a person who may roll back (MCP: on an admin-scope token); an agent never can.
68
69 ## Where to see it
70
71 - The terminal: **Release keeper** in the command palette shows the same
72 read as the card, for the same people.
73 - `/releases`: the **Release keeper** card says what holds the next cut and
74 lists each release with its stage, the known-good version, the roll's
75 non-200s, the probes and pages, the worst latency and the seconds dark.
76 - The **Maintenance** board (`/boards`, from the `keeperReleases` source)
77 charts seconds dark and worst latency per release, and releases by
78 outcome.
79 - MCP `release_keeper` returns the same data as JSON.
80
81 ## Turning it on
82
83 The keeper is off unless all of these are true:
84
85 - `FORGE_RELEASE_KEEPER=on`;
86 - `FORGE_REPO` names a repository on this forge;
87 - the forge runs in a cluster.
88
89 `FORGE_KEEPER_DEPLOYMENT` and `FORGE_KEEPER_CONTAINER` name the Deployment
90 and its container (both default `forge`). The pod's service account needs
91 `get` and `patch` on that Deployment; the chart does not grant them. The
92 boot log says why it is off.
93
94 Guardrail: the keeper writes only a tag, the Deployment's image, its own
95 rows and issues. It has no account that may land a pull or push main.
96 Rollback is the one action it takes without a canary, because the image it
97 sets was already live.
98
99 Assumption: the keeper promotes by setting the image directly, as the
100 steward's rollback did. If Flux reconciles the image back, the keeper sets
101 it again on its next tick while the release is rolling. If it never takes,
102 the 3 h deadline rolls it back and holds.
src/boards/sources.ts +22
@@ -285,6 +285,28 @@ export const SOURCES: Readonly<Record<string, Source>> = {
285285 reports: "reports kept that day (production only)",
286286 },
287287 },
288 // The release keeper's releases (#7557), one row each: what the probes
289 // counted through the roll and the ten minutes under page load. Seconds
290 // dark is the target-zero number. The stop reason is free text, not listed.
291 keeperReleases: {
292 table: "keeper_release",
293 owner: "admin",
294 time: "cut_at",
295 columns: {
296 version: "text", stage: "text", cut_at: "time", live_at: "time", finished_at: "time",
297 roll_probes: "number", roll_non200: "number", probes: "number", pages: "number",
298 bad_in_row: "number", worst_ms: "number", seconds_dark: "number",
299 },
300 docs: {
301 stage: "rolling, verifying, verified (verified live) or rolled_back",
302 roll_non200: "/healthz probes, one a second, that got no 200 while the roll ran (target 0 with surge)",
303 probes: "/healthz probes in the ten minutes under page load",
304 pages: "pages fetched as an admin under load, one at a time",
305 bad_in_row: "the most bad probes in a row (non-200 or over 10 s); 3 fails the release",
306 worst_ms: "the slowest probe or page, ms",
307 seconds_dark: "probe-seconds with no 200, roll and verify together (target 0)",
308 },
309 },
288310 // Forges made by the one-click path (#2914): one row per spawn event
289311 // (`actions/spawn.ts`, `spawn/live.ts`). The forge's name and the failure's
290312 // words are not listed; the step is one of a fixed few.
src/boards/starting.ts +13 −1
@@ -1,5 +1,5 @@
11 /**
2 * The boards the forge offers a new install: Usage, Serving, Crew, Releases, Apps, App restarts, Security, Throughput, Forges and Reach. They are plain
2 * The boards the forge offers a new install: Usage, Serving, Crew, Releases, Maintenance, Apps, App restarts, Security, Throughput, Forges and Reach. They are plain
33 * boards in `forge/boards` once written, so a person edits or deletes them
44 * like any other; `.offered` (a JSON list of slugs, committed with the
55 * boards) records what was offered so a deletion sticks and an edit is
@@ -83,6 +83,18 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
8383 panel("Decider time per hour (ms)", "line", { source: "decisions", aggregate: "avg", column: "ms", groupBy: HOUR, series: "kind" }, 2),
8484 ],
8585 },
86 // The release keeper (#7557): every release dark for 0 s is the target.
87 maintenance: {
88 title: "Maintenance",
89 range: "30d",
90 refresh: "1m",
91 panels: [
92 panel("Seconds dark per release", "bar", { source: "keeperReleases", aggregate: "max", column: "seconds_dark", groupBy: "version" }, 2),
93 panel("Worst latency per release (ms)", "bar", { source: "keeperReleases", aggregate: "max", column: "worst_ms", groupBy: "version" }, 2),
94 panel("Releases by outcome", "bar", { source: "keeperReleases", aggregate: "count", groupBy: "stage" }),
95 panel("Seconds dark (30 days)", "number", { source: "keeperReleases", aggregate: "sum", column: "seconds_dark" }),
96 ],
97 },
8698 apps: {
8799 title: "Apps",
88100 range: "7d",
src/core/event_audience.ts +2
@@ -177,6 +177,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
177177 kind === "repo.crew_outsiders" ||
178178 // A clog starting names the forge's own pile-up: the owner's to hear (#6524).
179179 kind.startsWith("clog.") ||
180 // The release keeper's steps name images and probe numbers: the forge owner's (`keeper/keeper.ts`, #7557).
181 kind.startsWith("release.") ||
180182 // A file held against new work is its admins' book-keeping (#6522).
181183 kind === "repo.file_held" ||
182184 kind === "repo.file_released"
src/core/migration-pins/20261027140000-release-keeper.pin added +1
@@ -0,0 +1,1 @@
1 e24c6b4f77b34f5ba3d6f043f2da2788dd0b2e239ebfd5970e12c3b99b1016be
src/core/migrations/20261027140000-release-keeper.ts added +39
@@ -0,0 +1,39 @@
1 // The release keeper (#7557): one row per release it cut, with the
2 // known-good image it rolls back to and what the probes counted, and the
3 // holds that stop further cuts. `kind` is `failed` (a verify that failed,
4 // lifted by a verified release after a pull naming it lands) or `owner`
5 // (set and lifted by an admin).
6 export default `
7 CREATE TABLE keeper_release (
8 id INTEGER PRIMARY KEY AUTOINCREMENT,
9 version TEXT NOT NULL UNIQUE,
10 sha TEXT NOT NULL,
11 known_good_version TEXT NOT NULL,
12 known_good_image TEXT NOT NULL,
13 stage TEXT NOT NULL,
14 cut_at TEXT NOT NULL,
15 live_at TEXT,
16 finished_at TEXT,
17 roll_probes INTEGER NOT NULL DEFAULT 0,
18 roll_non200 INTEGER NOT NULL DEFAULT 0,
19 probes INTEGER NOT NULL DEFAULT 0,
20 pages INTEGER NOT NULL DEFAULT 0,
21 bad_in_row INTEGER NOT NULL DEFAULT 0,
22 worst_ms INTEGER NOT NULL DEFAULT 0,
23 seconds_dark INTEGER NOT NULL DEFAULT 0,
24 reason TEXT NOT NULL DEFAULT ''
25 );
26 CREATE TABLE keeper_hold (
27 id INTEGER PRIMARY KEY AUTOINCREMENT,
28 kind TEXT NOT NULL,
29 version TEXT NOT NULL,
30 reason TEXT NOT NULL,
31 probes INTEGER NOT NULL DEFAULT 0,
32 bad_in_row INTEGER NOT NULL DEFAULT 0,
33 worst_ms INTEGER NOT NULL DEFAULT 0,
34 created_at TEXT NOT NULL,
35 cause_pull INTEGER,
36 lifted_at TEXT,
37 lifted_by TEXT
38 );
39 `;
src/http/mcp/tools/index.ts +2
@@ -108,6 +108,8 @@ export { tool as pulls } from "./pulls.ts";
108108 export { tool as queue_gates } from "./queue_gates.ts";
109109 export { tool as quotas } from "./quotas.ts";
110110 export { tool as read_file } from "./read_file.ts";
111 export { tool as release_hold } from "./release_hold.ts";
112 export { tool as release_keeper } from "./release_keeper.ts";
111113 export { tool as releases } from "./releases.ts";
112114 export { tool as repo } from "./repo.ts";
113115 export { tool as repo_labels } from "./repo_labels.ts";
src/http/mcp/tools/release_hold.ts added +24
@@ -0,0 +1,24 @@
1 import { holdAs, liftAs, MAX_REASON } from "../../../keeper/hold.ts";
2 import { bad, good, str, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "release_hold",
6 doc: "`{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent",
7 description: "Hold or release the release keeper's cuts (admin).",
8 inputSchema: {
9 type: "object",
10 properties: { reason: { type: "string", maxLength: MAX_REASON }, lift: { type: "integer", minimum: 1 } },
11 },
12 writes: true,
13 needsAdminScope: true,
14 run(ctx, user, args) {
15 const id = args["lift"];
16 if (id !== undefined) {
17 if (typeof id !== "number") return bad("invalid", "lift is a hold's id");
18 const lifted = liftAs(ctx.app, user, id);
19 return lifted.ok ? good({ lifted: lifted.value }) : bad(lifted.error, lifted.message);
20 }
21 const held = holdAs(ctx.app, user, str(args["reason"]));
22 return held.ok ? good({ hold: held.value }) : bad(held.error, held.message);
23 },
24 };
src/http/mcp/tools/release_keeper.ts added +22
@@ -0,0 +1,22 @@
1 import { mayRead } from "../../../release/gate.ts";
2 import { blocking, listHolds, standingHolds } from "../../../keeper/hold.ts";
3 import { listKeeperReleases } from "../../../keeper/record.ts";
4 import { bad, good, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "release_keeper",
8 doc: "`{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases",
9 description: "The release keeper's releases, holds and what blocks the next cut.",
10 inputSchema: { type: "object", properties: {} },
11 writes: false,
12 run(ctx, user) {
13 if (!mayRead(ctx.app, user))
14 return bad("forbidden", "the release history is for the forge's administrators and its repository's readers");
15 const db = ctx.app.db;
16 return good({
17 blocked: blocking(standingHolds(db)) ?? null,
18 releases: listKeeperReleases(db),
19 holds: listHolds(db),
20 });
21 },
22 };
src/http/server.ts +3 −1
@@ -163,7 +163,7 @@ import {
163163 } from "../web/pages/backups.ts";
164164 import { apiPage, appApiPage } from "../web/pages/api.ts";
165165 import { changelogPage } from "../web/pages/changelog.ts";
166 import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
166 import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
167167 import {
168168 cloudAdd,
169169 cloudAudit,
@@ -795,6 +795,8 @@ export function buildRouter(): Router<Handler> {
795795 router.add("GET", "/changelog", changelogPage);
796796 router.add("GET", "/:owner/:name/api", appApiPage);
797797 router.add("POST", "/releases/:sha/rollback", rollbackSubmit);
798 router.add("POST", "/releases/hold", releaseHoldSubmit);
799 router.add("POST", "/releases/hold/:id/lift", liftSubmit);
798800 router.add("GET", "/audit.csv", auditCsv);
799801 router.add("POST", "/audit/:owner/:name/probe", auditProbeSubmit);
800802 router.add("POST", "/new", newRepoSubmit);
src/keeper/cadence.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * When the release keeper cuts (#7557): plain data in, one answer out.
3 *
4 * cut ⇔ main ≠ the newest tag's commit
5 * ∧ no release of the keeper's is in flight (cut, rolling, verifying)
6 * ∧ the live version is the newest tag's, or the keeper rolled that
7 * tag back (nothing still promoting)
8 * ∧ no merge-queue gate is running (the queue is not mid-landing)
9 * ∧ now − the last cut ≥ the minimum interval
10 * ∧ no hold stands (a failed verify's, or an owner's)
11 *
12 * The first rule that refuses names itself, so the record says why not.
13 * The version is the newest tag's with its patch number plus one.
14 */
15
16 /** Two releases are at least this far apart: 30 minutes. */
17 export const MIN_INTERVAL_MS = 30 * 60_000;
18
19 export interface CadenceInput {
20 /** main's head commit. */
21 readonly main: string;
22 /** The newest release tag: its version and the commit it names. */
23 readonly newest: { readonly version: string; readonly sha: string } | undefined;
24 /** The version this forge runs. */
25 readonly live: string;
26 /** The keeper rolled the newest tag back: it is over, not promoting. */
27 readonly settled?: boolean;
28 /** A keeper release not yet verified or rolled back. */
29 readonly inFlight: boolean;
30 /** Merge-queue gates running now. */
31 readonly gating: number;
32 /** When the keeper last cut, epoch ms. */
33 readonly lastCutAt: number | undefined;
34 /** The reason of a standing hold, when one stands. */
35 readonly hold: string | undefined;
36 readonly now: number;
37 readonly minIntervalMs?: number;
38 }
39
40 export type Cadence = { readonly cut: true; readonly version: string } | { readonly cut: false; readonly why: string };
41
42 const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
43
44 /** The next patch version: `0.7.283` → `0.7.284`; undefined when `version` is not major.minor.patch. */
45 export function nextVersion(version: string): string | undefined {
46 const m = SEMVER.exec(version);
47 if (m === null) return undefined;
48 return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
49 }
50
51 export function decideCut(input: CadenceInput): Cadence {
52 const no = (why: string): Cadence => ({ cut: false, why });
53 if (input.hold !== undefined) return no(`held: ${input.hold}`);
54 if (input.newest === undefined) return no("no release tag to follow; cut the first one by hand");
55 if (input.main === input.newest.sha) return no(`main is still ${input.newest.version}`);
56 if (input.inFlight) return no("a release is still rolling or being verified");
57 if (input.live !== input.newest.version && input.settled !== true) return no(`${input.newest.version} is not live yet (live: ${input.live})`);
58 if (input.gating > 0) return no(`the merge queue is landing (${input.gating} gate${input.gating === 1 ? "" : "s"} running)`);
59 const wait = input.minIntervalMs ?? MIN_INTERVAL_MS;
60 if (input.lastCutAt !== undefined && input.now - input.lastCutAt < wait)
61 return no(`the last cut was ${Math.floor((input.now - input.lastCutAt) / 60_000)} min ago; the interval is ${wait / 60_000} min`);
62 const version = nextVersion(input.newest.version);
63 if (version === undefined) return no(`${input.newest.version} is not major.minor.patch`);
64 return { cut: true, version };
65 }
src/keeper/deploy.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * The release keeper's hands on the cluster (#7557): read the image the
3 * forge's Deployment runs, set it to a named image, and say whether a roll
4 * to an image has finished. That is all. There is no "undo to previous":
5 * on 2026-10-27 a `rollout undo` put the broken version back for two
6 * minutes, because "previous" is whatever the controller last held. A
7 * rollback here sets the image recorded before the cut, by name
8 * (`keeper/rollback.ts`).
9 *
10 * Spoken over `fetch` to the kube API with the pod's service account
11 * (`cluster/jobs.ts` `serviceAccount`), as a strategic-merge patch of the
12 * one container's image, the same request `kubectl set image` sends.
13 */
14
15 import type { Credentials } from "../cluster/jobs.ts";
16
17 export interface Deployer {
18 /** The image the Deployment's forge container names now. */
19 image(): Promise<string>;
20 /** Name this image on the forge container; the Deployment rolls to it with surge. */
21 setImage(image: string): Promise<void>;
22 }
23
24 /** An image reference: registry/path:tag or @digest, no space, no flag. */
25 const IMAGE = /^[a-z0-9][a-z0-9._\-/:]{0,254}(@sha256:[a-f0-9]{64})?$/;
26 const LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
27
28 export const isImage = (s: string): boolean => IMAGE.test(s) && !s.includes("..");
29
30 /** The image `image` names with its tag replaced by `version`: `reg:5000/o/forge:0.7.1` → `reg:5000/o/forge:0.7.2`. */
31 export function imageAt(image: string, version: string): string {
32 const repo = image.split("@")[0]!;
33 const slash = repo.lastIndexOf("/");
34 const colon = repo.lastIndexOf(":");
35 return `${colon > slash ? repo.slice(0, colon) : repo}:${version}`;
36 }
37
38 /** The tag an image names, or "" when it names none. */
39 export function tagOf(image: string): string {
40 const repo = image.split("@")[0]!;
41 const colon = repo.lastIndexOf(":");
42 return colon > repo.lastIndexOf("/") ? repo.slice(colon + 1) : "";
43 }
44
45 type Fetch = (url: string, init: RequestInit & { tls?: { ca: string } }) => Promise<Response>;
46
47 interface DeploymentJson {
48 metadata?: { generation?: number };
49 spec?: { replicas?: number; template?: { spec?: { containers?: { name?: string; image?: string }[] } } };
50 status?: { observedGeneration?: number; replicas?: number; updatedReplicas?: number; availableReplicas?: number };
51 }
52
53 export function kubeDeployer(input: {
54 credentials: Credentials;
55 deployment: string;
56 container: string;
57 fetch?: Fetch;
58 }): Deployer {
59 const { credentials: c, deployment, container } = input;
60 if (!LABEL.test(deployment) || !LABEL.test(container) || !LABEL.test(c.namespace))
61 throw new Error("deployment, container and namespace must be DNS labels");
62 const call = input.fetch ?? (fetch as unknown as Fetch);
63 const url = `${c.server}/apis/apps/v1/namespaces/${c.namespace}/deployments/${deployment}`;
64 const auth = { authorization: `Bearer ${c.token}` };
65 const tls = c.ca === undefined ? {} : { tls: { ca: c.ca } };
66 const read = async (): Promise<DeploymentJson> => {
67 const res = await call(url, { headers: auth, ...tls });
68 if (!res.ok) throw new Error(`reading deployment ${deployment}: HTTP ${res.status}`);
69 return (await res.json()) as DeploymentJson;
70 };
71 const imageOf = (d: DeploymentJson): string => {
72 const found = d.spec?.template?.spec?.containers?.find((k) => k.name === container)?.image;
73 if (found === undefined) throw new Error(`deployment ${deployment} has no container ${container}`);
74 return found;
75 };
76 return {
77 image: async () => imageOf(await read()),
78 async setImage(image) {
79 if (!isImage(image)) throw new Error("not an image reference");
80 const res = await call(url, {
81 method: "PATCH",
82 headers: { ...auth, "content-type": "application/strategic-merge-patch+json" },
83 body: JSON.stringify({ spec: { template: { spec: { containers: [{ name: container, image }] } } } }),
84 ...tls,
85 });
86 if (!res.ok) throw new Error(`setting ${deployment} to ${image}: HTTP ${res.status}`);
87 },
88 };
89 }
src/keeper/hold.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * Release holds (#7557): while one stands, the keeper cuts nothing.
3 *
4 * - `failed`: a verify failed. It carries the version, the reason and the
5 * probe numbers. A pull that lands saying `release hold 4` is its cause —
6 * the hold by number, never the bare version, which changelogs and revert
7 * notes mention too. With a cause named the keeper may cut again; the
8 * hold is lifted when that release verifies.
9 * - `owner`: an admin set it, with a reason; an admin lifts it.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import type { App } from "../app.ts";
14 import type { User } from "../core/users.ts";
15 import { running } from "../kernel/version.ts";
16 import { mayRollBack } from "../release/gate.ts";
17
18 export type HoldKind = "failed" | "owner";
19
20 export interface Hold {
21 readonly id: number;
22 readonly kind: HoldKind;
23 readonly version: string;
24 readonly reason: string;
25 readonly probes: number;
26 readonly badInRow: number;
27 readonly worstMs: number;
28 readonly createdAt: string;
29 readonly causePull: number | null;
30 readonly liftedAt: string | null;
31 readonly liftedBy: string | null;
32 }
33
34 interface Row {
35 id: number;
36 kind: HoldKind;
37 version: string;
38 reason: string;
39 probes: number;
40 bad_in_row: number;
41 worst_ms: number;
42 created_at: string;
43 cause_pull: number | null;
44 lifted_at: string | null;
45 lifted_by: string | null;
46 }
47
48 const toHold = (r: Row): Hold => ({
49 id: r.id,
50 kind: r.kind,
51 version: r.version,
52 reason: r.reason,
53 probes: r.probes,
54 badInRow: r.bad_in_row,
55 worstMs: r.worst_ms,
56 createdAt: r.created_at,
57 causePull: r.cause_pull,
58 liftedAt: r.lifted_at,
59 liftedBy: r.lifted_by,
60 });
61
62 /** A reason is one line a person reads: at most this long. */
63 export const MAX_REASON = 500;
64
65 export function setHold(
66 db: Database,
67 input: { kind: HoldKind; version: string; reason: string; probes?: number; badInRow?: number; worstMs?: number; at: number },
68 ): Hold {
69 const reason = input.reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, MAX_REASON);
70 return toHold(
71 db
72 .query<Row, [string, string, string, number, number, number, string]>(
73 `INSERT INTO keeper_hold (kind, version, reason, probes, bad_in_row, worst_ms, created_at)
74 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *`,
75 )
76 .get(input.kind, input.version, reason, input.probes ?? 0, input.badInRow ?? 0, input.worstMs ?? 0, new Date(input.at).toISOString())!,
77 );
78 }
79
80 /** The holds not lifted, oldest first. */
81 export function standingHolds(db: Database): Hold[] {
82 return db.query<Row, []>(`SELECT * FROM keeper_hold WHERE lifted_at IS NULL ORDER BY id`).all().map(toHold);
83 }
84
85 /** What stops a cut, in one line; undefined when nothing does. A failed hold whose cause has landed lets the fix through. */
86 export function blocking(holds: readonly Hold[]): string | undefined {
87 const h = holds.find((x) => x.kind === "owner" || x.causePull === null);
88 if (h === undefined) return undefined;
89 return h.kind === "owner"
90 ? `the owner holds releases: ${h.reason}`
91 : `${h.version} failed its verify (${h.reason}); waiting for a pull saying "release hold ${h.id}" to land`;
92 }
93
94 /** Does this pull's text say it is the hold's cause? Only `release hold N` does; a mere mention of the version does not. */
95 export function names(hold: Hold, text: string): boolean {
96 return new RegExp(`(^|[^0-9a-z])release hold #?${hold.id}(?![0-9])`, "i").test(text);
97 }
98
99 export function setCause(db: Database, id: number, pull: number): void {
100 db.query(`UPDATE keeper_hold SET cause_pull = ?2 WHERE id = ?1 AND cause_pull IS NULL`).run(id, pull);
101 }
102
103 export function lift(db: Database, id: number, by: string, at: number): boolean {
104 return db.query(`UPDATE keeper_hold SET lifted_at = ?3, lifted_by = ?2 WHERE id = ?1 AND lifted_at IS NULL`).run(id, by, new Date(at).toISOString())
105 .changes === 1;
106 }
107
108 /** A verified release lifts every failed hold whose cause has landed. */
109 export function liftFixed(db: Database, version: string, at: number): number {
110 return db
111 .query(`UPDATE keeper_hold SET lifted_at = ?2, lifted_by = ?1 WHERE kind = 'failed' AND cause_pull IS NOT NULL AND lifted_at IS NULL`)
112 .run(`verified ${version}`, new Date(at).toISOString()).changes;
113 }
114
115 /** Every hold, newest first. */
116 export function listHolds(db: Database, limit = 20): Hold[] {
117 return db.query<Row, [number]>(`SELECT * FROM keeper_hold ORDER BY id DESC LIMIT ?1`).all(limit).map(toHold);
118 }
119
120 export type OwnerHoldResult<T> = { ok: true; value: T } | { ok: false; error: "forbidden" | "invalid" | "not_found" | "refused"; message: string };
121
122 /**
123 * An owner's hold, set by a person who may roll back (`release/gate.ts`):
124 * the one function the MCP tool, the /releases card and the terminal call,
125 * so the refusal, the sanitised reason and the event are the same in all
126 * three. A refusal writes no hold and no event.
127 */
128 export function holdAs(app: App, user: User | undefined, reason: string): OwnerHoldResult<Hold> {
129 if (user === undefined || !mayRollBack(app, user))
130 return { ok: false, error: "forbidden", message: "holding releases is for a person who may write the forge's repository and administer it, not an agent" };
131 const clean = reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
132 if (clean === "" || clean.length > MAX_REASON) return { ok: false, error: "invalid", message: `reason is one line of 1 to ${MAX_REASON} characters` };
133 const hold = setHold(app.db, { kind: "owner", version: running().version, reason: clean, at: Date.now() });
134 app.bus.append({ kind: "release.held", actorId: user.id, payload: { hold: hold.id, reason: hold.reason, owner: true } });
135 return { ok: true, value: hold };
136 }
137
138 /** Lift an owner's hold by id; a failed verify's hold is lifted only by a verified release. */
139 export function liftAs(app: App, user: User | undefined, id: number): OwnerHoldResult<number> {
140 if (user === undefined || !mayRollBack(app, user))
141 return { ok: false, error: "forbidden", message: "holding releases is for a person who may write the forge's repository and administer it, not an agent" };
142 if (!Number.isInteger(id) || id < 1) return { ok: false, error: "invalid", message: "lift is a hold's id" };
143 const hold = standingHolds(app.db).find((h) => h.id === id);
144 if (hold === undefined) return { ok: false, error: "not_found", message: "no standing hold has that id" };
145 if (hold.kind !== "owner")
146 return { ok: false, error: "refused", message: "a failed verify's hold is lifted by a verified release after a pull saying \"release hold N\" lands" };
147 lift(app.db, id, user.username, Date.now());
148 app.bus.append({ kind: "release.hold_lifted", actorId: user.id, payload: { hold: id } });
149 return { ok: true, value: id };
150 }
src/keeper/keeper.ts added +227
@@ -0,0 +1,227 @@
1 /**
2 * The release keeper (#7557): plain code, no model, on the forge's own
3 * timer, so releases keep going out when no one is at the keyboard. It is
4 * the steward's `release.sh`, `verify-after.sh` and `verify-release.sh`
5 * moved inside:
6 *
7 * 1. **Cut** on cadence (`cadence.ts`): the known-good version and image
8 * are recorded, then the annotated tag `v<version>` is made on main's
9 * head — a tag, never a commit, so no gate in flight is voided (#6640).
10 * 2. **Promote**: the Deployment is set to the new version's image; it
11 * rolls with surge (`maxSurge 1, maxUnavailable 0`). The old forge
12 * probes `/healthz` each second while it still serves (`rollProbe`),
13 * so a roll that went dark says how long.
14 * 3. **Verify**: the new forge, finding itself the version in flight,
15 * spends ten minutes under page load (`verify.ts`).
16 * 4. **Verified live**, or **roll back** to the named known-good image
17 * (`rollback.ts`; there is no undo) and **hold** with the numbers
18 * (`hold.ts`). A release with seconds dark files one issue.
19 *
20 * Each step is an event on the outbox: `release.cut`, `release.released`,
21 * `release.verified_live`, `release.rolled_back`, `release.held`.
22 *
23 * Guardrail: the keeper's only writes are a tag, the Deployment's image,
24 * its own rows, and issues. It holds no account that may land a pull or
25 * push main, and it has no code path to either.
26 */
27
28 import type { Database } from "bun:sqlite";
29 import type { EventBus } from "../core/events.ts";
30 import { createIssue } from "../core/issues.ts";
31 import { decideCut } from "./cadence.ts";
32 import { imageAt, type Deployer } from "./deploy.ts";
33 import { blocking, liftFixed, names, setCause, setHold, standingHolds, type Hold } from "./hold.ts";
34 import { countRollProbe, dropCut, finish, getKeeperRelease, inFlight, keepTally, listKeeperReleases, markLive, recordCut, type KeeperRelease } from "./record.ts";
35 import { rollBackToNamed } from "./rollback.ts";
36 import type { Probe, Tally, Verdict } from "./verify.ts";
37
38 /** A release not live this long after its cut never rolled: 3 h (the tag's check may take 2 h, the build some more). */
39 export const ROLL_DEADLINE_MS = 3 * 3_600_000;
40
41 export interface KeeperPorts {
42 readonly db: Database;
43 readonly bus: EventBus;
44 /** The forge's own repository, for events and the issues the keeper files. */
45 readonly repo: { readonly id: string; readonly ownerId: string };
46 readonly now: () => number;
47 /** The version this process runs. */
48 readonly live: () => string;
49 readonly main: () => Promise<string | undefined>;
50 readonly newest: () => Promise<{ version: string; sha: string } | undefined>;
51 /** Merge-queue gates running now. */
52 readonly gating: () => number;
53 readonly deployer: Deployer;
54 /** Make the annotated tag `v<version>` on `sha` and send it where the release workflow starts; why not. */
55 readonly tag: (version: string, sha: string) => Promise<string | undefined>;
56 /** One `/healthz` probe through the public URL. */
57 readonly probe: () => Promise<Probe>;
58 /** Ten minutes under page load. */
59 readonly verify: (onProbe: (t: Tally) => void) => Promise<Verdict>;
60 readonly minIntervalMs?: number;
61 }
62
63 /** Pulls of the forge's repository merged since `since`: number, title and body. */
64 function landedSince(db: Database, repoId: string, since: string): { number: number; text: string }[] {
65 return db
66 .query<{ number: number; title: string; body: string | null }, [string, string]>(
67 `SELECT issue.number, issue.title, issue.body FROM pull JOIN issue ON issue.id = pull.issue_id
68 WHERE issue.repo_id = ?1 AND pull.merged_at IS NOT NULL AND pull.merged_at > ?2 ORDER BY pull.merged_at LIMIT 500`,
69 )
70 .all(repoId, since)
71 .map((r) => ({ number: r.number, text: `${r.title}\n${r.body ?? ""}` }));
72 }
73
74 /** Name the landed pull that fixes each failed hold. */
75 function findCauses(p: KeeperPorts, holds: readonly Hold[]): void {
76 for (const h of holds) {
77 if (h.kind !== "failed" || h.causePull !== null) continue;
78 const fix = landedSince(p.db, p.repo.id, h.createdAt).find((x) => names(h, x.text));
79 if (fix !== undefined) setCause(p.db, h.id, fix.number);
80 }
81 }
82
83 /** One issue for a release that went dark, unless one by its title is open. */
84 function fileDark(p: KeeperPorts, r: KeeperRelease): number | undefined {
85 if (r.secondsDark <= 0) return undefined;
86 const title = `Release ${r.version} was dark for ${r.secondsDark} s`;
87 const open = p.db
88 .query<{ n: number }, [string, string]>(`SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open'`)
89 .get(p.repo.id, title)!.n;
90 if (open > 0) return undefined;
91 const made = createIssue(p.db, {
92 repoId: p.repo.id,
93 authorId: p.repo.ownerId,
94 title,
95 body:
96 `_Filed by the release keeper, not written by a person._\n\n` +
97 `${r.version} (${r.sha.slice(0, 12)}) did not answer \`/healthz\` with a 200 for ${r.secondsDark} probe-seconds: ` +
98 `${r.rollNon200} of ${r.rollProbes} during the roll, the rest of ${r.probes} under page load; worst ${r.worstMs} ms. ` +
99 `The target is 0 s: the forge rolls with \`maxSurge 1, maxUnavailable 0\`. ` +
100 `${r.stage === "rolled_back" ? `It was rolled back to ${r.knownGood.version}.` : "It stayed live."}\n\n` +
101 `Files: src/keeper/keeper.ts, charts/app/templates/deployment.yaml`,
102 });
103 if (!made.ok) return undefined;
104 p.bus.append({ kind: "issue.opened", repoId: p.repo.id, actorId: p.repo.ownerId, payload: { number: made.value.number } });
105 return made.value.number;
106 }
107
108 /** The release each keeper is verifying; keyed by its ports, so one keeper (or one test) never sees another's. */
109 const verifying = new WeakMap<KeeperPorts, number>();
110
111 /** The verify of an in-flight release this process is the new version of, to its end. */
112 async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<string> {
113 if (verifying.has(p)) return `verifying ${r.version}`;
114 verifying.set(p, r.id);
115 try {
116 const verdict = await p.verify((t) => keepTally(p.db, r.id, t, r.rollNon200));
117 keepTally(p.db, r.id, verdict.tally, r.rollNon200);
118 const kept = getKeeperRelease(p.db, r.id)!;
119 const numbers = { version: r.version, probes: kept.probes, pages: kept.pages, worstMs: kept.worstMs, secondsDark: kept.secondsDark, rollNon200: kept.rollNon200 };
120 if (verdict.ok) {
121 finish(p.db, r.id, "verified", "", p.now());
122 const lifted = liftFixed(p.db, r.version, p.now());
123 p.bus.append({ kind: "release.verified_live", repoId: p.repo.id, payload: { ...numbers, holdsLifted: lifted } });
124 fileDark(p, getKeeperRelease(p.db, r.id)!);
125 return `${r.version} verified live: ${kept.probes} probes, worst ${kept.worstMs} ms, ${kept.secondsDark} s dark`;
126 }
127 return await rollBack(p, kept, verdict.reason);
128 } finally {
129 verifying.delete(p);
130 }
131 }
132
133 /** Hold with the numbers, then set the named known-good image. The hold first: this process may be the one replaced. */
134 async function rollBack(p: KeeperPorts, r: KeeperRelease, reason: string): Promise<string> {
135 const hold = setHold(p.db, { kind: "failed", version: r.version, reason, probes: r.probes, badInRow: r.badInRow, worstMs: r.worstMs, at: p.now() });
136 p.bus.append({ kind: "release.held", repoId: p.repo.id, payload: { version: r.version, hold: hold.id, reason, probes: r.probes, badInRow: r.badInRow, worstMs: r.worstMs } });
137 const back = await rollBackToNamed(p.deployer, r.knownGood);
138 const line = back.ok ? `rolled back to ${r.knownGood.version} (${back.image})` : `rollback failed: ${back.line}`;
139 finish(p.db, r.id, "rolled_back", `${reason}; ${line}`, p.now());
140 p.bus.append({ kind: "release.rolled_back", repoId: p.repo.id, payload: { version: r.version, to: r.knownGood.version, image: r.knownGood.image, ok: back.ok } });
141 fileDark(p, getKeeperRelease(p.db, r.id)!);
142 return `${r.version} failed its verify: ${reason}; ${line}; hold ${hold.id} set`;
143 }
144
145 /** One step of the keeper; the line says what it did or why it did nothing. */
146 export async function tick(p: KeeperPorts): Promise<string> {
147 const holds = standingHolds(p.db);
148 findCauses(p, holds);
149 const flying = inFlight(p.db);
150 if (flying !== undefined) {
151 if (p.live() === flying.version) {
152 markLive(p.db, flying.id, p.now());
153 return verifyAndDecide(p, getKeeperRelease(p.db, flying.id)!);
154 }
155 // A cut recorded but never tagged (the process died between the two): tag it now, or drop it.
156 if (flying.stage === "rolling" && (await p.newest())?.version !== flying.version) {
157 const refused = await tagged(p, flying);
158 if (refused !== undefined) return refused;
159 }
160 if (flying.stage === "rolling" && p.now() - Date.parse(flying.cutAt) > ROLL_DEADLINE_MS)
161 return rollBack(p, flying, `${flying.version} was not live ${ROLL_DEADLINE_MS / 3_600_000} h after its cut`);
162 // A promote that did not take (the API refused, or something set the image back) is sent again.
163 const want = imageAt(flying.knownGood.image, flying.version);
164 if (flying.stage === "rolling" && (await p.deployer.image()) !== want) await promote(p, flying.version, want);
165 return `${flying.version} is ${flying.stage}`;
166 }
167 const main = await p.main();
168 const newest = await p.newest();
169 const last = listKeeperReleases(p.db, 1)[0];
170 const decision = decideCut({
171 main: main ?? "",
172 newest,
173 live: p.live(),
174 settled: newest !== undefined && rolledBack(p.db, newest.version),
175 inFlight: false,
176 gating: p.gating(),
177 lastCutAt: last === undefined ? undefined : Date.parse(last.cutAt),
178 hold: blocking(standingHolds(p.db)),
179 now: p.now(),
180 ...(p.minIntervalMs === undefined ? {} : { minIntervalMs: p.minIntervalMs }),
181 });
182 if (!decision.cut || main === undefined) return decision.cut ? "main is unreadable" : decision.why;
183 const image = await p.deployer.image();
184 const knownGood = { version: p.live(), image };
185 // The row first, the tag second: a crash between them leaves a `rolling` row
186 // whose tag the next tick sends (`tagged`), never a tag the keeper has no row for.
187 const r = recordCut(p.db, { version: decision.version, sha: main, knownGood, at: p.now() });
188 const refused = await tagged(p, r);
189 if (refused !== undefined) return refused;
190 const sent = await promote(p, r.version, imageAt(image, r.version));
191 return `cut ${r.version} at ${main.slice(0, 12)}; ${sent}; known good: ${knownGood.version}`;
192 }
193
194 /** Make the release's tag; when it is refused the cut is dropped and the line says why. */
195 async function tagged(p: KeeperPorts, r: KeeperRelease): Promise<string | undefined> {
196 const refused = await p.tag(r.version, r.sha);
197 if (refused !== undefined) {
198 dropCut(p.db, r.id);
199 return `not cut: ${refused}`;
200 }
201 p.bus.append({ kind: "release.cut", repoId: p.repo.id, payload: { version: r.version, sha: r.sha, knownGood: r.knownGood.version, image: r.knownGood.image } });
202 return undefined;
203 }
204
205 /** Set the Deployment to the new version's image; it rolls with surge. */
206 async function promote(p: KeeperPorts, version: string, image: string): Promise<string> {
207 try {
208 await p.deployer.setImage(image);
209 } catch (error) {
210 return `promote to ${image} refused (${error instanceof Error ? error.message : String(error)}); sent again next tick`;
211 }
212 p.bus.append({ kind: "release.released", repoId: p.repo.id, payload: { version, image } });
213 return `promoted to ${image}`;
214 }
215
216 /** The keeper rolled this version back: its tag is settled, not still promoting. */
217 function rolledBack(db: Database, version: string): boolean {
218 return db.query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM keeper_release WHERE version = ?1 AND stage = 'rolled_back'`).get(version)!.n > 0;
219 }
220
221 /** The old forge's probe while a release rolls: one `/healthz`, counted toward the roll. */
222 export async function rollProbe(p: Pick<KeeperPorts, "db" | "live" | "probe">): Promise<void> {
223 const r = inFlight(p.db);
224 if (r === undefined || r.stage !== "rolling" || p.live() === r.version) return;
225 const got = await p.probe();
226 countRollProbe(p.db, r.id, got.status === 200);
227 }
src/keeper/record.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The keeper's releases (#7557): one row per version it cut, from the cut
3 * to `verified` or `rolled_back`, with the known-good image recorded before
4 * the cut and what the probes counted. Plain rows in, plain rows out.
5 *
6 * Stages: `rolling` (tagged and promoted; the old forge still serves),
7 * `verifying` (the new version answers; ten minutes under page load),
8 * `verified` (verified live), `rolled_back` (set back to the known-good
9 * image, a hold stands).
10 */
11
12 import type { Database } from "bun:sqlite";
13 import type { Tally } from "./verify.ts";
14
15 export type Stage = "rolling" | "verifying" | "verified" | "rolled_back";
16 export const IN_FLIGHT: readonly Stage[] = ["rolling", "verifying"];
17
18 export interface KeeperRelease {
19 readonly id: number;
20 readonly version: string;
21 readonly sha: string;
22 readonly knownGood: { readonly version: string; readonly image: string };
23 readonly stage: Stage;
24 readonly cutAt: string;
25 readonly liveAt: string | null;
26 readonly finishedAt: string | null;
27 readonly rollProbes: number;
28 readonly rollNon200: number;
29 readonly probes: number;
30 readonly pages: number;
31 readonly badInRow: number;
32 readonly worstMs: number;
33 readonly secondsDark: number;
34 readonly reason: string;
35 }
36
37 interface Row {
38 id: number;
39 version: string;
40 sha: string;
41 known_good_version: string;
42 known_good_image: string;
43 stage: Stage;
44 cut_at: string;
45 live_at: string | null;
46 finished_at: string | null;
47 roll_probes: number;
48 roll_non200: number;
49 probes: number;
50 pages: number;
51 bad_in_row: number;
52 worst_ms: number;
53 seconds_dark: number;
54 reason: string;
55 }
56
57 const toRelease = (r: Row): KeeperRelease => ({
58 id: r.id,
59 version: r.version,
60 sha: r.sha,
61 knownGood: { version: r.known_good_version, image: r.known_good_image },
62 stage: r.stage,
63 cutAt: r.cut_at,
64 liveAt: r.live_at,
65 finishedAt: r.finished_at,
66 rollProbes: r.roll_probes,
67 rollNon200: r.roll_non200,
68 probes: r.probes,
69 pages: r.pages,
70 badInRow: r.bad_in_row,
71 worstMs: r.worst_ms,
72 secondsDark: r.seconds_dark,
73 reason: r.reason,
74 });
75
76 const iso = (ms: number) => new Date(ms).toISOString();
77
78 /** The cut, written before the tag is pushed: the known-good image is on record before anything can need it. */
79 export function recordCut(
80 db: Database,
81 input: { version: string; sha: string; knownGood: { version: string; image: string }; at: number },
82 ): KeeperRelease {
83 return toRelease(
84 db
85 .query<Row, [string, string, string, string, string]>(
86 `INSERT INTO keeper_release (version, sha, known_good_version, known_good_image, stage, cut_at)
87 VALUES (?1, ?2, ?3, ?4, 'rolling', ?5) RETURNING *`,
88 )
89 .get(input.version, input.sha, input.knownGood.version, input.knownGood.image, iso(input.at))!,
90 );
91 }
92
93 /** A cut whose tag was refused never happened: its row goes, so the cadence is not left waiting on it. */
94 export function dropCut(db: Database, id: number): void {
95 db.query(`DELETE FROM keeper_release WHERE id = ?1 AND stage = 'rolling' AND live_at IS NULL`).run(id);
96 }
97
98 /** One more `/healthz` probe while the roll runs. */
99 export function countRollProbe(db: Database, id: number, ok: boolean): void {
100 db.query(
101 `UPDATE keeper_release SET roll_probes = roll_probes + 1, roll_non200 = roll_non200 + ?2,
102 seconds_dark = seconds_dark + ?2 WHERE id = ?1 AND stage = 'rolling'`,
103 ).run(id, ok ? 0 : 1);
104 }
105
106 /** The new version answers: the verify begins. Only one forge moves it, so a second caller gets false. */
107 export function markLive(db: Database, id: number, at: number): boolean {
108 return db.query(`UPDATE keeper_release SET stage = 'verifying', live_at = ?2 WHERE id = ?1 AND stage = 'rolling'`).run(id, iso(at))
109 .changes === 1;
110 }
111
112 /** The verify's numbers so far: the seconds dark it saw are added to the roll's. */
113 export function keepTally(db: Database, id: number, t: Tally, rollDark: number): void {
114 db.query(
115 `UPDATE keeper_release SET probes = ?2, pages = ?3, bad_in_row = ?4, worst_ms = ?5, seconds_dark = ?6 WHERE id = ?1`,
116 ).run(id, t.probes, t.pages, t.worstRun, t.worstMs, rollDark + t.secondsDark);
117 }
118
119 export function finish(db: Database, id: number, stage: "verified" | "rolled_back", reason: string, at: number): void {
120 db.query(`UPDATE keeper_release SET stage = ?2, reason = ?3, finished_at = ?4 WHERE id = ?1`).run(id, stage, reason, iso(at));
121 }
122
123 /** Newest first. */
124 export function listKeeperReleases(db: Database, limit = 20): KeeperRelease[] {
125 return db.query<Row, [number]>(`SELECT * FROM keeper_release ORDER BY id DESC LIMIT ?1`).all(limit).map(toRelease);
126 }
127
128 /** The release the keeper has not finished with, if any. */
129 export function inFlight(db: Database): KeeperRelease | undefined {
130 const row = db
131 .query<Row, []>(`SELECT * FROM keeper_release WHERE stage IN ('rolling', 'verifying') ORDER BY id DESC LIMIT 1`)
132 .get();
133 return row === null ? undefined : toRelease(row);
134 }
135
136 export function getKeeperRelease(db: Database, id: number): KeeperRelease | undefined {
137 const row = db.query<Row, [number]>(`SELECT * FROM keeper_release WHERE id = ?1`).get(id);
138 return row === null ? undefined : toRelease(row);
139 }
src/keeper/rollback.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The one rollback the release keeper has (#7557): set the Deployment to
3 * the known-good image recorded before the cut, by name. Never "the
4 * previous revision": that is whatever the controller last held, and on
5 * 2026-10-27 it was the broken version. This is the one action the keeper
6 * takes without a canary, because the image it sets was already live.
7 */
8
9 import { isImage, type Deployer } from "./deploy.ts";
10
11 export interface KnownGood {
12 readonly version: string;
13 readonly image: string;
14 }
15
16 export type RolledBack = { readonly ok: true; readonly image: string } | { readonly ok: false; readonly line: string };
17
18 export async function rollBackToNamed(deployer: Deployer, good: KnownGood): Promise<RolledBack> {
19 if (!isImage(good.image)) return { ok: false, line: `the recorded known-good image of ${good.version} is not an image reference` };
20 try {
21 await deployer.setImage(good.image);
22 return { ok: true, image: good.image };
23 } catch (error) {
24 return { ok: false, line: `could not set ${good.image}: ${error instanceof Error ? error.message : String(error)}` };
25 }
26 }
src/keeper/verify.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * Proving a release live (#7557), as the steward's `verify-release.sh` did:
3 * for ten minutes, the pages people open are fetched one at a time as an
4 * admin (/boards, the forge's repository, /explore, /) while `/healthz` is
5 * probed once a second. A probe is bad when it is not a 200 or takes over
6 * 10 s; three bad probes in a row fail the release.
7 *
8 * Each probe stands for one second, so the seconds dark are the probes
9 * that got no 200 — a slow 200 is slow, not dark. The same tally counts
10 * the roll (`roll: true`), where the target is zero with surge.
11 *
12 * `tally` is plain data in, plain data out. `verifyUnderLoad` drives it
13 * with the fetch, clock and sleep it is handed, so a test runs ten minutes
14 * of probes in no time.
15 */
16
17 /** A probe slower than this is bad: 10 s. */
18 export const SLOW_MS = 10_000;
19 /** This many bad probes in a row fail a release. */
20 export const FAIL_AFTER = 3;
21 /** How long a release is held under page load: 10 minutes. */
22 export const VERIFY_MS = 10 * 60_000;
23 /** One `/healthz` probe a second. */
24 export const PROBE_EVERY_MS = 1000;
25
26 export interface Probe {
27 /** The HTTP status; 0 when nothing answered. */
28 readonly status: number;
29 readonly ms: number;
30 }
31
32 export interface Tally {
33 readonly probes: number;
34 readonly non200: number;
35 /** Bad probes in a row now, and the most there have been. */
36 readonly badInRow: number;
37 readonly worstRun: number;
38 readonly worstMs: number;
39 readonly secondsDark: number;
40 /** Pages fetched under load, and how many of them were not a 200. */
41 readonly pages: number;
42 readonly pagesBad: number;
43 }
44
45 export const EMPTY: Tally = { probes: 0, non200: 0, badInRow: 0, worstRun: 0, worstMs: 0, secondsDark: 0, pages: 0, pagesBad: 0 };
46
47 export const isBad = (p: Probe): boolean => p.status !== 200 || p.ms > SLOW_MS;
48
49 /** The tally with one more `/healthz` probe. */
50 export function tally(t: Tally, p: Probe): Tally {
51 const bad = isBad(p);
52 const badInRow = bad ? t.badInRow + 1 : 0;
53 return {
54 ...t,
55 probes: t.probes + 1,
56 non200: t.non200 + (p.status === 200 ? 0 : 1),
57 badInRow,
58 worstRun: Math.max(t.worstRun, badInRow),
59 worstMs: Math.max(t.worstMs, Math.round(p.ms)),
60 secondsDark: t.secondsDark + (p.status === 200 ? 0 : 1),
61 };
62 }
63
64 /** The tally with one more page fetched under load; its latency counts toward the worst. */
65 export function tallyPage(t: Tally, p: Probe): Tally {
66 return { ...t, pages: t.pages + 1, pagesBad: t.pagesBad + (isBad(p) ? 1 : 0), worstMs: Math.max(t.worstMs, Math.round(p.ms)) };
67 }
68
69 export const failed = (t: Tally): boolean => t.worstRun >= FAIL_AFTER;
70
71 /** Why a failed tally failed, with its numbers, in one line. */
72 export function failure(t: Tally): string {
73 return `${t.worstRun} bad /healthz probes in a row (non-200 or over ${SLOW_MS / 1000} s) after ${t.probes} probes; ${t.non200} non-200, worst ${t.worstMs} ms`;
74 }
75
76 export interface VerifyPorts {
77 /** One GET: the status (0 when nothing answered) and how long it took. */
78 readonly get: (path: string, asAdmin: boolean) => Promise<Probe>;
79 readonly now: () => number;
80 readonly sleep: (ms: number) => Promise<void>;
81 /** The pages fetched in turn, one at a time. */
82 readonly pages: readonly string[];
83 readonly durationMs?: number;
84 /** Told after each probe, so the record keeps up while the verify runs. */
85 readonly onProbe?: (t: Tally) => void;
86 }
87
88 export type Verdict = { readonly ok: boolean; readonly tally: Tally; readonly reason: string };
89
90 /** Probe `/healthz` each second under page load until the time is up or three bad probes in a row fail it. */
91 export async function verifyUnderLoad(ports: VerifyPorts, start: Tally = EMPTY): Promise<Verdict> {
92 const end = ports.now() + (ports.durationMs ?? VERIFY_MS);
93 let t = start;
94 let stop = false;
95 // The load: one page at a time, the next when the last has answered.
96 const load = (async () => {
97 for (let i = 0; !stop && ports.pages.length > 0; i++) {
98 const page = await ports.get(ports.pages[i % ports.pages.length]!, true);
99 t = tallyPage(t, page);
100 if (ports.now() >= end) break;
101 }
102 })();
103 while (ports.now() < end) {
104 const began = ports.now();
105 t = tally(t, await ports.get("/healthz", false));
106 ports.onProbe?.(t);
107 if (failed(t)) break;
108 await ports.sleep(Math.max(0, PROBE_EVERY_MS - (ports.now() - began)));
109 }
110 stop = true;
111 await load;
112 return failed(t) ? { ok: false, tally: t, reason: failure(t) } : { ok: true, tally: t, reason: "" };
113 }
114
115 /** A GET through `fetch` that never throws: a refused connection or a timeout is status 0. */
116 export function timedGet(
117 base: string,
118 headers: (asAdmin: boolean) => Record<string, string>,
119 fetcher: typeof fetch = fetch,
120 now: () => number = () => performance.now(),
121 ): (path: string, asAdmin: boolean) => Promise<Probe> {
122 return async (path, asAdmin) => {
123 const began = now();
124 try {
125 const res = await fetcher(new URL(path, base), {
126 headers: headers(asAdmin),
127 redirect: "manual",
128 signal: AbortSignal.timeout(SLOW_MS + 5000),
129 });
130 await res.arrayBuffer();
131 return { status: res.status, ms: now() - began };
132 } catch {
133 return { status: 0, ms: now() - began };
134 }
135 };
136 }
src/keeper/wire.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The release keeper's ports, from a running forge (#7557): the forge's own
3 * repository for main and the tags, the merge queue for "mid-landing", the
4 * kube API for the Deployment, and the public URL for the probes.
5 *
6 * On when `FORGE_RELEASE_KEEPER=on`, `FORGE_REPO` names a repository here,
7 * and the forge runs in a cluster. The Deployment and its container are
8 * `FORGE_KEEPER_DEPLOYMENT` and `FORGE_KEEPER_CONTAINER` (both default
9 * `forge`); the pod's service account needs `get` and `patch` on it.
10 */
11
12 import type { App } from "../app.ts";
13 import { originPush } from "../actions/branches.ts";
14 import { serviceAccount } from "../cluster/jobs.ts";
15 import { getRepo, type Repo } from "../core/repos.ts";
16 import { createSession, destroySession } from "../core/sessions.ts";
17 import { getUserById, type User } from "../core/users.ts";
18 import { gatesInFlight } from "../checks/queue.ts";
19 import { migrationFiles, NUMBERED } from "../core/schema.ts";
20 import { runGit } from "../git/env.ts";
21 import { repoDir } from "../git/repos.ts";
22 import { ownProbeHeaders } from "../kernel/probe.ts";
23 import { running } from "../kernel/version.ts";
24 import { compareVersions, readTags, recordFor } from "../release/shipped.ts";
25 import { kubeDeployer } from "./deploy.ts";
26 import type { KeeperPorts } from "./keeper.ts";
27 import { timedGet, verifyUnderLoad } from "./verify.ts";
28
29 const LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
30 const OID = /^[0-9a-f]{40}$/;
31 const VERSION = /^\d+\.\d+\.\d+$/;
32 const ZERO = "0".repeat(40);
33
34 export type KeeperEnv = { on: true; deployment: string; container: string } | { on: false; why: string };
35
36 /** Whether the keeper runs, from the environment. */
37 export function keeperEnv(env: Record<string, string | undefined>): KeeperEnv {
38 if (env.FORGE_RELEASE_KEEPER !== "on") return { on: false, why: "FORGE_RELEASE_KEEPER is not on" };
39 if (!env.KUBERNETES_SERVICE_HOST) return { on: false, why: "not running in a cluster" };
40 const deployment = env.FORGE_KEEPER_DEPLOYMENT || "forge";
41 const container = env.FORGE_KEEPER_CONTAINER || "forge";
42 if (!LABEL.test(deployment) || !LABEL.test(container)) return { on: false, why: "FORGE_KEEPER_DEPLOYMENT and FORGE_KEEPER_CONTAINER must be DNS labels" };
43 return { on: true, deployment, container };
44 }
45
46 /** The first active site admin that is a person: the account the page load signs in as. */
47 function anAdmin(app: App): User | undefined {
48 const row = app.db
49 .query<{ id: string }, []>(`SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 ORDER BY created_at LIMIT 1`)
50 .get();
51 return row === null ? undefined : getUserById(app.db, row.id);
52 }
53
54 /** The pages a release is verified under, one at a time: the ones people open. */
55 export function loadPages(repo: Pick<Repo, "ownerName" | "name">): string[] {
56 return ["/boards", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/explore", "/"];
57 }
58
59 export function keeperPorts(app: App, input: { forgeUrl: string; deployment: string; container: string }): KeeperPorts | string {
60 const named = app.config.forgeRepo;
61 if (named === undefined) return "FORGE_REPO is not set";
62 const [owner = "", name = ""] = named.split("/");
63 const repo = getRepo(app.db, owner, name);
64 if (repo === undefined) return `FORGE_REPO names ${named}, which is no repository on this forge`;
65 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
66 const deployer = kubeDeployer({ credentials: serviceAccount(), deployment: input.deployment, container: input.container });
67 const probeHeaders = ownProbeHeaders(app.config.dataDir);
68 const at = (version: string) => runGit(["rev-parse", "--verify", "--quiet", `refs/tags/v${version}^{commit}`], { cwd: dir });
69 return {
70 db: app.db,
71 bus: app.bus,
72 repo: { id: repo.id, ownerId: repo.ownerId },
73 now: () => Date.now(),
74 live: () => running().version,
75 async main() {
76 const head = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${repo.defaultBranch}^{commit}`], { cwd: dir });
77 return head.ok && OID.test(head.value) ? head.value : undefined;
78 },
79 async newest() {
80 const tags = await readTags(dir);
81 if (!tags.ok) return undefined;
82 const top = [...tags.value].filter((t) => VERSION.test(t.version)).sort((a, b) => compareVersions(b.version, a.version))[0];
83 if (top === undefined) return undefined;
84 const sha = await at(top.version);
85 return sha.ok && OID.test(sha.value) ? { version: top.version, sha: sha.value } : undefined;
86 },
87 gating: gatesInFlight,
88 deployer,
89 async tag(version, sha) {
90 if (!VERSION.test(version) || !OID.test(sha)) return "not a version and a commit";
91 // The record `scripts/cut-release.ts` writes. The migrations are this process's: the keeper cuts only
92 // when main has moved past a live tag, and each forge cuts the code it was built from at the earliest.
93 const record = JSON.stringify(recordFor(version, migrationFiles, NUMBERED));
94 const made = await runGit(["tag", "--annotate", "--file=-", `v${version}`, sha], {
95 cwd: dir,
96 stdin: `${record}\n`,
97 env: { GIT_COMMITTER_NAME: "release keeper", GIT_COMMITTER_EMAIL: "[email protected]" },
98 });
99 if (!made.ok) return made.error.stderr || "git refused the tag";
100 const tagOid = await runGit(["rev-parse", `refs/tags/v${version}`], { cwd: dir });
101 const owner = getUserById(app.db, repo.ownerId);
102 if (!tagOid.ok || owner === undefined) return undefined;
103 // A tag the origin did not take is taken back here too: the keeper drops the cut, and a
104 // tag left behind would be the newest, never live, and stall the cadence.
105 const untag = async (why: string) => (await runGit(["tag", "--delete", `v${version}`], { cwd: dir }), why);
106 const push = await originPush(app, owner, repo);
107 if (!push.ok) return untag(`the origin refused v${version}: ${push.error}`);
108 if (push.value === undefined) return undefined;
109 const sent = await push.value([{ ref: `refs/tags/v${version}`, oldOid: ZERO, newOid: tagOid.value }]);
110 return sent.ok ? undefined : untag(`pushing v${version} to the origin failed: ${sent.error}`);
111 },
112 probe: () => timedGet(input.forgeUrl, () => probeHeaders)("/healthz", false),
113 async verify(onProbe) {
114 const admin = anAdmin(app);
115 const session = admin === undefined ? undefined : createSession(app.db, admin.id);
116 const cookie = session === undefined ? {} : { cookie: `forge_session=${session.token}` };
117 try {
118 return await verifyUnderLoad({
119 get: timedGet(input.forgeUrl, (asAdmin) => (asAdmin ? cookie : probeHeaders)),
120 now: () => Date.now(),
121 sleep: (ms) => Bun.sleep(ms),
122 pages: loadPages(repo),
123 onProbe,
124 });
125 } finally {
126 if (session !== undefined) destroySession(app.db, session.token);
127 }
128 },
129 };
130 }
src/main.ts +22
@@ -70,6 +70,8 @@ import { proveRunning } from "./release/prove.ts";
7070 import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts";
7171 import { readLandings } from "./release/landings.ts";
7272 import { ownTagsReader } from "./release/own_tags.ts";
73 import { rollProbe, tick as keeperTick } from "./keeper/keeper.ts";
74 import { keeperEnv, keeperPorts } from "./keeper/wire.ts";
7375 import { snapshotPlace } from "./actions/health.ts";
7476 import { capacityDefault } from "./core/capacity.ts";
7577 import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts";
@@ -627,6 +629,26 @@ if (app.config.forgeRepo) {
627629 void readLandings(app);
628630 setInterval(() => void readLandings(app), 60_000);
629631 }
632 // The release keeper (#7557): cut on cadence, promote, verify under page load, roll back to the named
633 // known-good image and hold. Off unless FORGE_RELEASE_KEEPER=on in a cluster (keeper/wire.ts).
634 const keeperOn = keeperEnv(process.env);
635 if (keeperOn.on) {
636 const ports = keeperPorts(app, { forgeUrl, deployment: keeperOn.deployment, container: keeperOn.container });
637 if (typeof ports === "string") app.log.warn("the release keeper is off", { why: ports });
638 else {
639 let said = "";
640 const step = () =>
641 void keeperTick(ports)
642 .then((line) => {
643 if (line !== said) app.log.info("release keeper", { line });
644 said = line;
645 })
646 .catch((error) => app.log.warn("release keeper step failed", { error: String(error) }));
647 step();
648 setInterval(step, 60_000);
649 setInterval(() => void rollProbe(ports).catch(() => {}), 1000);
650 }
651 } else app.log.info("the release keeper is off", { why: keeperOn.why });
630652 // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person
631653 // put on one before that is reported here for the owner to take off — the
632654 // forge never removes it on its own.
src/tui/forge.ts +9
@@ -62,6 +62,7 @@ import { noteRead, telemetryReader } from "../actions/telemetry.ts";
6262 import { queuesFor, systemFor } from "../system/telemetry.ts";
6363 import { apiScreen } from "./screens/api.ts";
6464 import { changelogScreen } from "./screens/changelog.ts";
65 import { keeperScreen } from "./screens/keeper.ts";
6566 import { releasesScreen } from "./screens/releases.ts";
6667 import { mayRead } from "../release/gate.ts";
6768 import { fileHoldsPrompt } from "./screens/fileholds.ts";
@@ -333,6 +334,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
333334 hint: "the live release and the kept ones",
334335 run: () => ctx.push(releasesScreen(session, ctx)),
335336 });
337 // The release keeper's card (#7557), gated as /releases is.
338 if (mayRead(session.app, session.user))
339 commands.push({
340 id: "keeper",
341 title: "Release keeper",
342 hint: "what holds the next cut, and each release it cut with its probes",
343 run: () => ctx.push(keeperScreen(session, ctx)),
344 });
336345 // Stalled flow steps the viewer can unblock, as the home's cards show them (#1807).
337346 if (session.signedIn)
338347 commands.push({
src/tui/screens/keeper.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * The release keeper in the terminal (#7557), the /releases card's read:
3 * what holds the next cut, then each release the keeper cut with its stage,
4 * the known-good version it rolls back to and what the probes counted.
5 * Gated by `release/gate.ts` as the page is. `h` holds releases with a
6 * reason and `h` with `lift N` lifts an owner's hold, through the same
7 * functions the card and MCP `release_hold` call.
8 */
9
10 import { blocking, holdAs, liftAs, standingHolds } from "../../keeper/hold.ts";
11 import { listKeeperReleases, type KeeperRelease, type Stage } from "../../keeper/record.ts";
12 import { mayRead, mayRollBack } from "../../release/gate.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import type { Session } from "../session.ts";
16 import { style } from "../term/style.ts";
17 import { truncate, wrap } from "../term/width.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { emptyState, type Tone } from "../ui/widgets.ts";
20 import { ListState, ago, drawList, metaOf, promptScreen, type ListRow } from "./common.ts";
21
22 const STAGE: Record<Stage, { label: string; tone: Tone }> = {
23 rolling: { label: "rolling", tone: "default" },
24 verifying: { label: "verifying", tone: "default" },
25 verified: { label: "verified live", tone: "success" },
26 rolled_back: { label: "rolled back", tone: "danger" },
27 };
28
29 export interface KeeperRead {
30 /** What holds the next cut, or the line that says nothing does. */
31 readonly blocked: string;
32 readonly releases: readonly KeeperRelease[];
33 }
34
35 /** The card's content for this person; undefined for anyone the page would 404. */
36 export function readKeeper(f: Session): KeeperRead | undefined {
37 if (!mayRead(f.app, f.user)) return undefined;
38 return { blocked: blocking(standingHolds(f.db)) ?? "Nothing holds the next cut.", releases: listKeeperReleases(f.db) };
39 }
40
41 export function keeperRows(releases: readonly KeeperRelease[]): ListRow[] {
42 return releases.map((r) => ({
43 title: r.version,
44 tone: STAGE[r.stage].tone,
45 meta: metaOf(
46 STAGE[r.stage].label,
47 `cut ${ago(r.cutAt)}`,
48 `known good ${r.knownGood.version}`,
49 `roll ${r.rollNon200}/${r.rollProbes} non-200`,
50 `${r.probes} probes, ${r.pages} pages`,
51 `worst ${r.worstMs} ms`,
52 `${r.secondsDark} s dark`,
53 ),
54 }));
55 }
56
57 /** One line of the hold prompt: a reason holds, `lift N` lifts; the sentence to show. */
58 export function holdLine(f: Session, text: string): { ok: boolean; says: string } {
59 const lifting = /^lift\s+#?(\d+)$/i.exec(text.trim());
60 if (lifting !== null) {
61 const done = liftAs(f.app, f.user, Number(lifting[1]));
62 return done.ok ? { ok: true, says: `Hold ${done.value} lifted.` } : { ok: false, says: done.message };
63 }
64 const held = holdAs(f.app, f.user, text);
65 return held.ok ? { ok: true, says: `Releases held: ${held.value.reason}` } : { ok: false, says: held.message };
66 }
67
68 export function keeperScreen(f: Session, ctx: Ctx): Screen {
69 const state = new ListState();
70 let read: KeeperRead | undefined;
71 const refresh = (): void => {
72 read = readKeeper(f);
73 state.clamp(read?.releases.length ?? 0);
74 ctx.invalidate();
75 };
76 const screen: Screen = {
77 title: "Release keeper",
78 keys: [binding(["j", "k"], "move"), ...(mayRollBack(f.app, f.user) ? [binding(["h"], "hold / lift")] : []), binding(["esc", "q"], "close")],
79 load: async () => refresh(),
80 render(area, s) {
81 const c = ctx.theme.c;
82 if (read === undefined) {
83 emptyState(s, area, c, { title: "Not yours to see", body: "The release keeper is for the forge's administrators and its repository's readers." });
84 return;
85 }
86 // What holds the cut ends with what to land ("release hold N"): wrapped, up to three lines, never cut short of it.
87 const lines = wrap(read.blocked, area.w).slice(0, 3);
88 const [head, body] = split(area, "v", [len(lines.length + 1), fill(1)]);
89 lines.forEach((l, i) => s.text(head!.x, head!.y + i, truncate(l, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w));
90 if (read.releases.length === 0) {
91 emptyState(s, body!, c, { title: "No keeper releases yet", body: "The keeper cuts when main has moved past a live tag (FORGE_RELEASE_KEEPER=on)." });
92 return;
93 }
94 drawList(s, body!, c, state, keeperRows(read.releases));
95 },
96 key(e) {
97 if (e.key === "esc" || e.key === "q") ctx.pop();
98 else if (e.key === "h" && mayRollBack(f.app, f.user))
99 ctx.push(
100 promptScreen(ctx, screen, {
101 title: "Hold releases",
102 label: "a reason holds the next cut · lift N lifts an owner hold",
103 onSubmit: (text) => {
104 const done = holdLine(f, text);
105 if (!done.ok) return done.says;
106 ctx.toast(done.says);
107 refresh();
108 return undefined;
109 },
110 }),
111 );
112 else state.key(e, read?.releases.length ?? 0);
113 return true;
114 },
115 };
116 return screen;
117 }
src/web/pages/keeper-panel.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The release keeper on /releases (#7557): what stops the next cut, and
3 * each release it cut with its stage, the known-good version it rolls back
4 * to, and what the probes counted. Nothing when it has done nothing.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { blocking, MAX_REASON, standingHolds } from "../../keeper/hold.ts";
9 import { listKeeperReleases, type Stage } from "../../keeper/record.ts";
10 import { html, when, type Html } from "../html.ts";
11 import { badge, btn, card, time, type Tone } from "../ui.ts";
12
13 const STAGE: Record<Stage, { label: string; tone: Tone }> = {
14 rolling: { label: "rolling", tone: "attn" },
15 verifying: { label: "verifying", tone: "attn" },
16 verified: { label: "verified live", tone: "success" },
17 rolled_back: { label: "rolled back", tone: "danger" },
18 };
19
20 /** `mayHold` is `mayRollBack`: the card then offers the owner's hold and its lift. */
21 export function keeperPanel(db: Database, mayHold = false): Html | undefined {
22 const releases = listKeeperReleases(db);
23 const holds = standingHolds(db);
24 const blocked = blocking(holds);
25 if (releases.length === 0 && blocked === undefined && !mayHold) return undefined;
26 const rows = releases.map((r) => {
27 const s = STAGE[r.stage];
28 return html`<tr>
29 <td>${r.version} ${badge(s.label, { tone: s.tone })}</td>
30 <td>${time(r.cutAt)}</td>
31 <td>${r.knownGood.version}</td>
32 <td>${r.rollNon200} of ${r.rollProbes}</td>
33 <td>${r.probes} / ${r.pages}</td>
34 <td>${r.worstMs} ms</td>
35 <td>${r.secondsDark} s</td>
36 </tr>${when(r.reason !== "", () => html`<tr><td colspan="7" class="small muted">${r.reason}</td></tr>`)}`;
37 });
38 return card(html`<h2>Release keeper</h2>
39 <p class="small ${blocked === undefined ? "muted" : ""}" id="keeper-blocked">${blocked ?? "Nothing holds the next cut."}</p>
40 ${when(rows.length > 0, () => html`<table class="table">
41 <thead><tr><th>Version</th><th>Cut</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Dark</th></tr></thead>
42 <tbody>${rows}</tbody>
43 </table>`)}
44 ${when(mayHold, () => html`${holds.filter((h) => h.kind === "owner").map(
45 (h) => html`<form method="post" action="/releases/hold/${h.id}/lift" class="small">Owner hold ${h.id}: ${h.reason} ${btn("Lift", { type: "submit" })}</form>`,
46 )}
47 <form method="post" action="/releases/hold" class="small">
48 <label>Hold releases <input name="reason" maxlength="${MAX_REASON}" required placeholder="why"></label> ${btn("Hold", { type: "submit" })}
49 </form>`)}`);
50 }
src/web/pages/releases.ts +23 −1
@@ -19,6 +19,8 @@ import { readForm } from "../form.ts";
1919 import { html, when, type Html } from "../html.ts";
2020 import { flashTo, page } from "../layout.ts";
2121 import { badge, btn, card, empty, sha, time } from "../ui.ts";
22 import { holdAs, liftAs } from "../../keeper/hold.ts";
23 import { keeperPanel } from "./keeper-panel.ts";
2224
2325 /** The live line (`liveSince`) for the home, or nothing when no release is known to be live. */
2426 export function liveLine(ctx: Ctx): Html | undefined {
@@ -81,7 +83,7 @@ export async function releasesPage(ctx: Ctx): Promise<Response> {
8183 const response = page({
8284 ctx,
8385 title: "Releases",
84 body: html`<div class="page-head"><h1>Releases</h1></div>${body}`,
86 body: html`<div class="page-head"><h1>Releases</h1></div>${keeperPanel(ctx.app.db, writer)}${body}`,
8587 });
8688 notePhase(ctx.request, "render", performance.now() - rendering);
8789 return response;
@@ -101,3 +103,23 @@ export async function rollbackSubmit(ctx: Ctx): Promise<Response> {
101103 if (!result.ok) return refuse(result.line);
102104 return flashTo(ctx, "/releases", { message: `Rolled back to ${target.slice(0, 7)}; it reads healthy.` });
103105 }
106
107 /** POST /releases/hold: an owner's hold with a reason, through the function MCP calls. */
108 export async function releaseHoldSubmit(ctx: Ctx): Promise<Response> {
109 const form = await readForm(ctx, "rw");
110 if (form instanceof Response) return form;
111 const held = holdAs(ctx.app, ctx.auth.user, String(form.get("reason") ?? ""));
112 return held.ok
113 ? flashTo(ctx, "/releases", { message: `Releases held: ${held.value.reason}` })
114 : flashTo(ctx, "/releases", { tone: "danger", message: held.message });
115 }
116
117 /** POST /releases/hold/:id/lift: lift an owner's hold. */
118 export async function liftSubmit(ctx: Ctx): Promise<Response> {
119 const form = await readForm(ctx, "rw");
120 if (form instanceof Response) return form;
121 const lifted = liftAs(ctx.app, ctx.auth.user, Number(ctx.params["id"]));
122 return lifted.ok
123 ? flashTo(ctx, "/releases", { message: `Hold ${lifted.value} lifted.` })
124 : flashTo(ctx, "/releases", { tone: "danger", message: lifted.message });
125 }
test/boards-starting.test.ts +3 −3
@@ -36,10 +36,10 @@ test("a forge with no person to found the repo writes nothing", async () => {
3636 expect(await offerStartingBoards(bare)).toEqual([]);
3737 });
3838
39 test("a fresh boot writes the ten starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "reach", "releases", "restarts", "security", "serving", "throughput", "usage"]);
39 test("a fresh boot writes the eleven starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "maintenance", "reach", "releases", "restarts", "security", "serving", "throughput", "usage"]);
4141 const boards = await listBoards(app.reposRoot);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "reach", "releases", "restarts", "security", "serving", "throughput", "usage"]);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "maintenance", "reach", "releases", "restarts", "security", "serving", "throughput", "usage"]);
4343 let panels = 0;
4444 for (const { board } of boards) {
4545 for (const p of board.panels) {
test/helpers/keeper.ts added +101
@@ -0,0 +1,101 @@
1 /** A forge, its own repository and the release keeper's ports with a fake cluster and clock (#7557). */
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp } from "../../src/app.ts";
6 import { createRepo } from "../../src/core/repos.ts";
7 import { createUser } from "../../src/core/users.ts";
8 import { id } from "../../src/kernel/id.ts";
9 import type { Deployer } from "../../src/keeper/deploy.ts";
10 import type { KeeperPorts } from "../../src/keeper/keeper.ts";
11 import { verifyUnderLoad, type Probe } from "../../src/keeper/verify.ts";
12
13 export const A = "a".repeat(40);
14 export const B = "b".repeat(40);
15 export const C = "c".repeat(40);
16
17 /** A Deployment that remembers every image set on it. Its revision history holds a broken image "undo" would bring back. */
18 export function fakeDeployer(image: string) {
19 const state = { image, set: [] as string[], history: ["reg.test/o/forge:0.7.277"] };
20 const deployer: Deployer = {
21 image: async () => state.image,
22 async setImage(next) {
23 state.history.push(state.image);
24 state.image = next;
25 state.set.push(next);
26 },
27 };
28 return { deployer, state };
29 }
30
31 export async function keeperForge(opts: { healthz?: (n: number) => Probe } = {}) {
32 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-keeper-")), port: 0, forgeRepo: "alice/forge" });
33 const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
34 if (!u.ok) throw new Error("user");
35 const repo = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "forge", visibility: "public", description: "f" });
36 if (!repo.ok) throw new Error("repo");
37 const world = {
38 now: Date.parse("2026-10-27T12:00:00.000Z"),
39 live: "0.7.283",
40 main: B,
41 newest: { version: "0.7.283", sha: A } as { version: string; sha: string } | undefined,
42 gating: 0,
43 tags: [] as { version: string; sha: string }[],
44 /** When set, the tag is refused with this line. */
45 tagRefused: undefined as string | undefined,
46 /** Why the next tag is refused; undefined lets it through. */
47 refuseTag: undefined as string | undefined,
48 healthz: opts.healthz ?? (() => ({ status: 200, ms: 12 })),
49 pages: [] as string[],
50 };
51 const { deployer, state } = fakeDeployer("reg.test/o/forge:0.7.283");
52 let probes = 0;
53 const ports: KeeperPorts = {
54 db: app.db,
55 bus: app.bus,
56 repo: { id: repo.value.id, ownerId: u.value.id },
57 now: () => world.now,
58 live: () => world.live,
59 main: async () => world.main,
60 newest: async () => world.newest,
61 gating: () => world.gating,
62 deployer,
63 // As the forge's repository does: a tag made is the newest tag read back.
64 async tag(version, sha) {
65 if (world.refuseTag !== undefined) return world.refuseTag;
66 world.tags.push({ version, sha });
67 world.newest = { version, sha };
68 return undefined;
69 },
70 probe: async () => world.healthz(probes++),
71 verify: (onProbe) =>
72 verifyUnderLoad({
73 get: async (path) => {
74 if (path === "/healthz") {
75 const p = world.healthz(probes++);
76 world.now += p.ms;
77 return p;
78 }
79 world.pages.push(path);
80 world.now += 40;
81 return { status: 200, ms: 40 };
82 },
83 now: () => world.now,
84 sleep: async (ms) => {
85 world.now += ms;
86 },
87 pages: ["/boards", "/alice/forge", "/explore", "/"],
88 onProbe,
89 }),
90 };
91 /** A pull of the forge's repository, merged now. */
92 const land = (n: number, title: string, body = "") => {
93 const issueId = id();
94 const at = new Date(world.now).toISOString();
95 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, ?5, ?6, 'closed', ?7)`).run(issueId, repo.value.id, n, title, body, u.value.id, at);
96 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, 'main', ?2, '', '', ?3, ?4)`).run(issueId, `b/${n}`, repo.value.id, at);
97 };
98 const events = (kind: string) =>
99 app.db.query<{ payload: string }, [string]>(`SELECT payload FROM event WHERE kind = ?1 ORDER BY id`).all(kind).map((e) => JSON.parse(e.payload) as Record<string, unknown>);
100 return { app, user: u.value, repo: repo.value, world, ports, state, land, events };
101 }
test/mcp-write-scope.test.ts +1
@@ -31,6 +31,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
3131 subscription_wake: { id: "u1" },
3232 subscription_headroom: { id: "u1", percent: 30 },
3333 prompts: { repo: "alice/game", action: "remove", id: "p1" },
34 release_hold: { reason: "freeze for the demo" },
3435 telemetry_grant: { agent: "robo", on: true },
3536 hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" },
3637 app_domains: { repo: "alice/game", action: "add", host: "game.example.com" },
test/mcp.test.ts +1
@@ -203,6 +203,7 @@ describe("the forge over MCP", () => {
203203 "edit",
204204 "close",
205205 "read_file",
206 "release_keeper",
206207 "write_file",
207208 "asks",
208209 "asks_act",
test/palette-roster.test.ts +2
@@ -77,6 +77,7 @@ test("a site-admin person is offered the whole roster", () => {
7777 ["api", "API", "the forge's endpoints; run a GET"],
7878 ["changelog", "Changelog", "the last 30 releases and their pulls"],
7979 ["releases", "Releases", "the live release and the kept ones"],
80 ["keeper", "Release keeper", "what holds the next cut, and each release it cut with its probes"],
8081 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
8182 ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"],
8283 ["access", "Access", "what outside agents tried"],
@@ -135,6 +136,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
135136 ["api", "API", "the forge's endpoints; run a GET"],
136137 ["changelog", "Changelog", "the last 30 releases and their pulls"],
137138 ["releases", "Releases", "the live release and the kept ones"],
139 ["keeper", "Release keeper", "what holds the next cut, and each release it cut with its probes"],
138140 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
139141 ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"],
140142 ["secrets", "Secrets", "sealed secrets by name; set or remove one"],
test/release-keeper-cadence.test.ts added +98
@@ -0,0 +1,98 @@
1 // When the release keeper cuts (#7557): each rule refuses on its own, and a cut is a tag, never a commit to main.
2 import { describe, expect, test } from "bun:test";
3 import { decideCut, MIN_INTERVAL_MS, nextVersion, type CadenceInput } from "../src/keeper/cadence.ts";
4 import { tick } from "../src/keeper/keeper.ts";
5 import { listKeeperReleases, recordCut } from "../src/keeper/record.ts";
6 import { A, B, keeperForge } from "./helpers/keeper.ts";
7
8 const NOW = Date.parse("2026-10-27T12:00:00.000Z");
9 const ready: CadenceInput = {
10 main: B,
11 newest: { version: "0.7.283", sha: A },
12 live: "0.7.283",
13 inFlight: false,
14 gating: 0,
15 lastCutAt: NOW - MIN_INTERVAL_MS,
16 hold: undefined,
17 now: NOW,
18 };
19
20 describe("cadence", () => {
21 test("the next version is the patch plus one", () => {
22 expect(nextVersion("0.7.283")).toBe("0.7.284");
23 expect(nextVersion("1.0.9")).toBe("1.0.10");
24 expect(nextVersion("0.7")).toBeUndefined();
25 });
26
27 test("main past a live tag, nothing in flight, the queue still, the interval passed, no hold: cut", () => {
28 expect(decideCut(ready)).toEqual({ cut: true, version: "0.7.284" });
29 });
30
31 test("each rule refuses alone, and says which", () => {
32 const why = (o: Partial<CadenceInput>) => {
33 const d = decideCut({ ...ready, ...o });
34 return d.cut ? "" : d.why;
35 };
36 expect(why({ main: A })).toBe("main is still 0.7.283");
37 expect(why({ inFlight: true })).toContain("still rolling");
38 expect(why({ live: "0.7.282" })).toBe("0.7.283 is not live yet (live: 0.7.282)");
39 expect(why({ gating: 2 })).toBe("the merge queue is landing (2 gates running)");
40 // One minute short of 30 is refused; exactly 30 passes (above).
41 expect(why({ lastCutAt: NOW - MIN_INTERVAL_MS + 60_000 })).toBe("the last cut was 29 min ago; the interval is 30 min");
42 expect(why({ hold: "owner: freeze for the demo" })).toBe("held: owner: freeze for the demo");
43 expect(why({ newest: undefined })).toContain("no release tag");
44 });
45
46 test("a tag the keeper rolled back is settled: the fix after it may be cut", () => {
47 expect(decideCut({ ...ready, newest: { version: "0.7.284", sha: A }, live: "0.7.283", settled: true })).toEqual({ cut: true, version: "0.7.285" });
48 });
49
50 test("the keeper's cut is a tag on main's head, and records the known-good version and image first", async () => {
51 const f = await keeperForge();
52 const line = await tick(f.ports);
53 expect(line).toContain("cut 0.7.284");
54 expect(f.world.tags).toEqual([{ version: "0.7.284", sha: B }]);
55 const [r] = listKeeperReleases(f.app.db);
56 expect(r).toMatchObject({ version: "0.7.284", sha: B, stage: "rolling", knownGood: { version: "0.7.283", image: "reg.test/o/forge:0.7.283" } });
57 expect(f.state.set).toEqual(["reg.test/o/forge:0.7.284"]);
58 expect(f.events("release.cut")).toHaveLength(1);
59 expect(f.events("release.released")).toEqual([{ version: "0.7.284", image: "reg.test/o/forge:0.7.284" }]);
60 // While it rolls, a second tick cuts nothing more.
61 f.world.main = "d".repeat(40);
62 f.world.now += 2 * MIN_INTERVAL_MS;
63 expect(await tick(f.ports)).toBe("0.7.284 is rolling");
64 expect(f.world.tags).toHaveLength(1);
65 });
66
67 test("the queue mid-landing holds the cut", async () => {
68 const f = await keeperForge();
69 f.world.gating = 1;
70 expect(await tick(f.ports)).toContain("merge queue is landing");
71 expect(f.world.tags).toHaveLength(0);
72 expect(f.state.set).toHaveLength(0);
73 });
74
75 test("the cut is recorded before the tag: a crash between them is tagged on the next tick, not stranded", async () => {
76 const f = await keeperForge();
77 // The process died after the row and before the tag.
78 recordCut(f.app.db, { version: "0.7.284", sha: B, knownGood: { version: "0.7.283", image: "reg.test/o/forge:0.7.283" }, at: f.world.now });
79 expect(f.world.tags).toHaveLength(0);
80 expect(await tick(f.ports)).toBe("0.7.284 is rolling");
81 expect(f.world.tags).toEqual([{ version: "0.7.284", sha: B }]);
82 expect(f.state.set).toEqual(["reg.test/o/forge:0.7.284"]);
83 // Tagged once: the next tick does not tag it again.
84 await tick(f.ports);
85 expect(f.world.tags).toHaveLength(1);
86 });
87
88 test("a refused tag drops the cut, so the cadence is not left waiting on a release that never was", async () => {
89 const f = await keeperForge();
90 f.world.refuseTag = "the origin refused v0.7.284";
91 expect(await tick(f.ports)).toBe("not cut: the origin refused v0.7.284");
92 expect(listKeeperReleases(f.app.db)).toHaveLength(0);
93 expect(f.state.set).toHaveLength(0);
94 expect(f.events("release.cut")).toHaveLength(0);
95 f.world.refuseTag = undefined;
96 expect(await tick(f.ports)).toContain("cut 0.7.284");
97 });
98 });
test/release-keeper-guest.test.ts added +107
@@ -0,0 +1,107 @@
1 // The keeper's record is the admins' (#7557): at each door that reads it — /releases, the terminal
2 // palette and screen, MCP `release_keeper` — a guest gets none of it and an admin gets all of it.
3 import { describe, expect, test } from "bun:test";
4 import { createSession } from "../src/core/sessions.ts";
5 import { createToken } from "../src/core/tokens.ts";
6 import { createUser, type User } from "../src/core/users.ts";
7 import { serve } from "../src/http/server.ts";
8 import { tick } from "../src/keeper/keeper.ts";
9 import type { Ctx } from "../src/tui/app/app.ts";
10 import { forgeCommands, Session } from "../src/tui/forge.ts";
11 import { readKeeper } from "../src/tui/screens/keeper.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { keeperForge } from "./helpers/keeper.ts";
14
15 quietLogs();
16
17 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
18
19 /** 0.7.284 failed its verify and was rolled back to 0.7.283; a failed hold stands. A guest account `bob` beside the admin `alice`. */
20 async function seeded() {
21 const f = await keeperForge();
22 await tick(f.ports);
23 f.world.live = "0.7.284";
24 f.world.healthz = (n) => (n >= 5 ? { status: 503, ms: 2 } : { status: 200, ms: 2 });
25 await tick(f.ports);
26 const bob = await createUser(f.app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
27 if (!bob.ok) throw new Error("user");
28 return { ...f, guest: bob.value };
29 }
30
31 describe("a guest sees no keeper data; an admin sees all of it", () => {
32 test("/releases", async () => {
33 const f = await seeded();
34 const servers = await serve(f.app);
35 try {
36 const get = (user?: User) =>
37 fetch(`${servers.url}/releases`, {
38 headers: user === undefined ? {} : { cookie: `forge_session=${createSession(f.app.db, user.id).token}` },
39 redirect: "manual",
40 });
41 const signedOut = await get();
42 expect(signedOut.status).toBe(303);
43 expect(signedOut.headers.get("location")).toContain("/signin");
44 expect(await signedOut.text()).not.toContain("0.7.284");
45 const guest = await get(f.guest);
46 expect([403, 404]).toContain(guest.status);
47 const guestBody = await guest.text();
48 expect(guestBody).not.toContain("Release keeper");
49 expect(guestBody).not.toContain("0.7.284");
50 const admin = await get(f.user);
51 expect(admin.status).toBe(200);
52 const body = await admin.text();
53 expect(body).toContain("Release keeper");
54 expect(body).toContain("0.7.284");
55 expect(body).toContain("0.7.283");
56 expect(body).toContain("rolled back");
57 expect(body).toContain("0.7.284 failed its verify");
58 } finally {
59 servers.stop();
60 }
61 });
62
63 test("the terminal palette and screen", async () => {
64 const f = await seeded();
65 const offered = (user?: User) => forgeCommands(new Session(f.app, identity(user)))({} as Ctx).some((c) => c.id === "keeper");
66 for (const who of [undefined, f.guest]) {
67 expect(offered(who)).toBe(false);
68 expect(readKeeper(new Session(f.app, identity(who)))).toBeUndefined();
69 }
70 expect(offered(f.user)).toBe(true);
71 const read = readKeeper(new Session(f.app, identity(f.user)))!;
72 expect(read.blocked).toContain("0.7.284 failed its verify");
73 expect(read.releases.map((r) => [r.version, r.stage, r.knownGood.version])).toEqual([["0.7.284", "rolled_back", "0.7.283"]]);
74 });
75
76 test("MCP release_keeper", async () => {
77 const f = await seeded();
78 const servers = await serve(f.app);
79 try {
80 const call = async (user: User) => {
81 const res = await fetch(`${servers.url}/mcp`, {
82 method: "POST",
83 headers: {
84 "content-type": "application/json",
85 accept: "application/json, text/event-stream",
86 authorization: `Bearer ${createToken(f.app.db, user.id, "ro", "ro").token}`,
87 },
88 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "release_keeper", arguments: {} } }),
89 });
90 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
91 return { isError: body.result.isError === true, text: body.result.content[0]!.text };
92 };
93 const guest = await call(f.guest);
94 expect(guest.isError).toBe(true);
95 expect(guest.text).not.toContain("0.7.284");
96 const admin = await call(f.user);
97 expect(admin.isError).toBe(false);
98 const value = JSON.parse(admin.text);
99 expect(value.blocked).toContain("0.7.284 failed its verify");
100 expect(value.releases.map((r: { version: string; knownGood: { version: string } }) => [r.version, r.knownGood.version])).toEqual([["0.7.284", "0.7.283"]]);
101 expect(value.holds).toHaveLength(1);
102 expect(value.holds[0].kind).toBe("failed");
103 } finally {
104 servers.stop();
105 }
106 });
107 });
test/release-keeper-hold-doors.test.ts added +115
@@ -0,0 +1,115 @@
1 // An owner's hold from each door — the /releases card, the terminal screen and MCP — runs the one function
2 // in src/keeper/hold.ts; a refusal (an agent, a non-admin, a cross-origin post) writes no hold and no event (#7557).
3 import { describe, expect, test } from "bun:test";
4 import { createSession } from "../src/core/sessions.ts";
5 import { createUser, type User } from "../src/core/users.ts";
6 import { holdAs, liftAs, listHolds, standingHolds } from "../src/keeper/hold.ts";
7 import { serve } from "../src/http/server.ts";
8 import { createForgeApp, Session } from "../src/tui/forge.ts";
9 import { holdLine, keeperScreen } from "../src/tui/screens/keeper.ts";
10 import { keeperPanel } from "../src/web/pages/keeper-panel.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { keeperForge } from "./helpers/keeper.ts";
14 import { settleApp } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
19 const heldEvents = (f: Awaited<ReturnType<typeof keeperForge>>) => f.events("release.held").length + f.events("release.hold_lifted").length;
20
21 describe("the shared hold functions", () => {
22 test("an agent and a non-admin are refused, and a refusal writes no hold and no event", async () => {
23 const f = await keeperForge();
24 const bob = await createUser(f.app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
25 if (!bob.ok) throw new Error("user");
26 const owner = holdAs(f.app, f.user, "freeze");
27 expect(owner.ok).toBe(true);
28 const before = heldEvents(f);
29 for (const who of [{ ...f.user, kind: "agent" as const }, bob.value, undefined]) {
30 expect(holdAs(f.app, who, "stop").ok).toBe(false);
31 expect(liftAs(f.app, who, 1).ok).toBe(false);
32 }
33 expect(listHolds(f.app.db)).toHaveLength(1);
34 expect(standingHolds(f.app.db)).toHaveLength(1);
35 expect(heldEvents(f)).toBe(before);
36 });
37
38 test("control characters are stripped and an empty reason is refused", async () => {
39 const f = await keeperForge();
40 const h = holdAs(f.app, f.user, "a\u0007b\nc");
41 expect(h.ok && h.value.reason).toBe("a b c");
42 expect(holdAs(f.app, f.user, " ").ok).toBe(false);
43 });
44 });
45
46 describe("the /releases card", () => {
47 test("offers the form to whoever may roll back, and posts through the shared function", async () => {
48 const f = await keeperForge();
49 const servers = await serve(f.app);
50 try {
51 expect(String(keeperPanel(f.app.db, false))).not.toContain("/releases/hold");
52 expect(String(keeperPanel(f.app.db, true))).toContain('action="/releases/hold"');
53 const cookie = `forge_session=${createSession(f.app.db, f.user.id).token}`;
54 const post = (path: string, fields: Record<string, string>, origin = servers.url) =>
55 fetch(`${servers.url}${path}`, { method: "POST", headers: { cookie, origin }, body: new URLSearchParams(fields), redirect: "manual" });
56 const cross = await post("/releases/hold", { reason: "x" }, "https://evil.test");
57 expect(cross.status).toBe(403);
58 expect(standingHolds(f.app.db)).toHaveLength(0);
59 const res = await post("/releases/hold", { reason: "freeze for the demo" });
60 expect(res.status).toBe(303);
61 expect(standingHolds(f.app.db).map((h) => [h.kind, h.reason])).toEqual([["owner", "freeze for the demo"]]);
62 expect(String(keeperPanel(f.app.db, true))).toContain("/releases/hold/1/lift");
63 expect((await post("/releases/hold/1/lift", {})).status).toBe(303);
64 expect(standingHolds(f.app.db)).toHaveLength(0);
65 } finally {
66 servers.stop();
67 }
68 });
69
70 test("a guest and a member who may not administer are refused and nothing is written", async () => {
71 const f = await keeperForge();
72 const servers = await serve(f.app);
73 try {
74 const bob = await createUser(f.app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
75 if (!bob.ok) throw new Error("user");
76 const cookie = `forge_session=${createSession(f.app.db, bob.value.id).token}`;
77 for (const headers of [{ origin: servers.url }, { cookie, origin: servers.url }]) {
78 const res = await fetch(`${servers.url}/releases/hold`, { method: "POST", headers, body: new URLSearchParams({ reason: "x" }), redirect: "manual" });
79 expect(res.status).not.toBe(303);
80 }
81 expect(listHolds(f.app.db)).toHaveLength(0);
82 expect(heldEvents(f)).toBe(0);
83 } finally {
84 servers.stop();
85 }
86 });
87 });
88
89 describe("the terminal screen", () => {
90 test("h holds with a reason and `lift N` lifts it", async () => {
91 const f = await keeperForge();
92 const session = new Session(f.app, identity(f.user));
93 expect(holdLine(session, "freeze").ok).toBe(true);
94 expect(standingHolds(f.app.db)).toHaveLength(1);
95 expect(holdLine(session, "lift 1").ok).toBe(true);
96 expect(standingHolds(f.app.db)).toHaveLength(0);
97 expect(holdLine(new Session(f.app, identity(undefined)), "freeze").ok).toBe(false);
98 expect(listHolds(f.app.db)).toHaveLength(1);
99 });
100
101 test("the key is on the screen and opens the prompt", async () => {
102 const f = await keeperForge();
103 const terminal = headlessTerminal(120, 20);
104 const { app: tui, session } = createForgeApp(f.app, identity(f.user), { terminal, exitHooks: false });
105 tui.start();
106 const screen = keeperScreen(session, tui.ctx);
107 tui.ctx.push(screen);
108 await settleApp(tui);
109 screen.key!({ key: "h" } as never);
110 await settleApp(tui);
111 const text = tui.screen.toText();
112 tui.stop?.();
113 expect(text).toContain("Hold releases");
114 });
115 });
test/release-keeper-hold.test.ts added +105
@@ -0,0 +1,105 @@
1 // A failed verify holds further cuts until a pull naming the cause lands and a verified release lifts it;
2 // an owner's hold is set and lifted by an admin, never an agent (#7557).
3 import { describe, expect, test } from "bun:test";
4 import type { Ctx } from "../src/http/context.ts";
5 import { tool as holdTool } from "../src/http/mcp/tools/release_hold.ts";
6 import { tool as keeperTool } from "../src/http/mcp/tools/release_keeper.ts";
7 import { blocking, names, setHold, standingHolds, type Hold } from "../src/keeper/hold.ts";
8 import { tick } from "../src/keeper/keeper.ts";
9 import { listKeeperReleases } from "../src/keeper/record.ts";
10 import { keeperPanel } from "../src/web/pages/keeper-panel.ts";
11 import { keeperForge } from "./helpers/keeper.ts";
12
13 /** A forge whose 0.7.284 failed its verify and was rolled back to 0.7.283. */
14 async function heldForge() {
15 const f = await keeperForge();
16 await tick(f.ports);
17 f.world.live = "0.7.284";
18 f.world.healthz = (n) => (n >= 5 ? { status: 503, ms: 2 } : { status: 200, ms: 2 });
19 await tick(f.ports);
20 f.world.live = "0.7.283";
21 f.world.healthz = () => ({ status: 200, ms: 2 });
22 f.world.newest = { version: "0.7.284", sha: f.world.main };
23 f.world.main = "e".repeat(40);
24 f.world.now += 3_600_000;
25 return f;
26 }
27
28 describe("a failed verify's hold", () => {
29 test("names the version and the probe numbers, and blocks the next cut", async () => {
30 const f = await heldForge();
31 const [h] = standingHolds(f.app.db);
32 expect(h).toMatchObject({ kind: "failed", version: "0.7.284", probes: 8, badInRow: 3, causePull: null });
33 expect(await tick(f.ports)).toBe(`held: 0.7.284 failed its verify (${h!.reason}); waiting for a pull saying "release hold ${h!.id}" to land`);
34 });
35
36 test("a pull that does not name it changes nothing; one that does lets the fix through, and its verify lifts the hold", async () => {
37 const f = await heldForge();
38 const [h] = standingHolds(f.app.db);
39 f.land(10, "Tidy the explore page");
40 expect(await tick(f.ports)).toContain("held:");
41 // A changelog or a revert note mentions the version; that is no cause.
42 f.land(12, "Changelog for 0.7.284", "Reverts the 0.7.284 notes.");
43 expect(await tick(f.ports)).toContain("held:");
44 expect(standingHolds(f.app.db)[0]!.causePull).toBeNull();
45 f.land(11, "Fix the boot stall that sank 0.7.284", `Closes release hold ${h!.id}.`);
46 const line = await tick(f.ports);
47 expect(line).toContain("cut 0.7.285");
48 expect(standingHolds(f.app.db)[0]!.causePull).toBe(11);
49 // Not lifted yet: only a verified release lifts it.
50 expect(standingHolds(f.app.db)).toHaveLength(1);
51 f.world.live = "0.7.285";
52 expect(await tick(f.ports)).toContain("0.7.285 verified live");
53 expect(standingHolds(f.app.db)).toHaveLength(0);
54 expect(listKeeperReleases(f.app.db).map((r) => [r.version, r.stage])).toEqual([["0.7.285", "verified"], ["0.7.284", "rolled_back"]]);
55 expect(f.events("release.verified_live")[0]).toMatchObject({ version: "0.7.285", holdsLifted: 1 });
56 });
57
58 test("only `release hold N` names the cause: not the bare version, not another hold's number", () => {
59 const h = { id: 4, version: "0.7.284" } as Hold;
60 expect(names(h, "Fixes release hold #4")).toBe(true);
61 expect(names(h, "release hold 4")).toBe(true);
62 expect(names(h, "release hold 41")).toBe(false);
63 expect(names(h, "after 0.7.2840")).toBe(false);
64 expect(names(h, "after 10.7.284")).toBe(false);
65 expect(names(h, "0.7.284 hung at boot")).toBe(false);
66 expect(names(h, "prerelease hold 4")).toBe(false);
67 });
68
69 test("the /releases panel says what holds the next cut, with the numbers", async () => {
70 const f = await heldForge();
71 const out = String(keeperPanel(f.app.db));
72 expect(out).toContain("0.7.284 failed its verify");
73 expect(out).toContain("rolled back");
74 expect(out).toContain("0.7.283");
75 });
76 });
77
78 describe("an owner's hold", () => {
79 test("an admin sets it with a reason through MCP; the keeper cuts nothing; only an admin lifts it", async () => {
80 const f = await keeperForge();
81 const ctx = { app: f.app } as unknown as Ctx;
82 const set = await holdTool.run(ctx, f.user, { reason: "freeze for the launch demo" });
83 expect(set.ok).toBe(true);
84 expect(await tick(f.ports)).toBe("held: the owner holds releases: freeze for the launch demo");
85 expect(f.world.tags).toHaveLength(0);
86 const read = await keeperTool.run(ctx, f.user, {});
87 expect(read.ok && (read.value as { blocked: string }).blocked).toBe("the owner holds releases: freeze for the launch demo");
88 const agent = { ...f.user, kind: "agent" as const };
89 expect((await holdTool.run(ctx, agent, { lift: 1 })).ok).toBe(false);
90 expect((await holdTool.run(ctx, f.user, { lift: 1 })).ok).toBe(true);
91 expect(blocking(standingHolds(f.app.db))).toBeUndefined();
92 expect(await tick(f.ports)).toContain("cut 0.7.284");
93 });
94
95 test("an agent cannot set one, and a failed verify's hold is not lifted by hand", async () => {
96 const f = await keeperForge();
97 const ctx = { app: f.app } as unknown as Ctx;
98 expect((await holdTool.run(ctx, { ...f.user, kind: "agent" as const }, { reason: "stop" })).ok).toBe(false);
99 const h = setHold(f.app.db, { kind: "failed", version: "0.7.284", reason: "3 bad", at: f.world.now });
100 const tried = await holdTool.run(ctx, f.user, { lift: h.id });
101 expect(tried.ok).toBe(false);
102 expect(standingHolds(f.app.db)).toHaveLength(1);
103 expect((await holdTool.run(ctx, f.user, { reason: "" })).ok).toBe(false);
104 });
105 });
test/release-keeper-rollback-named.test.ts added +97
@@ -0,0 +1,97 @@
1 // The keeper rolls back by setting the known-good image recorded before the cut, by name; there is no undo path (#7557).
2 import { describe, expect, test } from "bun:test";
3 import { readdirSync, readFileSync } from "node:fs";
4 import { join } from "node:path";
5 import { imageAt, kubeDeployer, tagOf } from "../src/keeper/deploy.ts";
6 import { tick } from "../src/keeper/keeper.ts";
7 import { listKeeperReleases } from "../src/keeper/record.ts";
8 import { rollBackToNamed } from "../src/keeper/rollback.ts";
9 import { standingHolds } from "../src/keeper/hold.ts";
10 import { fakeDeployer, keeperForge } from "./helpers/keeper.ts";
11
12 const creds = { server: "https://kube.test", token: "t", namespace: "forge" };
13
14 describe("the named rollback", () => {
15 test("a seeded failing verify sets the recorded known-good image, holds with the numbers, and cuts nothing further", async () => {
16 const f = await keeperForge();
17 await tick(f.ports);
18 expect(f.state.set).toEqual(["reg.test/o/forge:0.7.284"]);
19 // The new forge answers, then stops answering /healthz.
20 f.world.live = "0.7.284";
21 f.world.healthz = (n) => (n >= 20 ? { status: 503, ms: 4 } : { status: 200, ms: 4 });
22 const line = await tick(f.ports);
23 expect(line).toContain("0.7.284 failed its verify");
24 // The image set is the one recorded at the cut, by name — not the controller's "previous" (the broken 0.7.277 here).
25 expect(f.state.set).toEqual(["reg.test/o/forge:0.7.284", "reg.test/o/forge:0.7.283"]);
26 expect(f.state.image).toBe("reg.test/o/forge:0.7.283");
27 const [r] = listKeeperReleases(f.app.db);
28 expect(r).toMatchObject({ stage: "rolled_back", probes: 23, badInRow: 3, secondsDark: 3 });
29 expect(r!.reason).toContain("rolled back to 0.7.283 (reg.test/o/forge:0.7.283)");
30 const [hold] = standingHolds(f.app.db);
31 expect(hold).toMatchObject({ kind: "failed", version: "0.7.284", probes: 23, badInRow: 3, worstMs: 40 });
32 expect(hold!.reason).toContain("3 bad /healthz probes in a row");
33 expect(f.events("release.rolled_back")).toEqual([{ version: "0.7.284", to: "0.7.283", image: "reg.test/o/forge:0.7.283", ok: true }]);
34 expect(f.events("release.held")).toHaveLength(1);
35 // Back on the known-good version, main moves on, time passes: nothing more is cut.
36 f.world.live = "0.7.283";
37 f.world.newest = { version: "0.7.284", sha: f.world.main };
38 f.world.main = "e".repeat(40);
39 f.world.now += 24 * 3_600_000;
40 expect(await tick(f.ports)).toContain("held: 0.7.284 failed its verify");
41 expect(f.world.tags).toHaveLength(1);
42 expect(f.state.set).toHaveLength(2);
43 });
44
45 test("a release not live three hours after its cut is set back by name, and held", async () => {
46 const f = await keeperForge();
47 await tick(f.ports);
48 f.world.now += 3 * 3_600_000 + 1;
49 expect(await tick(f.ports)).toContain("was not live 3 h after its cut");
50 expect(f.state.image).toBe("reg.test/o/forge:0.7.283");
51 expect(standingHolds(f.app.db)).toHaveLength(1);
52 });
53
54 test("a recorded image that is no image reference is refused, and nothing is set", async () => {
55 const { deployer, state } = fakeDeployer("reg.test/o/forge:0.7.284");
56 const r = await rollBackToNamed(deployer, { version: "0.7.283", image: "--help" });
57 expect(r.ok).toBe(false);
58 expect(state.set).toEqual([]);
59 });
60
61 test("there is no undo path: the deployer reads or sets a named image — and sends one PATCH naming it", async () => {
62 const sent: { url: string; method: string; body: string }[] = [];
63 const deployer = kubeDeployer({
64 credentials: creds,
65 deployment: "forge",
66 container: "forge",
67 fetch: async (url, init) => {
68 sent.push({ url, method: init.method ?? "GET", body: String(init.body ?? "") });
69 return Response.json({ spec: { replicas: 1, template: { spec: { containers: [{ name: "forge", image: "reg.test/o/forge:0.7.284" }] } } } });
70 },
71 });
72 expect(Object.keys(deployer).sort()).toEqual(["image", "setImage"]);
73 const r = await rollBackToNamed(deployer, { version: "0.7.283", image: "reg.test/o/forge:0.7.283" });
74 expect(r.ok).toBe(true);
75 expect(sent).toEqual([
76 {
77 url: "https://kube.test/apis/apps/v1/namespaces/forge/deployments/forge",
78 method: "PATCH",
79 body: JSON.stringify({ spec: { template: { spec: { containers: [{ name: "forge", image: "reg.test/o/forge:0.7.283" }] } } } }),
80 },
81 ]);
82 // No code under src/keeper asks for a previous revision: no rollout undo, no rollback subresource, no revision number.
83 const dir = join(import.meta.dir, "../src/keeper");
84 for (const file of readdirSync(dir)) {
85 const code = readFileSync(join(dir, file), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
86 expect(code.match(/undo|\/rollback(?!\.ts)|revision|previous/i)?.[0], file).toBeUndefined();
87 }
88 });
89
90 test("an image keeps its registry port when its tag changes", () => {
91 expect(imageAt("reg.test:5000/o/forge:0.7.283", "0.7.284")).toBe("reg.test:5000/o/forge:0.7.284");
92 expect(imageAt("reg.test:5000/o/forge", "0.7.284")).toBe("reg.test:5000/o/forge:0.7.284");
93 expect(imageAt(`reg.test/o/forge:0.7.283@sha256:${"f".repeat(64)}`, "0.7.284")).toBe("reg.test/o/forge:0.7.284");
94 expect(tagOf("reg.test:5000/o/forge:0.7.283")).toBe("0.7.283");
95 expect(tagOf("reg.test:5000/o/forge")).toBe("");
96 });
97 });
test/release-keeper-verify.test.ts added +140
@@ -0,0 +1,140 @@
1 // Proving a release live (#7557): ten minutes of /healthz once a second under page load; three bad in a row fail it.
2 import { describe, expect, test } from "bun:test";
3 import { tick, rollProbe } from "../src/keeper/keeper.ts";
4 import { listKeeperReleases } from "../src/keeper/record.ts";
5 import { EMPTY, FAIL_AFTER, SLOW_MS, tally, verifyUnderLoad, type Probe } from "../src/keeper/verify.ts";
6 import { keeperForge } from "./helpers/keeper.ts";
7
8 /** A clock that only the probes and the sleeps move. */
9 function clocked(healthz: (n: number) => Probe, pages: readonly string[] = [], page: Probe = { status: 200, ms: 0 }) {
10 let now = 0;
11 let n = 0;
12 let open = 0;
13 let mostOpen = 0;
14 const run = verifyUnderLoad({
15 get: async (path) => {
16 if (path === "/healthz") {
17 const p = healthz(n++);
18 now += p.ms;
19 return p;
20 }
21 open++;
22 mostOpen = Math.max(mostOpen, open);
23 await Promise.resolve();
24 now += page.ms;
25 open--;
26 return page;
27 },
28 now: () => now,
29 sleep: async (ms) => {
30 now += ms;
31 },
32 pages,
33 });
34 return { run, mostOpen: () => mostOpen };
35 }
36
37 describe("tally", () => {
38 test("a bad probe is a non-200 or one over 10 s; a slow 200 is slow, not dark", () => {
39 let t = tally(EMPTY, { status: 200, ms: SLOW_MS + 1 });
40 expect(t).toMatchObject({ probes: 1, non200: 0, badInRow: 1, worstMs: 10_001, secondsDark: 0 });
41 t = tally(t, { status: 502, ms: 3 });
42 expect(t).toMatchObject({ probes: 2, non200: 1, badInRow: 2, secondsDark: 1 });
43 t = tally(t, { status: 200, ms: 5 });
44 expect(t).toMatchObject({ badInRow: 0, worstRun: 2 });
45 // Exactly 10 s is not over it.
46 expect(tally(EMPTY, { status: 200, ms: SLOW_MS }).badInRow).toBe(0);
47 });
48 });
49
50 describe("under page load", () => {
51 test("a healthy release: 600 probes in 10 minutes, one a second, and it passes", async () => {
52 const v = await clocked(() => ({ status: 200, ms: 0 })).run;
53 expect(v.ok).toBe(true);
54 expect(v.tally.probes).toBe(600);
55 expect(v.tally.secondsDark).toBe(0);
56 });
57
58 test("two bad probes in a row pass; three fail at once, with the numbers", async () => {
59 const two = await clocked((n) => (n === 10 || n === 11 ? { status: 503, ms: 1 } : { status: 200, ms: 1 })).run;
60 expect(two.ok).toBe(true);
61 expect(two.tally).toMatchObject({ non200: 2, worstRun: 2, secondsDark: 2 });
62 const three = await clocked((n) => (n >= 10 && n < 10 + FAIL_AFTER ? { status: 0, ms: 1 } : { status: 200, ms: 1 })).run;
63 expect(three.ok).toBe(false);
64 expect(three.tally.probes).toBe(13);
65 expect(three.reason).toBe("3 bad /healthz probes in a row (non-200 or over 10 s) after 13 probes; 3 non-200, worst 1 ms");
66 });
67
68 test("three probes over 10 s fail it though each was a 200", async () => {
69 const v = await clocked((n) => ({ status: 200, ms: n >= 5 && n < 8 ? 10_500 : 2 })).run;
70 expect(v.ok).toBe(false);
71 expect(v.tally).toMatchObject({ non200: 0, worstMs: 10_500, secondsDark: 0 });
72 });
73
74 test("the pages are fetched one at a time, every one of them", async () => {
75 const pages = ["/boards", "/alice/forge", "/explore", "/"];
76 const c = clocked(() => ({ status: 200, ms: 0 }), pages, { status: 200, ms: 250 });
77 const v = await c.run;
78 expect(c.mostOpen()).toBe(1);
79 expect(v.tally.pages).toBeGreaterThan(pages.length);
80 expect(v.tally.worstMs).toBe(250);
81 });
82 });
83
84 describe("a seeded healthy release", () => {
85 test("ends verified live with its probes and worst latency stored, and an event", async () => {
86 const f = await keeperForge({ healthz: () => ({ status: 200, ms: 30 }) });
87 await tick(f.ports);
88 // The old forge counts the roll: zero non-200 with surge.
89 for (let i = 0; i < 5; i++) await rollProbe(f.ports);
90 f.world.live = "0.7.284";
91 const line = await tick(f.ports);
92 expect(line).toContain("0.7.284 verified live");
93 const [r] = listKeeperReleases(f.app.db);
94 expect(r!.stage).toBe("verified");
95 expect(r!.rollProbes).toBe(5);
96 expect(r!.rollNon200).toBe(0);
97 expect(r!.probes).toBeGreaterThan(500);
98 expect(r!.pages).toBeGreaterThan(0);
99 expect(r!.worstMs).toBe(40);
100 expect(r!.secondsDark).toBe(0);
101 expect(r!.liveAt).not.toBeNull();
102 const [ev] = f.events("release.verified_live");
103 expect(ev).toMatchObject({ version: "0.7.284", probes: r!.probes, worstMs: 40, secondsDark: 0 });
104 expect(f.world.pages.slice(0, 4)).toEqual(["/boards", "/alice/forge", "/explore", "/"]);
105 // Nothing went dark, so nothing is filed.
106 expect(f.app.db.query("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Release % dark%'").get()).toEqual({ n: 0 });
107 });
108
109 test("a roll that went dark files one issue, and only one", async () => {
110 const f = await keeperForge({ healthz: () => ({ status: 200, ms: 5 }) });
111 await tick(f.ports);
112 const healthy = f.world.healthz;
113 f.world.healthz = () => ({ status: 0, ms: 1 });
114 for (let i = 0; i < 4; i++) await rollProbe(f.ports);
115 f.world.healthz = healthy;
116 f.world.live = "0.7.284";
117 await tick(f.ports);
118 const [r] = listKeeperReleases(f.app.db);
119 expect(r).toMatchObject({ stage: "verified", rollNon200: 4, secondsDark: 4 });
120 const filed = f.app.db.query<{ title: string; body: string }, []>("SELECT title, body FROM issue WHERE title LIKE 'Release %'").all();
121 expect(filed.map((i) => i.title)).toEqual(["Release 0.7.284 was dark for 4 s"]);
122 expect(filed[0]!.body).toContain("4 of 4 during the roll");
123 });
124
125 test("a closed issue about the same dark time does not stop the next report", async () => {
126 const f = await keeperForge({ healthz: () => ({ status: 200, ms: 5 }) });
127 f.app.db
128 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('old', ?1, 900, 'issue', 'Release 0.7.284 was dark for 4 s', '', ?2, 'closed', ?3)`)
129 .run(f.repo.id, f.user.id, new Date(f.world.now).toISOString());
130 await tick(f.ports);
131 const healthy = f.world.healthz;
132 f.world.healthz = () => ({ status: 0, ms: 1 });
133 for (let i = 0; i < 4; i++) await rollProbe(f.ports);
134 f.world.healthz = healthy;
135 f.world.live = "0.7.284";
136 await tick(f.ports);
137 const open = f.app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue WHERE title = 'Release 0.7.284 was dark for 4 s' AND state = 'open'").get();
138 expect(open).toEqual({ n: 1 });
139 });
140 });
test/tui-people-verify.test.ts +4 −1
@@ -66,7 +66,10 @@ describe("verify an email from the People screen", () => {
6666 try {
6767 expect(h.text()).toMatch(/v +verify/);
6868 expect(h.text()).toMatch(/bobby.*unverified/);
69 // newest first: bobby is the first row
69 // Newest first, but alice and bobby can share a created_at millisecond and then
70 // sort by name: put the cursor on bobby rather than assume his row.
71 if (!/▸ bobby/.test(h.text())) await h.press("j");
72 expect(h.text()).toMatch(/▸ bobby/);
7073 await h.press("v");
7174 expect(h.text()).toContain("verify bobby's email? y/n");
7275 await h.press("n");
test/tui-release-keeper.test.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * The release keeper in the terminal (#7557): the palette offers it to whoever
3 * may read /releases, and the screen shows the card's read — what holds the
4 * next cut, then each release with its stage, known-good version and numbers.
5 */
6 import { describe, expect, test } from "bun:test";
7 import { tick } from "../src/keeper/keeper.ts";
8 import type { User } from "../src/core/users.ts";
9 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
10 import type { Ctx } from "../src/tui/app/app.ts";
11 import { keeperScreen, readKeeper } from "../src/tui/screens/keeper.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { keeperForge } from "./helpers/keeper.ts";
15 import { settleApp } from "./helpers/tui.ts";
16
17 quietLogs();
18
19 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
20
21 /** A forge whose 0.7.284 failed its verify and was rolled back to 0.7.283. */
22 async function heldForge() {
23 const f = await keeperForge();
24 await tick(f.ports);
25 f.world.live = "0.7.284";
26 f.world.healthz = (n) => (n >= 5 ? { status: 503, ms: 2 } : { status: 200, ms: 2 });
27 await tick(f.ports);
28 return f;
29 }
30
31 describe("the release keeper screen", () => {
32 test("the palette offers it to an admin, not to a guest", async () => {
33 const f = await keeperForge();
34 const offered = (user?: User) => forgeCommands(new Session(f.app, identity(user)))({} as Ctx).some((c) => c.id === "keeper");
35 expect(offered(f.user)).toBe(true);
36 expect(offered(undefined)).toBe(false);
37 expect(readKeeper(new Session(f.app, identity(undefined)))).toBeUndefined();
38 });
39
40 test("it shows what holds the next cut and the rolled-back release with its numbers", async () => {
41 const f = await heldForge();
42 const terminal = headlessTerminal(160, 20);
43 const { app: tui, session } = createForgeApp(f.app, identity(f.user), { terminal, exitHooks: false });
44 tui.start();
45 tui.ctx.push(keeperScreen(session, tui.ctx));
46 await settleApp(tui);
47 const text = tui.screen.toText();
48 tui.stop?.();
49 expect(text).toContain("0.7.284 failed its verify");
50 expect(text.replace(/\s+/g, " ")).toContain(`a pull saying "release hold 1" to land`);
51 expect(text).toContain("rolled back");
52 expect(text).toContain("known good 0.7.283");
53 expect(text).toMatch(/8 probes/);
54 });
55
56 test("with nothing cut, it says nothing holds the next cut and that no releases are kept yet", async () => {
57 const f = await keeperForge();
58 const read = readKeeper(new Session(f.app, identity(f.user)));
59 expect(read).toEqual({ blocked: "Nothing holds the next cut.", releases: [] });
60 });
61 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs