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
Check · Review · Land · Live — No review yet.
torque/forge:torque/11855-release-verify-probes-the-centre-point-a → main 1 commit fork
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 | ||
| 587 | 587 | 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. |
| 588 | 588 | |
| 589 | 589 | - **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. | |
| 591 | 591 | - **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. |
| 592 | 592 | - **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. |
| 593 | 593 | - **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 | ||
| 45 | 45 | the verify and deleted after it); |
| 46 | 46 | - it probes `/healthz` once a second. |
| 47 | 47 | |
| 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 | ||
| 48 | 56 | A probe is bad when it is not a 200 or takes over 10 s. Three bad probes |
| 49 | 57 | in a row fail the release. |
| 50 | 58 | 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"; | ||
| 35 | 35 | import { swapper } from "./swapper.ts"; |
| 36 | 36 | import type { KeeperPorts } from "./keeper.ts"; |
| 37 | 37 | import { timedGet, verifyUnderLoad } from "./verify.ts"; |
| 38 | import { probeCentre, verifyPhone } from "./covered.ts"; | |
| 38 | 39 | import { diskUnder } from "../spawn/disk_floor.ts"; |
| 39 | 40 | |
| 40 | 41 | 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[] { | ||
| 77 | 78 | return ["/boards", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/explore", "/"]; |
| 78 | 79 | } |
| 79 | 80 | |
| 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 | ||
| 80 | 86 | export function keeperPorts(app: App, input: { forgeUrl: string; deployment: string; container: string; swap?: boolean }): KeeperPorts | string { |
| 81 | 87 | const named = app.config.forgeRepo; |
| 82 | 88 | if (named === undefined) return "FORGE_REPO is not set"; |
| @@ -159,13 +165,19 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str | ||
| 159 | 165 | const session = admin === undefined ? undefined : createSession(app.db, admin.id); |
| 160 | 166 | const cookie = session === undefined ? {} : { cookie: `forge_session=${session.token}` }; |
| 161 | 167 | try { |
| 162 | return await verifyUnderLoad({ | |
| 168 | const loaded = await verifyUnderLoad({ | |
| 163 | 169 | get: timedGet(input.forgeUrl, (asAdmin) => (asAdmin ? cookie : probeHeaders)), |
| 164 | 170 | now: () => Date.now(), |
| 165 | 171 | sleep: (ms) => Bun.sleep(ms), |
| 166 | 172 | pages: loadPages(repo), |
| 167 | 173 | onProbe, |
| 168 | 174 | }); |
| 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}` }; | |
| 169 | 181 | } finally { |
| 170 | 182 | if (session !== undefined) destroySession(app.db, session.token); |
| 171 | 183 | } |
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 | }); |
new above
offline