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

A release counts as proven only when a stranger's journey passes on production, and the release card gives the timings #11872

opened this

Check · Review · Land · Live — Its check is queued. Next: reviewer, no review yet.

Ready to land

torque/forge:torque/11787-a-release-counts-as-proven-only-when-a-s → main 2 commits fork

Review

Sign in to review this change.
7 files changed +98 −6
src/main.ts +2
@@ -94,6 +94,7 @@ import { running } from "./kernel/version.ts";
9494 import { record as recordDegraded } from "./kernel/degraded_history.ts";
9595 import { vitals } from "./kernel/vitals.ts";
9696 import { proveRunning } from "./release/prove.ts";
97 import { walkJourney } from "./release/journey.ts";
9798 import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts";
9899 import { readLandings } from "./release/landings.ts";
99100 import { ownTagsReader } from "./release/own_tags.ts";
@@ -785,6 +786,7 @@ proveRunning({
785786 version: running().version,
786787 url: forgeUrl,
787788 token: process.env["FORGE_TOKEN"],
789 journey: walkJourney,
788790 // The first app's production host: it must never answer as the forge (#6726).
789791 appHost: (() => {
790792 const first = listApps(app.db)[0];
src/release/prove.ts +12 −2
@@ -22,6 +22,7 @@
2222
2323 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2424 import { join } from "node:path";
25 import { walkJourney, type JourneyResult } from "./journey.ts";
2526 import { smoke, type Failure, type Heavy } from "./smoke.ts";
2627 import { UNSTAMPED } from "../kernel/version.ts";
2728 import { ownProbeHeaders } from "../kernel/probe.ts";
@@ -58,6 +59,8 @@ export interface Proof {
5859 readonly slow?: boolean;
5960 /** The failure was the give-up: ten minutes already spent polling; only its first retry skips the backoff (#10168). */
6061 readonly gaveUp?: boolean;
62 /** A stranger's journey walked on the public URL after the smoke checks (#11158); its timings, in ms, for the release card. */
63 readonly journey?: { readonly readyMs: number | null; readonly firstActivityMs: number | null; readonly screens: readonly string[] };
6164 }
6265
6366 /**
@@ -151,6 +154,8 @@ export interface ProveOptions {
151154 readonly heavy?: Heavy;
152155 /** Aborted when the proof is hung: its asks stop and it keeps no verdict (#10313). */
153156 readonly signal?: AbortSignal;
157 /** Walks the stranger's journey after the smoke checks; the forge passes `walkJourney`, and a test its own. Without it no walk runs. */
158 readonly journey?: typeof walkJourney;
154159 }
155160
156161 /** Is a proof of `version` due: never tried, or failed `retryAfter` its failures or more ago? */
@@ -174,13 +179,14 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
174179 const baseFetch = o.fetch ?? fetch;
175180 const doFetch = ((url, init) => baseFetch(url, o.signal ? { ...init, signal: init?.signal ? AbortSignal.any([init.signal, o.signal]) : o.signal } : init)) as typeof fetch;
176181 const failedBefore = last?.version === o.version && !last.ok ? last : undefined;
177 const finish = (failure?: string, slow = false, gaveUp = false): Proof => {
182 const finish = (failure?: string, slow = false, gaveUp = false, walked?: JourneyResult): Proof => {
178183 if (o.signal?.aborted) throw new Error("the proof was aborted as hung");
179184 const at = new Date(now()).toISOString();
180185 return keep(o.dataDir, {
181186 version: o.version,
182187 at,
183188 ok: failure === undefined,
189 ...(walked === undefined ? {} : { journey: { readyMs: walked.readyMs, firstActivityMs: walked.firstActivityMs, screens: walked.screens } }),
184190 ...(failure === undefined
185191 ? {}
186192 : { failure, since: failedBefore ? (failedBefore.since ?? failedBefore.at) : at, failures: (failedBefore?.failures ?? (failedBefore ? 1 : 0)) + 1, ...(slow ? { slow } : {}), ...(gaveUp ? { gaveUp } : {}) }),
@@ -216,7 +222,11 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
216222 anonymousBoard: true,
217223 heavy: { version: o.version, probe: ownProbeHeaders(o.dataDir), ...o.heavy }, ...(o.appHost ? { appHost: o.appHost } : {}),
218224 });
219 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]), failures[0]?.slow === true || failures[0]?.empty === true || failures[0]?.hung === true);
225 if (failures[0] !== undefined || o.journey === undefined)
226 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]), failures[0]?.slow === true || failures[0]?.empty === true || failures[0]?.hung === true);
227 // The stranger's journey, the last check: a failed walk is a failed proof, retried on `retryAfter` like any other (one card, paged once).
228 const walked = await o.journey({ base: o.url, fetch: doFetch, ...(o.token ? { adminToken: o.token } : {}) });
229 return finish(walked.ok ? undefined : `a stranger's journey failed: ${walked.failure ?? "it did not finish"}`, false, false, walked);
220230 } catch (error) {
221231 return finish(`the smoke checks could not run: ${String(error)}`);
222232 }
src/release/words.ts +15
@@ -26,3 +26,18 @@ export const ALREADY_LIVE = "This release is the one serving; there is nothing t
2626
2727 /** Why a kept release offers no rollback: its preflight failed or its schema is behind the live database's. */
2828 export const NOT_ROLLABLE = "This release cannot be rolled back to: its preflight failed or its schema is behind the live database's.";
29
30 /** Whole seconds, rounded up from ms, for a sentence. */
31 const secs = (ms: number): string => `${Math.max(1, Math.round(ms / 1000))} s`;
32
33 /**
34 * The release card's one line on a proven stranger's journey (#11158):
35 * "a stranger's forge was ready in 12 s and the crew working in 41 s". Said
36 * in the surface's own tag, so the page escapes the numbers; undefined when
37 * the proof kept no timings (an older proof, or a walk that never got there).
38 */
39 export function journeyLine<T, V>(tag: Tag<T, V>, journey: { readyMs?: number | null; firstActivityMs?: number | null } | undefined, num: (s: string) => V): T | undefined {
40 const ready = journey?.readyMs, work = journey?.firstActivityMs;
41 if (typeof ready !== "number" || typeof work !== "number" || !Number.isFinite(ready) || !Number.isFinite(work)) return undefined;
42 return tag`a stranger's forge was ready in ${num(secs(ready))} and the crew working in ${num(secs(work))}`;
43 }
src/tui/screens/releases.ts +10 −2
@@ -10,7 +10,8 @@ import { getRepoById } from "../../core/repos.ts";
1010 import { running } from "../../kernel/version.ts";
1111 import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts";
1212 import { listReleases, type Release } from "../../release/record.ts";
13 import { liveSince, NO_RELEASES_YET, plain } from "../../release/words.ts";
13 import { lastProof } from "../../release/prove.ts";
14 import { journeyLine, liveSince, NO_RELEASES_YET, plain } from "../../release/words.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
1617 import type { Session } from "../session.ts";
@@ -33,6 +34,13 @@ export function liveText(f: Session): string | undefined {
3334 return live === null || !mayRead(f.app, f.user) ? undefined : liveSince(plain, live.slice(0, 7), ago(BOOTED));
3435 }
3536
37 /** The proven stranger's journey line for the serving version (`journeyLine`), or undefined without timings. */
38 export function journeyText(f: Session): string | undefined {
39 const p = lastProof(f.app.config.dataDir);
40 if (p === undefined || !p.ok || p.version !== running().version || !mayRead(f.app, f.user)) return undefined;
41 return journeyLine(plain, p.journey, (n) => n);
42 }
43
3644 /** The kept releases this person may see; none at all for anyone the page would 404. */
3745 export async function readReleases(f: Session): Promise<ReleaseLine[]> {
3846 if (!mayRead(f.app, f.user)) return [];
@@ -84,7 +92,7 @@ export function releasesScreen(f: Session, ctx: Ctx): Screen {
8492 render(area, s) {
8593 const c = ctx.theme.c;
8694 const [head, body] = split(area, "v", [len(1), fill(1)]);
87 s.text(head!.x, head!.y, truncate(liveText(f) ?? "", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
95 s.text(head!.x, head!.y, truncate([liveText(f), journeyText(f)].filter((x) => x !== undefined).join(" · "), head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
8896 if (lines.length === 0) {
8997 emptyState(s, body!, c, {
9098 title: loaded ? "No releases yet" : "Reading…",
src/web/pages/releases.ts +11 −2
@@ -11,9 +11,10 @@ import { getReposByIds } from "../../core/repos.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1212 import { running } from "../../kernel/version.ts";
1313 import type { Ctx } from "../../http/context.ts";
14 import { lastProof } from "../../release/prove.ts";
1415 import { listReleases } from "../../release/record.ts";
1516 import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts";
16 import { liveSince, NO_RELEASES_YET } from "../../release/words.ts";
17 import { journeyLine, liveSince, NO_RELEASES_YET } from "../../release/words.ts";
1718 import { notFoundPage, signInFirst, NOT_ADMIN_HERE, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
1819 import { readForm } from "../form.ts";
1920 import { html, when, type Html } from "../html.ts";
@@ -31,6 +32,14 @@ export function liveLine(ctx: Ctx): Html | undefined {
3132 </p>`;
3233 }
3334
35 /** The proven stranger's journey line for the version serving, or nothing when the last proof kept no timings. */
36 export function journeyCard(ctx: Ctx): Html | undefined {
37 const p = lastProof(ctx.app.config.dataDir);
38 if (p === undefined || !p.ok || p.version !== running().version) return undefined;
39 const line = journeyLine(html, p.journey, (n) => n);
40 return line === undefined ? undefined : html`<p class="small muted" id="journey-proof">${line}</p>`;
41 }
42
3443 /**
3544 * An owner's hold reason and a keeper's reason are free text: inside
3645 * `[data-releases]` they break anywhere, so the page never scrolls sideways at 390 px. No new class.
@@ -81,7 +90,7 @@ export async function releasesPage(ctx: Ctx): Promise<Response> {
8190 const response = page({
8291 ctx,
8392 title: "Releases",
84 body: html`<div data-releases><div class="page-head"><h1>Releases</h1></div>${keeperPanel(ctx.app.db, writer)}${body}</div>`,
93 body: html`<div data-releases><div class="page-head"><h1>Releases</h1></div>${keeperPanel(ctx.app.db, writer)}${journeyCard(ctx)}${body}</div>`,
8594 styleCss: RELEASES_CSS,
8695 });
8796 notePhase(ctx.request, "render", performance.now() - rendering);
test/release-prove.test.ts +30
@@ -10,6 +10,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { GIVE_UP_MS, isNewer, lastProof, proofDue, proofNeed, proveRelease, proveRunning, QUICK_RETRIES, retryAfter, RETRY_MS, startProving, type Proof, type ProveOptions } from "../src/release/prove.ts";
13 import { journeyLine, plain } from "../src/release/words.ts";
14 import type { JourneyResult } from "../src/release/journey.ts";
1315 import { owned } from "../src/crew/incidents.ts";
1416
1517 const CSS = '<html><link rel="stylesheet" href="/a.css"></html>';
@@ -88,6 +90,34 @@ describe("proving a release", () => {
8890 expect(proofNeed(dir)?.since).toBe(need.since);
8991 });
9092
93 const walk = (over: Partial<JourneyResult>) => async (): Promise<JourneyResult> => ({ ok: true, screens: ["home", "claim", "forge"], readyMs: 12_000, firstActivityMs: 41_000, forge: "probe-1", removed: true, ...over });
94
95 test("a failed stranger's journey leaves the release unproven, retried on the schedule", async () => {
96 const p = await run("4.0.0", site("4.0.0").fake, { journey: walk({ ok: false, failure: "the forge never answered", readyMs: null, firstActivityMs: null }) });
97 expect(p?.ok).toBe(false);
98 expect(p?.failure).toBe("a stranger's journey failed: the forge never answered");
99 expect(proofNeed(dir)?.sentence).toContain("never answered");
100 expect(p?.failures).toBe(1);
101 // not asked again before retryAfter, and then once
102 expect(await run("4.0.0", site("4.0.0").fake, { journey: walk({}) })).toBeUndefined();
103 const later = await run("4.0.0", site("4.0.0").fake, { journey: walk({ ok: false, failure: "x" }), now: () => Date.now() + retryAfter(1) + 1 });
104 expect(later?.failures).toBe(2);
105 });
106
107 test("a passing walk keeps its timings, and they reach the card, escaped", async () => {
108 const p = await run("4.0.1", site("4.0.1").fake, { journey: walk({}) });
109 expect(p?.ok).toBe(true);
110 expect(lastProof(dir)?.journey).toEqual({ readyMs: 12_000, firstActivityMs: 41_000, screens: ["home", "claim", "forge"] });
111 expect(journeyLine(plain, lastProof(dir)?.journey, (n) => n)).toBe("a stranger's forge was ready in 12 s and the crew working in 41 s");
112 const esc = journeyLine((s: TemplateStringsArray, ...v: string[]) => String.raw({ raw: s }, ...v), { readyMs: 1, firstActivityMs: 2 }, (n) => n);
113 expect(esc).toContain("1 s");
114 });
115
116 test("an older proof without timings reads as before", () => {
117 expect(journeyLine(plain, undefined, (n) => n)).toBeUndefined();
118 expect(journeyLine(plain, { readyMs: 5, firstActivityMs: null }, (n) => n)).toBeUndefined();
119 });
120
91121 test("the same version on restart does not run again", async () => {
92122 const first = site("2.0.0");
93123 await run("2.0.0", first.fake);
test/releases-page.test.ts +18
@@ -107,6 +107,24 @@ describe("the releases page", () => {
107107 expect(body).toContain('href="/alice/forge/pulls/6"');
108108 });
109109
110 test("a proven stranger's journey is one line on the page; no timings, no line", async () => {
111 const was = process.env["FORGE_VERSION"];
112 process.env["FORGE_VERSION"] = "9.9.9";
113 try {
114 const get = async () => (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text();
115 mkdirSync(join(root, "release"), { recursive: true });
116 writeFileSync(join(root, "release", "proof.json"), JSON.stringify({ version: "9.9.9", at: new Date().toISOString(), ok: true }));
117 expect(await get()).not.toContain("a stranger&#39;s forge");
118 writeFileSync(join(root, "release", "proof.json"), JSON.stringify({ version: "9.9.9", at: new Date().toISOString(), ok: true, journey: { readyMs: 12_000, firstActivityMs: 41_000, screens: [] } }));
119 const body = await get();
120 expect(body).toMatch(/id="journey-proof">a stranger(&#39;|')s forge was ready in 12 s and the crew working in 41 s/);
121 } finally {
122 if (was === undefined) delete process.env["FORGE_VERSION"];
123 else process.env["FORGE_VERSION"] = was;
124 rmSync(join(root, "release", "proof.json"), { force: true });
125 }
126 });
127
110128 test("the release list is rows, not a table or a card; the version is the one link", async () => {
111129 const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text();
112130 expect(body).not.toContain("<table");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs