Public: anyone can read this; the crew can change it.
The release smoke loads /boards and the heaviest public pages, then watches /healthz for 60 s before it calls a release live #6901
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6898-the-release-smoke-loads-boards-and-the-h → main 2 commits fork
6 files
changed +178
−6
scripts/smoke.ts +10 −2
| @@ -16,6 +16,9 @@ | ||
| 16 | 16 | * line per finding with its section (`--error-path` names a path that must answer |
| 17 | 17 | * 500), and exits 0: it reports, it does not gate. |
| 18 | 18 | * |
| 19 | * `--version` also loads `/boards`, `/`, `/explore`, `/search?q=a` and a repository home (`--repo`, default | |
| 20 | * /trevato/forge, skipped on 404) and watches `/healthz` for `--watch` seconds (default 60); see `release/smoke.ts`. | |
| 21 | * | |
| 19 | 22 | * `--app-host <url>` also fetches `/` there and fails if it is the forge's page (`<title>forge</title>`). |
| 20 | 23 | * |
| 21 | 24 | * `--outsider` also signs up a fresh account and proves each thing a |
| @@ -47,10 +50,13 @@ if (import.meta.main) { | ||
| 47 | 50 | const appHost = flag("--app-host"); |
| 48 | 51 | const version = flag("--version"); |
| 49 | 52 | const wait = flag("--wait"); |
| 53 | const watchFor = flag("--watch"); | |
| 54 | const repo = flag("--repo"); | |
| 50 | 55 | const base = args[0]; |
| 51 | 56 | const seconds = wait === undefined ? 120 : Number(wait); |
| 52 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { | |
| 53 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--app-host <url of an app>] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)"); | |
| 57 | const watchSeconds = watchFor === undefined ? undefined : Number(watchFor); | |
| 58 | if (!base || (watchSeconds !== undefined && !(watchSeconds >= 0)) || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { | |
| 59 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--watch <seconds of /healthz watching, default 60>] [--repo </owner/name>] [--outsider] [--app-host <url of an app>] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)"); | |
| 54 | 60 | process.exit(2); |
| 55 | 61 | } |
| 56 | 62 | if (withOutsider && !process.env["FORGE_TOKEN"]) { |
| @@ -71,6 +77,8 @@ if (import.meta.main) { | ||
| 71 | 77 | } |
| 72 | 78 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { |
| 73 | 79 | version, |
| 80 | // A release (`--version`) loads the heavy pages and watches /healthz after go-live (#6898). | |
| 81 | ...(version ? { heavy: { ...(watchSeconds === undefined ? {} : { watchMs: watchSeconds * 1000 }), ...(repo ? { repo } : {}) } } : {}), | |
| 74 | 82 | waitMs: seconds * 1000, |
| 75 | 83 | ...(appHost ? { appHost } : {}), |
| 76 | 84 | // The forge tells only its probe its version (`src/kernel/probe.ts`). |
src/release/prove.ts +8 −2
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | import { smoke, type Failure } from "./smoke.ts"; | |
| 13 | import { smoke, type Failure, type Heavy } from "./smoke.ts"; | |
| 14 | 14 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 15 | 15 | import { ownProbeHeaders } from "../kernel/probe.ts"; |
| 16 | 16 | |
| @@ -72,6 +72,8 @@ export interface ProveOptions { | ||
| 72 | 72 | readonly now?: () => number; |
| 73 | 73 | readonly sleep?: (ms: number) => Promise<void>; |
| 74 | 74 | readonly giveUpMs?: number; |
| 75 | /** The heavy-page load and `/healthz` watch after go-live; a shorter window for a test. */ | |
| 76 | readonly heavy?: Heavy; | |
| 75 | 77 | } |
| 76 | 78 | |
| 77 | 79 | /** |
| @@ -104,7 +106,11 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 104 | 106 | if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`); |
| 105 | 107 | await sleep(POLL_MS); |
| 106 | 108 | } |
| 107 | const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true, ...(o.appHost ? { appHost: o.appHost } : {}) }); | |
| 109 | const failures = await smoke(o.url, o.token ?? "", { | |
| 110 | fetch: doFetch, | |
| 111 | anonymousBoard: true, | |
| 112 | heavy: { version: o.version, probe: ownProbeHeaders(o.dataDir), ...o.heavy }, ...(o.appHost ? { appHost: o.appHost } : {}), | |
| 113 | }); | |
| 108 | 114 | return finish(failures[0] === undefined ? undefined : checkLine(failures[0])); |
| 109 | 115 | } catch (error) { |
| 110 | 116 | return finish(`the smoke checks could not run: ${String(error)}`); |
src/release/smoke.ts +74
| @@ -6,6 +6,12 @@ | ||
| 6 | 6 | * branch never answers with `Response.error()`. With `version`, `/healthz` is |
| 7 | 7 | * first polled until `X-Forge-Version` is that version. |
| 8 | 8 | * |
| 9 | * With `heavy`, it also loads the heaviest pages (`HEAVY_PAGES`: `/boards` | |
| 10 | * signed in, `/`, `/explore`, `/search?q=a`, one repository's home), each of which | |
| 11 | * must answer 200 within `SLOW_MS`, then polls `/healthz` once a second for | |
| 12 | * `WATCH_MS`, rotating through those pages, and fails naming the page that | |
| 13 | * was slow or failed (#6898: `/readyz` never touches the database, 0.7.277 hung). | |
| 14 | * | |
| 9 | 15 | * The running forge proves each new version with it (`release/prove.ts`), so |
| 10 | 16 | * it lives here: the image ships src/ and nothing else (#2652). |
| 11 | 17 | * `bun scripts/smoke.ts <url>` is the same checks from a shell. |
| @@ -18,6 +24,27 @@ export const AGENTS: Record<string, string> = { | ||
| 18 | 24 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", |
| 19 | 25 | }; |
| 20 | 26 | |
| 27 | /** How long after go-live `/healthz` is watched, how often, and the slowest answer allowed. */ | |
| 28 | export const WATCH_MS = 60_000; | |
| 29 | export const WATCH_POLL_MS = 1_000; | |
| 30 | export const SLOW_MS = 1_000; | |
| 31 | /** One repository's home, for the heavy-page load. */ | |
| 32 | export const REPO_HOME = "/trevato/forge"; | |
| 33 | /** The heaviest pages; `/boards` is fetched with the token. */ | |
| 34 | export const HEAVY_PAGES: readonly string[] = ["/boards", "/", "/explore", "/search?q=a", REPO_HOME]; | |
| 35 | ||
| 36 | export interface Heavy { | |
| 37 | watchMs?: number; | |
| 38 | pollMs?: number; | |
| 39 | slowMs?: number; | |
| 40 | /** The repository whose home is loaded; unset, `REPO_HOME` is tried and a 404 there is skipped. */ | |
| 41 | repo?: string; | |
| 42 | /** Every `/healthz` answer in the watch must carry this `X-Forge-Version`. */ | |
| 43 | version?: string | undefined; | |
| 44 | /** Sent with the watch's `/healthz`: the forge tells only its probe its version. */ | |
| 45 | probe?: Readonly<Record<string, string>>; | |
| 46 | } | |
| 47 | ||
| 21 | 48 | export interface Failure { |
| 22 | 49 | url: string; |
| 23 | 50 | agent: string; |
| @@ -61,6 +88,8 @@ export interface Options { | ||
| 61 | 88 | anonymousBoard?: boolean; |
| 62 | 89 | /** An app's URL on this forge: `GET /` there must never be a forge page (#6726). */ |
| 63 | 90 | appHost?: string; |
| 91 | /** Load the heaviest pages, then watch `/healthz` (see the top of this file). Off unless given. */ | |
| 92 | heavy?: Heavy; | |
| 64 | 93 | } |
| 65 | 94 | |
| 66 | 95 | /** The redirect statuses a sign-in answers with. */ |
| @@ -106,6 +135,46 @@ async function waitForVersion(root: URL, version: string, waitMs: number, pollMs | ||
| 106 | 135 | return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` }; |
| 107 | 136 | } |
| 108 | 137 | |
| 138 | /** Loads each of `HEAVY_PAGES`, then polls `/healthz` for the window; the first failure, or null. */ | |
| 139 | async function watch(root: URL, token: string, heavy: Heavy, fetch: typeof globalThis.fetch, anonymousBoard: boolean): Promise<Failure | null> { | |
| 140 | const slowMs = heavy.slowMs ?? SLOW_MS; | |
| 141 | const headers: Record<string, string> = { "user-agent": AGENTS["desktop"]! }; | |
| 142 | const pages = HEAVY_PAGES.map((p) => (p === REPO_HOME && heavy.repo ? heavy.repo : p)); | |
| 143 | const timed = async (path: string): Promise<Failure | null> => { | |
| 144 | const url = new URL(path, root).href; | |
| 145 | const started = performance.now(); | |
| 146 | const gone = (why: string, status: number | string = "error"): Failure => ({ url, agent: "desktop", status, contentType: "", why }); | |
| 147 | try { | |
| 148 | const auth: Record<string, string> = path === "/boards" && token ? { authorization: `Bearer ${token}` } : {}; | |
| 149 | const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(slowMs), headers: { ...headers, ...(path === "/healthz" ? heavy.probe : {}), ...auth } }); | |
| 150 | const body = await res.text(); | |
| 151 | const took = Math.round(performance.now() - started); | |
| 152 | if (took > slowMs) return gone(`${path} took ${took} ms, over ${slowMs} ms`, res.status); | |
| 153 | if (path === REPO_HOME && !heavy.repo && res.status === 404) return null; | |
| 154 | if (path === "/healthz" && heavy.version && res.headers.get("x-forge-version") !== heavy.version) | |
| 155 | return gone(`/healthz reported X-Forge-Version ${res.headers.get("x-forge-version") ?? "none"}, wanted ${heavy.version}`, res.status); | |
| 156 | if (path === "/boards" && anonymousBoard && !token && isSignInRedirect(url, res)) return null; | |
| 157 | if (res.status !== 200) return gone(`${path} expected 200`, res.status); | |
| 158 | if (!body.trim()) return gone(`${path} answered with an empty body`, res.status); | |
| 159 | return null; | |
| 160 | } catch (error) { | |
| 161 | return gone(`${path} did not answer within ${slowMs} ms (${error})`); | |
| 162 | } | |
| 163 | }; | |
| 164 | for (const path of pages) { | |
| 165 | const failed = await timed(path); | |
| 166 | if (failed) return failed; | |
| 167 | } | |
| 168 | const deadline = Date.now() + (heavy.watchMs ?? WATCH_MS); | |
| 169 | const pollMs = heavy.pollMs ?? WATCH_POLL_MS; | |
| 170 | for (let tick = 0; Date.now() < deadline; tick++) { | |
| 171 | const failed = (await timed("/healthz")) ?? (await timed(pages[tick % pages.length]!)); | |
| 172 | if (failed) return failed; | |
| 173 | await Bun.sleep(pollMs); | |
| 174 | } | |
| 175 | return null; | |
| 176 | } | |
| 177 | ||
| 109 | 178 | /** Runs every check against `base`; returns the failures (empty is green). */ |
| 110 | 179 | export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> { |
| 111 | 180 | const root = new URL(base); |
| @@ -181,5 +250,10 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr | ||
| 181 | 250 | failures.push({ url, agent: "desktop", status: "error", contentType: "", why: String(error) }); |
| 182 | 251 | } |
| 183 | 252 | } |
| 253 | if (options.heavy && failures.length === 0) { | |
| 254 | const heavy = { version: options.version, ...(options.probe ? { probe: options.probe } : {}), ...options.heavy }; | |
| 255 | const slow = await watch(root, token, heavy, fetch, !!options.anonymousBoard); | |
| 256 | if (slow) failures.push(slow); | |
| 257 | } | |
| 184 | 258 | return failures; |
| 185 | 259 | } |
test/release-prove.test.ts +11 −1
| @@ -29,6 +29,7 @@ function site(version: string, over: Record<string, () => Response> = {}) { | ||
| 29 | 29 | return new Response('if (r.mode === "navigate") { e.respondWith(fetch(r)); return; }', { |
| 30 | 30 | headers: { "content-type": "text/javascript" }, |
| 31 | 31 | }); |
| 32 | if (path === "/explore" || path === "/search" || path === "/boards") return new Response("page"); | |
| 32 | 33 | return new Response("no", { status: 404 }); |
| 33 | 34 | }) as unknown as typeof fetch; |
| 34 | 35 | return { fake, hits }; |
| @@ -41,7 +42,7 @@ describe("proving a release", () => { | ||
| 41 | 42 | }); |
| 42 | 43 | afterEach(() => rmSync(dir, { recursive: true, force: true })); |
| 43 | 44 | const run = (version: string, fake: typeof fetch, extra = {}) => |
| 44 | proveRelease({ dataDir: dir, version, url: "https://forge.test", fetch: fake, sleep: async () => {}, ...extra }); | |
| 45 | proveRelease({ dataDir: dir, version, url: "https://forge.test", fetch: fake, sleep: async () => {}, heavy: { watchMs: 30, pollMs: 5 }, ...extra }); | |
| 45 | 46 | |
| 46 | 47 | test("a pass is recorded with the version and raises nothing", async () => { |
| 47 | 48 | const { fake } = site("1.2.3"); |
| @@ -51,6 +52,15 @@ describe("proving a release", () => { | ||
| 51 | 52 | expect(proofNeed(dir)).toBeUndefined(); |
| 52 | 53 | }); |
| 53 | 54 | |
| 55 | test("the go-live proof loads the heavy pages and watches /healthz without being asked", async () => { | |
| 56 | const { fake: base, hits } = site("1.2.5"); | |
| 57 | const fake = (async (input: string | URL, init?: RequestInit) => | |
| 58 | new URL(String(input)).pathname === "/healthz" && hits.includes("/explore") ? new Response("down", { status: 503 }) : base(input, init)) as unknown as typeof fetch; | |
| 59 | const p = await proveRelease({ dataDir: dir, version: "1.2.5", url: "https://forge.test", fetch: fake, sleep: async () => {} }); | |
| 60 | expect(p?.ok).toBe(false); | |
| 61 | expect(p?.failure).toContain("/healthz"); | |
| 62 | }); | |
| 63 | ||
| 54 | 64 | test("an app host that answers as the forge fails the proof (#6726)", async () => { |
| 55 | 65 | const { fake: base } = site("1.2.6"); |
| 56 | 66 | const fake = (async (input: string | URL, init?: RequestInit) => |
test/release-smoke.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import { afterEach, expect, test } from "bun:test"; | |
| 2 | import { HEAVY_PAGES, smoke } from "../src/release/smoke.ts"; | |
| 3 | ||
| 4 | let server: ReturnType<typeof Bun.serve> | undefined; | |
| 5 | afterEach(() => server?.stop(true)); | |
| 6 | ||
| 7 | /** A healthy forge; `over` answers some paths its own way, `/boards` waits `boardsMs`. */ | |
| 8 | function forge(boardsMs: number, seen: string[] = [], over: (path: string) => Response | undefined = () => undefined) { | |
| 9 | server = Bun.serve({ | |
| 10 | port: 0, | |
| 11 | hostname: "127.0.0.1", | |
| 12 | async fetch(req) { | |
| 13 | const path = new URL(req.url).pathname; | |
| 14 | seen.push(path); | |
| 15 | const made = over(path); | |
| 16 | if (made) return made; | |
| 17 | if (path === "/") return new Response('<link rel="stylesheet" href="/a.css">', { headers: { "content-type": "text/html" } }); | |
| 18 | if (path === "/a.css") return new Response("body{margin:0}", { headers: { "content-type": "text/css" } }); | |
| 19 | if (path === "/sw.js") return new Response("if (r.mode === 'navigate') { e.respondWith(fetch(r)); return; }", { headers: { "content-type": "text/javascript" } }); | |
| 20 | if (path === "/boards") await Bun.sleep(boardsMs); | |
| 21 | return new Response("page " + path, { headers: { "x-forge-version": "v1" } }); | |
| 22 | }, | |
| 23 | }); | |
| 24 | return `http://127.0.0.1:${server.port}`; | |
| 25 | } | |
| 26 | ||
| 27 | const heavy = { watchMs: 300, pollMs: 50, slowMs: 200 }; | |
| 28 | ||
| 29 | test("a healthy forge passes the heavy pages and the /healthz watch", async () => { | |
| 30 | const seen: string[] = []; | |
| 31 | const failures = await smoke(forge(0, seen), "t", { heavy }); | |
| 32 | expect(failures.filter((f) => HEAVY_PAGES.some((page) => f.url.endsWith(page)) || f.url.endsWith("/healthz"))).toEqual([]); | |
| 33 | for (const page of HEAVY_PAGES) expect(seen).toContain(new URL(page, "http://x").pathname); | |
| 34 | expect(seen.filter((p) => p === "/healthz").length).toBeGreaterThan(4); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a /boards that blocks fails the smoke and names /boards", async () => { | |
| 38 | const failures = await smoke(forge(500), "t", { heavy }); | |
| 39 | const slow = failures.find((f) => f.url.endsWith("/boards") && f.why.includes("/boards")); | |
| 40 | expect(slow).toBeDefined(); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a page that answers 500 fails, named", async () => { | |
| 44 | const failures = await smoke(forge(0, [], (p) => (p === "/explore" ? new Response("x", { status: 500 }) : undefined)), "t", { heavy }); | |
| 45 | expect(failures.some((f) => f.url.endsWith("/explore") && f.status === 500)).toBe(true); | |
| 46 | }); | |
| 47 | ||
| 48 | test("without heavy, the pages are not loaded", async () => { | |
| 49 | const seen: string[] = []; | |
| 50 | expect(await smoke(forge(0, seen), "t")).toEqual([]); | |
| 51 | expect(seen).not.toContain("/explore"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a /healthz that reports another version mid-watch fails", async () => { | |
| 55 | let n = 0; | |
| 56 | const base = forge(0, [], (p) => (p === "/healthz" && ++n > 3 ? new Response("x", { headers: { "x-forge-version": "old" } }) : undefined)); | |
| 57 | const failures = await smoke(base, "t", { version: "v1", heavy }); | |
| 58 | expect(failures.some((f) => f.url.endsWith("/healthz") && f.why.includes("old"))).toBe(true); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a forge without the default repository is not failed for it, but a named one is", async () => { | |
| 62 | const gone = (p: string) => (p.startsWith("/trevato/") ? new Response("no", { status: 404 }) : undefined); | |
| 63 | expect(await smoke(forge(0, [], gone), "t", { heavy })).toEqual([]); | |
| 64 | const named = await smoke(forge(0, [], gone), "t", { heavy: { ...heavy, repo: "/trevato/other" } }); | |
| 65 | expect(named.some((f) => f.url.endsWith("/trevato/other"))).toBe(true); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the watch is skipped when the checks already failed", async () => { | |
| 69 | const seen: string[] = []; | |
| 70 | server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => (seen.push(new URL(req.url).pathname), new Response("x", { status: 500 })) }); | |
| 71 | await smoke(`http://127.0.0.1:${server.port}`, "t", { heavy: { ...heavy, watchMs: 5_000 } }); | |
| 72 | expect(seen).not.toContain("/explore"); | |
| 73 | }); |
test/smoke.test.ts +2 −1
| @@ -34,6 +34,7 @@ function stub(cssType: string, seen: string[] = []) { | ||
| 34 | 34 | ? new Response("board") |
| 35 | 35 | : new Response(null, { status: anonymousBoard.status, headers: { location: anonymousBoard.location } }); |
| 36 | 36 | if (path === "/healthz") return new Response("ok", { headers: { "x-forge-version": version() } }); |
| 37 | if (["/explore", "/search", "/boards"].includes(path) || path.startsWith("/trevato/")) return new Response("page"); | |
| 37 | 38 | if (path === "/sw.js") return new Response(sw.body, { status: sw.status, headers: { "content-type": sw.type } }); |
| 38 | 39 | return new Response("no", { status: 404 }); |
| 39 | 40 | }, |
| @@ -104,7 +105,7 @@ test("the CLI takes --version and --wait", async () => { | ||
| 104 | 105 | const base = stub("text/css"); |
| 105 | 106 | const run = (...a: string[]) => |
| 106 | 107 | Bun.spawn(["bun", "scripts/smoke.ts", base, ...a], { env: { ...process.env, FORGE_TOKEN: "t" }, stdout: "pipe", stderr: "pipe" }).exited; |
| 107 | expect(await run("--version", "v1")).toBe(0); | |
| 108 | expect(await run("--version", "v1", "--watch", "0.2")).toBe(0); | |
| 108 | 109 | expect(await run("--version", "zz", "--wait", "0")).toBe(1); |
| 109 | 110 | }); |
| 110 | 111 |
new above
offline