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
Check · Review · Land · Live — Its check is queued.
torque/forge:torque/11787-a-release-counts-as-proven-only-when-a-s → main 2 commits fork
7 files
changed +98
−6
src/main.ts +2
| @@ -94,6 +94,7 @@ import { running } from "./kernel/version.ts"; | ||
| 94 | 94 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 95 | 95 | import { vitals } from "./kernel/vitals.ts"; |
| 96 | 96 | import { proveRunning } from "./release/prove.ts"; |
| 97 | import { walkJourney } from "./release/journey.ts"; | |
| 97 | 98 | import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts"; |
| 98 | 99 | import { readLandings } from "./release/landings.ts"; |
| 99 | 100 | import { ownTagsReader } from "./release/own_tags.ts"; |
| @@ -785,6 +786,7 @@ proveRunning({ | ||
| 785 | 786 | version: running().version, |
| 786 | 787 | url: forgeUrl, |
| 787 | 788 | token: process.env["FORGE_TOKEN"], |
| 789 | journey: walkJourney, | |
| 788 | 790 | // The first app's production host: it must never answer as the forge (#6726). |
| 789 | 791 | appHost: (() => { |
| 790 | 792 | const first = listApps(app.db)[0]; |
src/release/prove.ts +12 −2
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | |
| 23 | 23 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 24 | 24 | import { join } from "node:path"; |
| 25 | import { walkJourney, type JourneyResult } from "./journey.ts"; | |
| 25 | 26 | import { smoke, type Failure, type Heavy } from "./smoke.ts"; |
| 26 | 27 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 27 | 28 | import { ownProbeHeaders } from "../kernel/probe.ts"; |
| @@ -58,6 +59,8 @@ export interface Proof { | ||
| 58 | 59 | readonly slow?: boolean; |
| 59 | 60 | /** The failure was the give-up: ten minutes already spent polling; only its first retry skips the backoff (#10168). */ |
| 60 | 61 | 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[] }; | |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | /** |
| @@ -151,6 +154,8 @@ export interface ProveOptions { | ||
| 151 | 154 | readonly heavy?: Heavy; |
| 152 | 155 | /** Aborted when the proof is hung: its asks stop and it keeps no verdict (#10313). */ |
| 153 | 156 | 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; | |
| 154 | 159 | } |
| 155 | 160 | |
| 156 | 161 | /** 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> | ||
| 174 | 179 | const baseFetch = o.fetch ?? fetch; |
| 175 | 180 | const doFetch = ((url, init) => baseFetch(url, o.signal ? { ...init, signal: init?.signal ? AbortSignal.any([init.signal, o.signal]) : o.signal } : init)) as typeof fetch; |
| 176 | 181 | 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 => { | |
| 178 | 183 | if (o.signal?.aborted) throw new Error("the proof was aborted as hung"); |
| 179 | 184 | const at = new Date(now()).toISOString(); |
| 180 | 185 | return keep(o.dataDir, { |
| 181 | 186 | version: o.version, |
| 182 | 187 | at, |
| 183 | 188 | ok: failure === undefined, |
| 189 | ...(walked === undefined ? {} : { journey: { readyMs: walked.readyMs, firstActivityMs: walked.firstActivityMs, screens: walked.screens } }), | |
| 184 | 190 | ...(failure === undefined |
| 185 | 191 | ? {} |
| 186 | 192 | : { 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> | ||
| 216 | 222 | anonymousBoard: true, |
| 217 | 223 | heavy: { version: o.version, probe: ownProbeHeaders(o.dataDir), ...o.heavy }, ...(o.appHost ? { appHost: o.appHost } : {}), |
| 218 | 224 | }); |
| 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); | |
| 220 | 230 | } catch (error) { |
| 221 | 231 | return finish(`the smoke checks could not run: ${String(error)}`); |
| 222 | 232 | } |
src/release/words.ts +15
| @@ -26,3 +26,18 @@ export const ALREADY_LIVE = "This release is the one serving; there is nothing t | ||
| 26 | 26 | |
| 27 | 27 | /** Why a kept release offers no rollback: its preflight failed or its schema is behind the live database's. */ |
| 28 | 28 | 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"; | ||
| 10 | 10 | import { running } from "../../kernel/version.ts"; |
| 11 | 11 | import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts"; |
| 12 | 12 | 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"; | |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import type { Session } from "../session.ts"; |
| @@ -33,6 +34,13 @@ export function liveText(f: Session): string | undefined { | ||
| 33 | 34 | return live === null || !mayRead(f.app, f.user) ? undefined : liveSince(plain, live.slice(0, 7), ago(BOOTED)); |
| 34 | 35 | } |
| 35 | 36 | |
| 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 | ||
| 36 | 44 | /** The kept releases this person may see; none at all for anyone the page would 404. */ |
| 37 | 45 | export async function readReleases(f: Session): Promise<ReleaseLine[]> { |
| 38 | 46 | if (!mayRead(f.app, f.user)) return []; |
| @@ -84,7 +92,7 @@ export function releasesScreen(f: Session, ctx: Ctx): Screen { | ||
| 84 | 92 | render(area, s) { |
| 85 | 93 | const c = ctx.theme.c; |
| 86 | 94 | 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); | |
| 88 | 96 | if (lines.length === 0) { |
| 89 | 97 | emptyState(s, body!, c, { |
| 90 | 98 | title: loaded ? "No releases yet" : "Reading…", |
src/web/pages/releases.ts +11 −2
| @@ -11,9 +11,10 @@ import { getReposByIds } from "../../core/repos.ts"; | ||
| 11 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 12 | 12 | import { running } from "../../kernel/version.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { lastProof } from "../../release/prove.ts"; | |
| 14 | 15 | import { listReleases } from "../../release/record.ts"; |
| 15 | 16 | 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"; | |
| 17 | 18 | import { notFoundPage, signInFirst, NOT_ADMIN_HERE, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts"; |
| 18 | 19 | import { readForm } from "../form.ts"; |
| 19 | 20 | import { html, when, type Html } from "../html.ts"; |
| @@ -31,6 +32,14 @@ export function liveLine(ctx: Ctx): Html | undefined { | ||
| 31 | 32 | </p>`; |
| 32 | 33 | } |
| 33 | 34 | |
| 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 | ||
| 34 | 43 | /** |
| 35 | 44 | * An owner's hold reason and a keeper's reason are free text: inside |
| 36 | 45 | * `[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> { | ||
| 81 | 90 | const response = page({ |
| 82 | 91 | ctx, |
| 83 | 92 | 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>`, | |
| 85 | 94 | styleCss: RELEASES_CSS, |
| 86 | 95 | }); |
| 87 | 96 | 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"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | 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"; | |
| 13 | 15 | import { owned } from "../src/crew/incidents.ts"; |
| 14 | 16 | |
| 15 | 17 | const CSS = '<html><link rel="stylesheet" href="/a.css"></html>'; |
| @@ -88,6 +90,34 @@ describe("proving a release", () => { | ||
| 88 | 90 | expect(proofNeed(dir)?.since).toBe(need.since); |
| 89 | 91 | }); |
| 90 | 92 | |
| 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 | ||
| 91 | 121 | test("the same version on restart does not run again", async () => { |
| 92 | 122 | const first = site("2.0.0"); |
| 93 | 123 | await run("2.0.0", first.fake); |
test/releases-page.test.ts +18
| @@ -107,6 +107,24 @@ describe("the releases page", () => { | ||
| 107 | 107 | expect(body).toContain('href="/alice/forge/pulls/6"'); |
| 108 | 108 | }); |
| 109 | 109 | |
| 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'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('|')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 | ||
| 110 | 128 | test("the release list is rows, not a table or a card; the version is the one link", async () => { |
| 111 | 129 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text(); |
| 112 | 130 | expect(body).not.toContain("<table"); |
new above
offline