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

Release verify probes the centre point at phone size for a covering element (#11831 item 4) #11886

opened this

Check · Review · Land · Live — No review yet. Next: reviewer, no review yet.

Ready to land

torque/forge:torque/11855-release-verify-probes-the-centre-point-a → main 1 commit fork

Review

Sign in to review this change.
5 files changed +241 −2
docs/REFERENCE.md +1 −1
@@ -587,7 +587,7 @@ The terminal's palette has `Signed in` for anyone signed in: your browsers, toke
587587 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. `FORGE_RELEASE_KEEPER=swap` needs no cluster: it swaps main's tree in place (`docs/features/ship-itself.md`). Its tag is the forge's own and is not pushed to an origin, so a cut is invisible to Forgejo-side tools. /releases, the terminal's Release keeper and MCP `release_keeper` show each release's short commit and its seconds from landed to live; /changelog's live line says it too.
588588
589589 - **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.
590 - **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.
590 - **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, then opening the home, repository and apps pages at phone size and failing if a dialog, popover or large fixed layer covers the centre of the screen.
591591 - **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.
592592 - **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.
593593 - **Where to see it:** the **Release keeper** card on `/releases`, `Release keeper` in the terminal's palette, the **Maintenance** board under `/boards`, MCP `release_keeper` and `GET /api/v1/keeper` (JSON; 403 for a caller who may not read releases), each gated as `/releases` is: a guest sees none of it.
docs/features/release-keeper.md +8
@@ -45,6 +45,14 @@ keyboard. It does what the steward's `release.sh`, `verify-after.sh` and
4545 the verify and deleted after it);
4646 - it probes `/healthz` once a second.
4747
48 When the ten minutes pass clean, it looks at a phone (`keeper/covered.ts`,
49 #11855): `/`, the forge's repository page and `/apps` are opened at
50 390×844 in headless Chromium as the same admin, and the element at the
51 centre of the screen must not be a dialog, a popover, a hidden element or
52 a `position: fixed` layer over a quarter of the viewport. One that is fails
53 the verify and rolls the release back, naming the page. A forge with no
54 Chromium cannot look and says nothing failed.
55
4856 A probe is bad when it is not a 200 or takes over 10 s. Three bad probes
4957 in a row fail the release.
5058 4. **Verified live**, or **roll back and hold**. On a failure, the keeper
src/keeper/covered.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * The release proof looks at a phone (#11855, #11831 item 4). A 200 is not
3 * proof a person can use a page: release 0.7.549 answered every probe while a
4 * closed dialog was drawn over every page on a phone. After the load probes,
5 * the home page, the forge's repository page and the apps page are opened at
6 * 390×844 in headless Chromium, signed in as the verify's admin, with nothing
7 * opened; the element at the centre of the screen must not be a dialog, a
8 * popover or a `position: fixed` layer bigger than a quarter of the viewport.
9 * One that is fails the verify, and the release rolls back like any other.
10 *
11 * `centreVerdict` is plain data in, a reason (or nothing) out; `probeCentre`
12 * is the browser. A forge with no browser cannot look: it says so and does
13 * not fail the release for it.
14 */
15
16 import { findChromium } from "../looks/capture.ts";
17 import { withPage } from "../release/devtools.ts";
18
19 /** A phone: the viewport the look is taken at, in CSS pixels. */
20 export const PHONE = { width: 390, height: 844 } as const;
21 /** A layer over this share of the viewport, at the centre, covers the page. */
22 export const COVER_SHARE = 0.25;
23
24 /** One element from the centre point up to the root, as the page reports it. */
25 export interface Layer {
26 readonly tag: string;
27 readonly position: string;
28 readonly popover: boolean;
29 readonly hidden: boolean;
30 /** Its box, in CSS pixels, clipped to the viewport. */
31 readonly width: number;
32 readonly height: number;
33 }
34
35 /** What the page saw at its centre: the layers from the topmost element up. */
36 export interface Centre {
37 readonly layers: readonly Layer[];
38 }
39
40 /**
41 * Runs in the page: the layers at the centre, topmost first. The page's
42 * words are narrowed by `parseCentre`; nothing here is trusted.
43 */
44 export const CENTRE_SCRIPT = `(() => {
45 const w = window.innerWidth, h = window.innerHeight;
46 const out = [];
47 for (let e = document.elementFromPoint(w / 2, h / 2); e && e !== document.documentElement; e = e.parentElement) {
48 const r = e.getBoundingClientRect();
49 out.push({
50 tag: e.localName,
51 position: getComputedStyle(e).position,
52 popover: e.hasAttribute("popover"),
53 hidden: e.hasAttribute("hidden"),
54 width: Math.max(0, Math.min(r.right, w) - Math.max(r.left, 0)),
55 height: Math.max(0, Math.min(r.bottom, h) - Math.max(r.top, 0)),
56 });
57 }
58 return JSON.stringify({ layers: out });
59 })()`;
60
61 /** The page's report narrowed to numbers and strings of a bounded size; anything else is no layers. */
62 export function parseCentre(raw: unknown): Centre {
63 let from: unknown = raw;
64 if (typeof raw === "string") {
65 try {
66 from = JSON.parse(raw);
67 } catch {
68 return { layers: [] };
69 }
70 }
71 const list = (from as { layers?: unknown } | null)?.layers;
72 if (!Array.isArray(list)) return { layers: [] };
73 const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
74 const layers: Layer[] = [];
75 for (const item of list.slice(0, 64)) {
76 if (item === null || typeof item !== "object") continue;
77 const l = item as Record<string, unknown>;
78 layers.push({
79 tag: typeof l["tag"] === "string" ? l["tag"].slice(0, 40) : "",
80 position: typeof l["position"] === "string" ? l["position"].slice(0, 20) : "",
81 popover: l["popover"] === true,
82 hidden: l["hidden"] === true,
83 width: num(l["width"]),
84 height: num(l["height"]),
85 });
86 }
87 return { layers };
88 }
89
90 /**
91 * Why the centre of the screen is covered, or undefined when it is not. The
92 * topmost element and its ancestors are asked: a dialog, a popover, a
93 * `hidden` element, or a fixed layer over a quarter of the viewport is a
94 * cover (a fixed header or a toast is small and does not count). No layers at
95 * all is a page with nothing at its centre, which is a cover of its own kind.
96 */
97 export function centreVerdict(c: Centre, viewport: { width: number; height: number } = PHONE): string | undefined {
98 if (c.layers.length === 0) return "nothing is drawn at the centre of the screen";
99 const area = viewport.width * viewport.height;
100 for (const l of c.layers) {
101 const share = (l.width * l.height) / area;
102 if (l.tag === "dialog") return `a <dialog> covers the centre of the screen (${Math.round(share * 100)}% of it)`;
103 if (l.popover) return `a [popover] <${l.tag}> covers the centre of the screen`;
104 if (l.hidden) return `a hidden <${l.tag}> is drawn at the centre of the screen`;
105 if (l.position === "fixed" && share > COVER_SHARE)
106 return `a fixed <${l.tag}> covers ${Math.round(share * 100)}% of the screen, centre included`;
107 }
108 return undefined;
109 }
110
111 export interface PhoneVerdict {
112 readonly ok: boolean;
113 /** One line naming the page and what covers it; empty when ok. */
114 readonly reason: string;
115 /** How many pages were looked at; 0 when no browser was there. */
116 readonly looked: number;
117 }
118
119 /**
120 * Look at each page through `look` (the centre it saw, or an error) and fail
121 * on the first covered one. A look that could not be made is not a cover: a
122 * forge without a browser, or a page that never loaded, is reported by the
123 * load probes, not here.
124 */
125 export async function verifyPhone(
126 pages: readonly string[],
127 look: (path: string) => Promise<Centre | undefined>,
128 ): Promise<PhoneVerdict> {
129 let looked = 0;
130 for (const path of pages) {
131 const c = await look(path).catch(() => undefined);
132 if (c === undefined) continue;
133 looked++;
134 const why = centreVerdict(c);
135 if (why !== undefined)
136 return { ok: false, reason: `${path} at ${PHONE.width}×${PHONE.height}: ${why}`, looked };
137 }
138 return { ok: true, reason: "", looked };
139 }
140
141 /** Time given to a page to load before its centre is read. */
142 const SETTLE_MS = 500;
143
144 /** The browser's look: each page opened at phone size, signed in by `cookie`, read at its centre. */
145 export function probeCentre(base: string, cookie: string | undefined): ((path: string) => Promise<Centre | undefined>) | undefined {
146 const binary = findChromium();
147 if (binary === undefined) return undefined;
148 return (path) =>
149 withPage(binary, async (send, once) => {
150 await send("Page.enable");
151 await send("Emulation.setDeviceMetricsOverride", { ...PHONE, deviceScaleFactor: 1, mobile: true });
152 if (cookie !== undefined) {
153 const [name = "", value = ""] = cookie.split("=");
154 await send("Network.setCookie", { name, value, url: base, httpOnly: true });
155 }
156 const loaded = once("Page.loadEventFired").catch(() => {});
157 await send("Page.navigate", { url: new URL(path, base).toString() });
158 await loaded;
159 await Bun.sleep(SETTLE_MS);
160 const read = await send("Runtime.evaluate", { expression: CENTRE_SCRIPT, returnByValue: true });
161 return parseCentre(read?.result?.value);
162 });
163 }
src/keeper/wire.ts +13 −1
@@ -35,6 +35,7 @@ import { kubeDeployer } from "./deploy.ts";
3535 import { swapper } from "./swapper.ts";
3636 import type { KeeperPorts } from "./keeper.ts";
3737 import { timedGet, verifyUnderLoad } from "./verify.ts";
38 import { probeCentre, verifyPhone } from "./covered.ts";
3839 import { diskUnder } from "../spawn/disk_floor.ts";
3940
4041 const LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
@@ -77,6 +78,11 @@ export function loadPages(repo: Pick<Repo, "ownerName" | "name">): string[] {
7778 return ["/boards", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/explore", "/"];
7879 }
7980
81 /** The pages looked at on a phone: home, the forge's repository, the apps page. */
82 export function phonePages(repo: Pick<Repo, "ownerName" | "name">): string[] {
83 return ["/", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/apps"];
84 }
85
8086 export function keeperPorts(app: App, input: { forgeUrl: string; deployment: string; container: string; swap?: boolean }): KeeperPorts | string {
8187 const named = app.config.forgeRepo;
8288 if (named === undefined) return "FORGE_REPO is not set";
@@ -159,13 +165,19 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str
159165 const session = admin === undefined ? undefined : createSession(app.db, admin.id);
160166 const cookie = session === undefined ? {} : { cookie: `forge_session=${session.token}` };
161167 try {
162 return await verifyUnderLoad({
168 const loaded = await verifyUnderLoad({
163169 get: timedGet(input.forgeUrl, (asAdmin) => (asAdmin ? cookie : probeHeaders)),
164170 now: () => Date.now(),
165171 sleep: (ms) => Bun.sleep(ms),
166172 pages: loadPages(repo),
167173 onProbe,
168174 });
175 if (!loaded.ok) return loaded;
176 // A 200 is not proof a person can use the page: look at a phone (#11855).
177 const look = probeCentre(input.forgeUrl, session === undefined ? undefined : `forge_session=${session.token}`);
178 if (look === undefined) return loaded;
179 const phone = await verifyPhone(phonePages(repo), look);
180 return phone.ok ? loaded : { ok: false, tally: loaded.tally, reason: `covered at phone size: ${phone.reason}` };
169181 } finally {
170182 if (session !== undefined) destroySession(app.db, session.token);
171183 }
test/keeper-covered.test.ts added +56
@@ -0,0 +1,56 @@
1 // The release proof looks at a phone (#11855): a closed dialog drawn over the page fails the verify.
2 import { describe, expect, test } from "bun:test";
3 import { centreVerdict, parseCentre, verifyPhone, type Layer } from "../src/keeper/covered.ts";
4
5 const layer = (o: Partial<Layer>): Layer => ({ tag: "p", position: "static", popover: false, hidden: false, width: 100, height: 40, ...o });
6 const page = [layer({ tag: "main" }), layer({ tag: "body" })];
7
8 describe("centreVerdict", () => {
9 test("the page's own content at the centre is not covered", () => {
10 expect(centreVerdict({ layers: page })).toBeUndefined();
11 });
12 test("a full-screen dialog (the 0.7.549 palette) covers", () => {
13 const palette = layer({ tag: "dialog", position: "fixed", width: 390, height: 844 });
14 expect(centreVerdict({ layers: [palette, ...page] })).toContain("<dialog>");
15 });
16 test("a popover and a hidden element cover", () => {
17 expect(centreVerdict({ layers: [layer({ tag: "div", popover: true })] })).toContain("popover");
18 expect(centreVerdict({ layers: [layer({ tag: "div", hidden: true })] })).toContain("hidden");
19 });
20 test("a fixed layer over a quarter of the screen covers; a fixed bar does not", () => {
21 expect(centreVerdict({ layers: [layer({ position: "fixed", width: 390, height: 300 }), ...page] })).toContain("fixed");
22 expect(centreVerdict({ layers: [layer({ position: "fixed", width: 390, height: 56 }), ...page] })).toBeUndefined();
23 });
24 test("a fixed ancestor counts, not only the topmost element", () => {
25 expect(centreVerdict({ layers: [layer({ tag: "span" }), layer({ tag: "div", position: "fixed", width: 390, height: 844 })] })).toContain("fixed");
26 });
27 test("nothing at the centre is a cover", () => {
28 expect(centreVerdict({ layers: [] })).toContain("nothing");
29 });
30 });
31
32 describe("parseCentre", () => {
33 test("narrows what the page said; junk is no layers", () => {
34 expect(parseCentre("not json").layers).toEqual([]);
35 expect(parseCentre(JSON.stringify({ layers: [{ tag: "dialog", position: "fixed", width: "x", height: 5 }, 7] })).layers).toEqual([
36 { tag: "dialog", position: "fixed", popover: false, hidden: false, width: 0, height: 5 },
37 ]);
38 });
39 });
40
41 describe("verifyPhone", () => {
42 test("fails on the first covered page, naming it and the size", async () => {
43 const seen: string[] = [];
44 const v = await verifyPhone(["/", "/a/b", "/apps"], async (p) => {
45 seen.push(p);
46 return { layers: p === "/a/b" ? [layer({ tag: "dialog", position: "fixed", width: 390, height: 844 })] : page };
47 });
48 expect(v.ok).toBe(false);
49 expect(v.reason).toContain("/a/b at 390×844");
50 expect(seen).toEqual(["/", "/a/b"]);
51 });
52 test("passes clean pages; a look that could not be made is not a cover", async () => {
53 const v = await verifyPhone(["/", "/apps"], async (p) => (p === "/" ? undefined : { layers: page }));
54 expect(v).toEqual({ ok: true, reason: "", looked: 1 });
55 });
56 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs