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

After a new version boots, the forge runs its smoke check against its own public URL and says on the board, and on the phone once, if the release is unproven #2575

Merged opened this

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

Merged

scout/forge:scout/2574-after-a-new-version-boots-the-forge-runs → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/2574-after-a-new-version-boots-the-forge-runs was merged into main as 3f878ef.

Review

Sign in to review this change.
10 files changed +297 −6
README.md +7
@@ -231,6 +231,13 @@ sees `Reduced protection: …` in one line, under the filter on the terminal's
231231 first screen and in the web footer; nobody else does, and a fully protected
232232 forge shows nothing.
233233
234 A new version proves itself once per version: after a boot that changes
235 `FORGE_VERSION`, the forge waits (up to 10 minutes) for its public URL to
236 answer `/healthz` as that version, then runs `scripts/smoke.ts` against it and
237 keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a
238 `Release X is unproven: …` card on an admin's board and on the terminal's first
239 screen (the same cards), and is paged once; a later passing version clears it.
240
234241 What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your
235242 thoughts, then the issues you can write to that are not shipped, oldest first
236243 (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read
scripts/smoke.ts +9 −3
@@ -53,10 +53,14 @@ export interface Options {
5353 waitMs?: number;
5454 /** Pause between polls, in milliseconds. */
5555 pollMs?: number;
56 /** The fetch to use; the global one unless a caller (the forge proving itself, a test) gives its own. */
57 fetch?: typeof fetch;
58 /** Accept a redirect from `/board` as the sign-in it is, for a caller holding no token. */
59 anonymousBoard?: boolean;
5660 }
5761
5862 /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */
59 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number): Promise<Failure | null> {
63 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> {
6064 const url = new URL("/healthz", root).href;
6165 const deadline = Date.now() + waitMs;
6266 let seen = "none";
@@ -83,8 +87,9 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr
8387 const root = new URL(base);
8488 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
8589 const failures: Failure[] = [];
90 const fetch = options.fetch ?? globalThis.fetch;
8691 if (options.version) {
87 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000);
92 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch);
8893 if (late) return [late];
8994 }

Check that the get helper's fetch is the injected one. Line 66 and this line sit in nested functions; the shadowing const is declared at line 90 so it should resolve, but the redundant options.fetch ?? fetch at the waitForVersion call hides this. Pass fetch alone.

9095
@@ -125,7 +130,8 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr
125130 }
126131 }
127132 const board = await get("/board", agent, auth);
128 if (board && board.res.status !== 200) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)");
133 const signIn = options.anonymousBoard && !token && board?.res.status === 302;
134 if (board && board.res.status !== 200 && !signIn) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)");
129135 const health = await get("/healthz", agent);
130136 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
131137 }
src/crew/incidents.ts +1 −1
@@ -48,7 +48,7 @@ export const HOLD_MS = 5 * 60_000;
4848
4949 /** The vitals causes this pager owns; the rest are paged elsewhere or are settings. */
5050 export function owned(cause: string): boolean {
51 return cause === "errors" || cause === "internal" || cause === "disk" || cause.startsWith("queue:");
51 return cause === "errors" || cause === "internal" || cause === "disk" || cause === "release" || cause.startsWith("queue:");
5252 }
5353
5454 /** One owned cause being watched. */
src/kernel/vitals.ts +6 −1
@@ -25,6 +25,7 @@ import { BUDGETS } from "./budgets.ts";
2525 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
2626 import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts";
2727 import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts";
28 import { proofNeed } from "../release/prove.ts";
2829 import { newestDrills } from "../core/domain_drills.ts";
2930 import { DOMAINS } from "./domains.ts";
3031 import { domainDrillNeeds } from "./domain_drill.ts";
@@ -51,7 +52,7 @@ export interface EnvCount {
5152 /**
5253 * One cause of the forge being off: the `needs` sentence, a `cause` key that
5354 * holds for as long as its condition does (`disk`, `errors`, `internal`,
54 * `queue:<kind>`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence
55 * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence
5556 * (the oldest evidence the forge holds, else the first time this process saw
5657 * it, held until the cause clears; `at` is never the answer to a repeat
5758 * call), and where to look.
@@ -393,6 +394,10 @@ export function vitals(input: VitalsInput): Vitals {
393394 const behind = mirrorNeed(lastMirror(input.dataDir), now);
394395 if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups");
395396
397 // A release the forge could not prove against its own public URL is a card until a release does.
398 const unproven = proofNeed(input.dataDir);
399 if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board");
outdated

Link /settings/capacity is unrelated to a release failure. Point it at where the owner can see the cause (the board or health page), and test it.

400
396401 const apps = { serving: 0, asleep: 0, previews: 0 };
397402 for (const e of input.envs ?? []) {
398403 if (e.env !== "production") apps.previews++;
src/main.ts +8
@@ -54,6 +54,7 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts";
5454 import { running } from "./kernel/version.ts";
5555 import { record as recordDegraded } from "./kernel/degraded_history.ts";
5656 import { vitals } from "./kernel/vitals.ts";
57 import { proveRelease } from "./release/prove.ts";
5758 import { snapshotPlace } from "./actions/health.ts";
5859 import { capacityDefault } from "./core/capacity.ts";
5960
@@ -480,6 +481,13 @@ const staleTimer = setInterval(() => void tickAsks(), 120_000);
480481 void tickAsks();
481482
482483 app.log.info("forge is up", { url: servers.url, data: app.config.dataDir });
484 // A new version proves itself against the public URL once (release/prove.ts).
485 void proveRelease({
486 dataDir: app.config.dataDir,
487 version: running().version,
488 url: forgeUrl,
489 token: process.env["FORGE_TOKEN"],
490 }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) }));
483491 // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person
484492 // put on one before that is reported here for the owner to take off — the
485493 // forge never removes it on its own.
src/release/prove.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * Is this release so: once per version, after a boot that changed it, the
3 * forge waits for its own public URL to answer `/healthz` as that version,
4 * then runs the smoke checks (`scripts/smoke.ts`) against it. The verdict is
5 * one small file under the data directory (`release/proof.json`), so a
6 * restart of the same version never runs it again and vitals reads it
7 * without the network. A failure is a `release` card, paged once by the
8 * cause pager, until a later version passes. It gives up after ten minutes.
9 */
10
11 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12 import { join } from "node:path";
13 import { smoke, type Failure } from "../../scripts/smoke.ts";
14 import { UNSTAMPED } from "../kernel/version.ts";
15
16 /** How long the public URL has to answer as the new version. */
17 export const GIVE_UP_MS = 10 * 60_000;
18 const POLL_MS = 2_000;
19
20 export interface Proof {
21 readonly version: string;
22 readonly at: string;
23 readonly ok: boolean;
24 /** The first failing check, or the give-up, when `ok` is false. */
25 readonly failure?: string;
26 }
27
28 const proofFile = (dataDir: string): string => join(dataDir, "release", "proof.json");
29
30 /** The last verdict kept, or undefined when none is or the file is not one. */
31 export function lastProof(dataDir: string): Proof | undefined {
32 try {
33 const p = JSON.parse(readFileSync(proofFile(dataDir), "utf8")) as Proof;
34 if (typeof p.version !== "string" || typeof p.at !== "string" || typeof p.ok !== "boolean") return undefined;
35 return p;
36 } catch {
37 return undefined;
38 }
39 }
40
41 function keep(dataDir: string, proof: Proof): Proof {
42 mkdirSync(join(dataDir, "release"), { recursive: true });
43 const tmp = `${proofFile(dataDir)}.tmp`;
44 writeFileSync(tmp, JSON.stringify(proof));
45 renameSync(tmp, proofFile(dataDir));
46 return proof;
47 }
48
49 /** The card an unproven release is, or undefined while the last verdict is a pass. */
50 export function proofNeed(dataDir: string): { sentence: string; since: string } | undefined {
51 const p = lastProof(dataDir);
52 if (p === undefined || p.ok) return undefined;
53 return { sentence: `Release ${p.version} is unproven: ${p.failure ?? "a smoke check failed"}.`, since: p.at };
54 }
55
56 /** A failing check as one line: where, as whom, and why. */
57 export function checkLine(f: Failure): string {
58 return `${f.url}${f.agent === "-" ? "" : ` as ${f.agent}`} ${f.why} (status ${f.status})`;
59 }
60
61 export interface ProveOptions {
62 readonly dataDir: string;
63 readonly version: string;
64 /** The forge's public URL. */
65 readonly url: string;
66 /** Signs `/board` in; without it a redirect to sign-in passes. */
67 readonly token?: string | undefined;
68 readonly fetch?: typeof fetch;
69 readonly now?: () => number;
70 readonly sleep?: (ms: number) => Promise<void>;
71 readonly giveUpMs?: number;
72 }
73
74 /**
75 * Proves `version` unless it was proven (or failed) before. Returns what was
76 * kept, or undefined when nothing ran. Never throws.
77 */
78 export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> {
79 if (o.version === UNSTAMPED || lastProof(o.dataDir)?.version === o.version) return undefined;
80 const now = o.now ?? Date.now;
81 const sleep = o.sleep ?? Bun.sleep;
82 const doFetch = o.fetch ?? fetch;
83 const finish = (failure?: string): Proof =>
84 keep(o.dataDir, {
85 version: o.version,
86 at: new Date(now()).toISOString(),
87 ok: failure === undefined,
88 ...(failure === undefined ? {} : { failure }),
89 });
90 try {
91 const health = new URL("/healthz", o.url).href;
92 const deadline = now() + (o.giveUpMs ?? GIVE_UP_MS);
93 for (;;) {
94 try {
95 const res = await doFetch(health, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
96 await res.body?.cancel();
97 if (res.headers.get("x-forge-version") === o.version) break;
98 } catch {
99 // not up yet at its public address
100 }
101 if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`);
102 await sleep(POLL_MS);
103 }
104 const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true });

The error string is stored verbatim in the card; keep it to one short line (e.g. error.message) so the board stays readable on a 360px phone.

105 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]));
106 } catch (error) {

${checkLine(failures[0])} is a needless template wrapper; use checkLine(failures[0]).

107 return finish(`the smoke checks could not run: ${String(error)}`);
108 }
109 }
test/notify-causes.test.ts +1 −1
@@ -16,7 +16,7 @@ const MIN = 60_000;
1616
1717 describe("owned causes", () => {
1818 test("an explicit list", () => {
19 for (const c of ["errors", "internal", "disk", "queue:check", "queue:run"])
19 for (const c of ["errors", "internal", "disk", "release", "queue:check", "queue:run"])
2020 expect(owned(c)).toBe(true);
2121 for (const c of ["drill", "mirror", "stuck:r1", "red:r1", "snapshots", "budget:r1", "shared:r:t"])
2222 expect(owned(c)).toBe(false);
test/release-prove.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * A new version proves itself once against the public URL: a pass is kept,
3 * a failure is a `release` card naming the first failing check, the same
4 * version on restart does not run again, and ten minutes of silence is
5 * "unproven" (#2574). A fake fetch plays the public URL.
6 */
7
8 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9 import { mkdtempSync, rmSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { lastProof, proofNeed, proveRelease } from "../src/release/prove.ts";
13 import { owned } from "../src/crew/incidents.ts";
14
15 const CSS = '<html><link rel="stylesheet" href="/a.css"></html>';
16
17 function site(version: string, over: Record<string, () => Response> = {}) {
18 const hits: string[] = [];
19 const fake = (async (input: string | URL) => {
20 const path = new URL(String(input)).pathname;
21 hits.push(path);
22 const made = over[path]?.();
23 if (made) return made;
24 if (path === "/healthz") return new Response("ok", { headers: { "x-forge-version": version } });
25 if (path === "/") return new Response(CSS, { headers: { "content-type": "text/html" } });
26 if (path === "/a.css") return new Response("a{}", { headers: { "content-type": "text/css" } });
27 if (path === "/board") return new Response(null, { status: 302, headers: { location: "/signin" } });
28 if (path === "/sw.js")
29 return new Response('if (r.mode === "navigate") { e.respondWith(fetch(r)); return; }', {
30 headers: { "content-type": "text/javascript" },
31 });
32 return new Response("no", { status: 404 });
33 }) as unknown as typeof fetch;
34 return { fake, hits };
35 }
36
37 describe("proving a release", () => {
38 let dir: string;
39 beforeEach(() => {
40 dir = mkdtempSync(join(tmpdir(), "forge-prove-"));
41 });
42 afterEach(() => rmSync(dir, { recursive: true, force: true }));
43 const run = (version: string, fake: typeof fetch, extra = {}) =>
44 proveRelease({ dataDir: dir, version, url: "https://forge.test", fetch: fake, sleep: async () => {}, ...extra });
45
46 test("a pass is recorded with the version and raises nothing", async () => {
47 const { fake } = site("1.2.3");
48 const p = await run("1.2.3", fake);
49 expect(p?.ok).toBe(true);
50 expect(lastProof(dir)?.version).toBe("1.2.3");
51 expect(proofNeed(dir)).toBeUndefined();
52 });
53
54 test("a failure raises the cause once, naming the first failing check", async () => {
55 const { fake } = site("1.2.4", { "/a.css": () => new Response("x", { status: 500 }) });
56 await run("1.2.4", fake);
57 const need = proofNeed(dir)!;
58 expect(need.sentence).toContain("Release 1.2.4 is unproven");
59 expect(need.sentence).toContain("/a.css");
60 expect(owned("release")).toBe(true);
61 // the same version again does not run, so the card is not re-raised
62 const again = site("1.2.4");
63 expect(await run("1.2.4", again.fake)).toBeUndefined();
64 expect(again.hits).toEqual([]);
65 expect(proofNeed(dir)?.since).toBe(need.since);
66 });
67
68 test("the same version on restart does not run again", async () => {
69 const first = site("2.0.0");
70 await run("2.0.0", first.fake);
71 const second = site("2.0.0");
72 expect(await run("2.0.0", second.fake)).toBeUndefined();
73 expect(second.hits).toEqual([]);
74 });
75
76 test("a later version that passes clears the card", async () => {
77 await run("3.0.0", site("3.0.0", { "/": () => new Response("x", { status: 500 }) }).fake);
78 expect(proofNeed(dir)).toBeDefined();
79 await run("3.0.1", site("3.0.1").fake);
80 expect(proofNeed(dir)).toBeUndefined();
81 });
82
83 test("it gives up after 10 minutes as unproven", async () => {
84 let t = 0;
85 const old = site("0.9.0"); // the old version keeps answering
86 const p = await run("1.0.0", old.fake, { now: () => t, sleep: async (ms: number) => void (t += ms) });
87 expect(p?.ok).toBe(false);
88 expect(t).toBeLessThanOrEqual(10 * 60_000);
89 expect(t).toBeGreaterThan(9 * 60_000);
90 expect(proofNeed(dir)?.sentence).toBe("Release 1.0.0 is unproven: the public URL never answered as 1.0.0.");
91 expect(old.hits.every((h) => h === "/healthz")).toBe(true);
92 });
93
94 test("an unstamped forge proves nothing", async () => {
95 const s = site("dev");
96 expect(await run("dev", s.fake)).toBeUndefined();
97 expect(s.hits).toEqual([]);
98 });
99 });
test/smoke.test.ts +7
@@ -105,3 +105,10 @@ test("the CLI takes --version and --wait", async () => {
105105 expect(await run("--version", "v1")).toBe(0);
106106 expect(await run("--version", "zz", "--wait", "0")).toBe(1);
107107 });
108
109 test("anonymousBoard: a 302 from /board passes with no token, and only then", async () => {
110 const base = stub("text/css");
111 expect(await smoke(base, "", { anonymousBoard: true })).toEqual([]);
112 expect((await smoke(base, "", {})).map((f) => f.status)).toEqual([302, 302]);
113 expect((await smoke(base, "wrong", { anonymousBoard: true })).map((f) => f.status)).toEqual([302, 302]);
114 });
test/vitals-release.test.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * An unproven release is a `release` card pointing at the board, and a
3 * later pass clears it (#2574). The terminal's first screen prints the same
4 * cards (`forgeNeeds`), so it shows this one too.
5 */
6
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdirSync, writeFileSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { vitals } from "../src/kernel/vitals.ts";
14
15 let dataDir: string;
16 let app: App;
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-vitals-release-"));
20 app = createApp({ dataDir, port: 0 });
21 });
22 afterAll(async () => {
23 app.db.close();
24 await rm(dataDir, { recursive: true, force: true });
25 });
26
27 const card = () =>
28 vitals({ db: app.db, signals: app.signals, dataDir }).degraded.find((d) => d.cause === "release");
29 const keep = (proof: object) => {
30 mkdirSync(join(dataDir, "release"), { recursive: true });
31 writeFileSync(join(dataDir, "release", "proof.json"), JSON.stringify(proof));
32 };
33
34 test("no proof, no card", () => {
35 expect(card()).toBeUndefined();
36 });
37
38 test("a failing proof is a release card naming the check, linking the board", () => {
39 keep({ version: "9.9.9", at: "2026-10-18T10:00:00.000Z", ok: false, failure: "https://f/a.css expected 200 (status 500)" });
40 expect(card()).toMatchObject({
41 sentence: "Release 9.9.9 is unproven: https://f/a.css expected 200 (status 500).",
42 href: "/board",
43 since: "2026-10-18T10:00:00.000Z",
44 });
45 });
46
47 test("a passing proof clears it", () => {
48 keep({ version: "9.9.10", at: "2026-10-18T11:00:00.000Z", ok: true });
49 expect(card()).toBeUndefined();
50 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs