Public: anyone can read this; the crew can change it.
A push that cannot boot leaves the front door saying "live" #216
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/200-a-push-that-cannot-boot-leaves-the-front → main 3 commits fork
7 files
changed +183
−19
src/apps/runtime.ts +31
| @@ -116,6 +116,16 @@ export interface EnvStatus { | ||
| 116 | 116 | * that has not started has not said either way. |
| 117 | 117 | */ |
| 118 | 118 | readonly tty?: string | null | undefined; |
| 119 | /** | |
| 120 | * A deploy newer than the one serving that never came up — set only | |
| 121 | * while an older deployment keeps answering in its place. A later | |
| 122 | * successful deploy of this environment clears it (it is the newest | |
| 123 | * attempt's own outcome, never a history), and it is never set once | |
| 124 | * nothing serves at all: `state: "failed"` and `error` already say that. | |
| 125 | */ | |
| 126 | readonly failedDeploy?: | |
| 127 | | { readonly commitOid: string; readonly runId: string; readonly error: string } | |
| 128 | | undefined; | |
| 119 | 129 | } |
| 120 | 130 | |
| 121 | 131 | interface Live { |
| @@ -188,6 +198,8 @@ interface Environment { | ||
| 188 | 198 | chain: Promise<void>; |
| 189 | 199 | lastError?: string | undefined; |
| 190 | 200 | lastRunId?: string | undefined; |
| 201 | /** The commit `lastError` belongs to — set together, cleared together. */ | |
| 202 | lastErrorCommitOid?: string | undefined; | |
| 191 | 203 | /** Crash restarts in the current window, for backing off. */ |
| 192 | 204 | restarts: number[]; |
| 193 | 205 | } |
| @@ -299,6 +311,22 @@ export class AppsRuntime { | ||
| 299 | 311 | }; |
| 300 | 312 | } |
| 301 | 313 | if (state.live !== undefined) { |
| 314 | // The most recent deploy attempt failed, and it was not this one — a | |
| 315 | // newer commit that never came up, while this older deployment kept | |
| 316 | // serving. `swapped` already covers the alternative (the newest | |
| 317 | // attempt is this one and it succeeded, however its tests read). | |
| 318 | const failedDeploy = | |
| 319 | !swapped && | |
| 320 | state.lastError !== undefined && | |
| 321 | state.lastErrorCommitOid !== undefined && | |
| 322 | state.lastErrorCommitOid !== state.live.commitOid && | |
| 323 | state.lastRunId !== undefined | |
| 324 | ? { | |
| 325 | commitOid: state.lastErrorCommitOid, | |
| 326 | runId: state.lastRunId, | |
| 327 | error: state.lastError, | |
| 328 | } | |
| 329 | : undefined; | |
| 302 | 330 | return { |
| 303 | 331 | env: state.env, |
| 304 | 332 | state: "running", |
| @@ -317,6 +345,7 @@ export class AppsRuntime { | ||
| 317 | 345 | ...(state.live.testsFailed === undefined |
| 318 | 346 | ? {} |
| 319 | 347 | : { error: `serving · tests failed: ${state.live.testsFailed}` }), |
| 348 | ...(failedDeploy === undefined ? {} : { failedDeploy }), | |
| 320 | 349 | }; |
| 321 | 350 | } |
| 322 | 351 | return { |
| @@ -661,6 +690,7 @@ export class AppsRuntime { | ||
| 661 | 690 | state.deploying = undefined; |
| 662 | 691 | lease(); |
| 663 | 692 | state.lastError = outcome.error; |
| 693 | state.lastErrorCommitOid = run.commitOid; | |
| 664 | 694 | const done = finishRun(this.app.db, run.id, { |
| 665 | 695 | state: "failed", |
| 666 | 696 | summary: outcome.error, |
| @@ -697,6 +727,7 @@ export class AppsRuntime { | ||
| 697 | 727 | }; |
| 698 | 728 | state.live = live; |
| 699 | 729 | state.lastError = undefined; |
| 730 | state.lastErrorCommitOid = undefined; | |
| 700 | 731 | if (previous !== undefined) { |
| 701 | 732 | write( |
| 702 | 733 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, |
src/web/pages/apps.ts +8 −4
| @@ -63,8 +63,8 @@ import { | ||
| 63 | 63 | empty, |
| 64 | 64 | envActions, |
| 65 | 65 | envFacts, |
| 66 | envPhrase, | |
| 66 | 67 | envStateBadge, |
| 67 | envTone, | |
| 68 | 68 | icon, |
| 69 | 69 | listCard, |
| 70 | 70 | row, |
| @@ -136,7 +136,7 @@ function appRow(entry: AppEntry): Html { | ||
| 136 | 136 | const name = `${entry.repo.ownerName}/${entry.repo.name}`; |
| 137 | 137 | return row({ |
| 138 | 138 | nav: true, |
| 139 | lead: dot(envTone(entry.production?.state)), | |
| 139 | lead: dot(envPhrase(entry.production).tone), | |
| 140 | 140 | title: html`<a href="${base}/app">${name}</a>`, |
| 141 | 141 | titleAttr: name, |
| 142 | 142 | meta: html`${entry.url === undefined |
| @@ -597,7 +597,9 @@ function envCard(base: string, env: EnvStatus, writer: boolean): Html { | ||
| 597 | 597 | extra: "env-card", |
| 598 | 598 | ...(env.state === "failed" || env.error !== undefined |
| 599 | 599 | ? { tone: "danger" as const } |
| 600 | : {}), | |
| 600 | : env.failedDeploy !== undefined | |
| 601 | ? { tone: "attn" as const } | |
| 602 | : {}), | |
| 601 | 603 | head: html`${icon(number === undefined ? "rocket" : "pull")} |
| 602 | 604 | <h2> |
| 603 | 605 | ${number === undefined |
| @@ -820,7 +822,9 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 820 | 822 | extra: "env-card", |
| 821 | 823 | ...(env.state === "failed" || env.error !== undefined |
| 822 | 824 | ? { tone: "danger" as const } |
| 823 | : {}), | |
| 825 | : env.failedDeploy !== undefined | |
| 826 | ? { tone: "attn" as const } | |
| 827 | : {}), | |
| 824 | 828 | }, |
| 825 | 829 | ); |
| 826 | 830 | } |
src/web/studio/previews.ts +13 −13
| @@ -12,7 +12,6 @@ import { envPull, previewEnv } from "../../apps/data.ts"; | ||
| 12 | 12 | import { |
| 13 | 13 | appsRuntime, |
| 14 | 14 | type AppsRuntime, |
| 15 | type EnvState, | |
| 16 | 15 | type EnvStatus, |
| 17 | 16 | } from "../../apps/runtime.ts"; |
| 18 | 17 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| @@ -67,16 +66,17 @@ function openAppBtn(base: string, env: EnvStatus | undefined): Html { | ||
| 67 | 66 | }); |
| 68 | 67 | } |
| 69 | 68 | |
| 70 | /** The three words the studio uses for a preview, whatever its check says. */ | |
| 71 | function previewBadge(state: EnvState | undefined): Html { | |
| 72 | if (state === "running") | |
| 73 | return badge("live", { tone: "success", icon: "play" }); | |
| 74 | if (state === "deploying") | |
| 75 | return badge("starting", { tone: "attn", icon: "loader" }); | |
| 76 | return badge("not up", { | |
| 77 | ...(state === "failed" ? { tone: "danger" as const } : {}), | |
| 78 | icon: "square", | |
| 79 | }); | |
| 69 | /** The studio's own words for a preview, whatever its check says — always | |
| 70 | * `envPhrase`'s, so this row never invents a second vocabulary. */ | |
| 71 | function previewBadge(env: EnvStatus | undefined): Html { | |
| 72 | const { word, tone } = envPhrase(env); | |
| 73 | const ic = | |
| 74 | env?.state === "running" | |
| 75 | ? "play" | |
| 76 | : env?.state === "deploying" | |
| 77 | ? "loader" | |
| 78 | : "square"; | |
| 79 | return badge(word, { tone, icon: ic }); | |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | /** |
| @@ -102,7 +102,7 @@ function previewRow( | ||
| 102 | 102 | const bits: Html[] = [html`#${pull.number} ${pull.title}`]; |
| 103 | 103 | if (env?.commitOid) bits.push(html`${env.commitOid.slice(0, 7)}`); |
| 104 | 104 | if (env?.since) bits.push(html`up ${time(env.since)}`); |
| 105 | const say = env?.error ?? env?.note; | |
| 105 | const say = env?.error ?? env?.failedDeploy?.error ?? env?.note; | |
| 106 | 106 | if (say) bits.push(html`${say}`); |
| 107 | 107 | return row({ |
| 108 | 108 | ...(href === undefined ? {} : { href, nav: true }), |
| @@ -112,7 +112,7 @@ function previewRow( | ||
| 112 | 112 | ? `#${pull.number} ${pull.title}` |
| 113 | 113 | : html`<span class="mono">${host}</span>`, |
| 114 | 114 | meta: bits.reduce((a, b) => html`${a} · ${b}`), |
| 115 | trail: previewBadge(env?.state), | |
| 115 | trail: previewBadge(env), | |
| 116 | 116 | }); |
| 117 | 117 | } |
| 118 | 118 |
src/web/studio/shelf.ts +13 −1
| @@ -131,8 +131,20 @@ export async function shelfRows( | ||
| 131 | 131 | ? undefined |
| 132 | 132 | : actors.get(latest.actorId)?.username; |
| 133 | 133 | const asks = asksByRepo.get(repo.id) ?? 0; |
| 134 | const tone = envTone(production?.state); | |
| 134 | // The badge is production's own word (`envPhrase`) — whether a newer | |
| 135 | // push failed behind what is serving is a fact of the environment, not | |
| 136 | // of the run or change history `happening` reads (#191's own word for | |
| 137 | // that stays put; this is the other half of the row, decided once here | |
| 138 | // rather than folded into a second vocabulary). The tone rides with it: | |
| 139 | // `envTone` only knows the runtime's raw state, which still reads | |
| 140 | // "success" while an older deploy answers for a push that failed behind | |
| 141 | // it — this row is not untroubled green just because `envTone` cannot | |
| 142 | // see that fact. | |
| 135 | 143 | const live = envPhrase(production); |
| 144 | const tone = | |
| 145 | production?.failedDeploy !== undefined | |
| 146 | ? "attn" | |
| 147 | : envTone(production?.state); | |
| 136 | 148 | const happening = |
| 137 | 149 | word === undefined |
| 138 | 150 | ? stuck.has(repo.id) |
src/web/ui.ts +15 −1
| @@ -322,6 +322,10 @@ export function envPhrase(env: EnvStatus | undefined): { | ||
| 322 | 322 | return { word: "not running", tone: "default" }; |
| 323 | 323 | if (env.state === "failed") return { word: "not up", tone: "danger" }; |
| 324 | 324 | if (env.state === "deploying") return { word: "starting", tone: "default" }; |
| 325 | // It answers, so it is not "not up" — but a newer push never came up | |
| 326 | // behind it, so it is not plain green either. | |
| 327 | if (env.failedDeploy !== undefined) | |
| 328 | return { word: "live · a newer push failed", tone: "attn" }; | |
| 325 | 329 | return { |
| 326 | 330 | word: env.error === undefined ? "live" : "serving · tests failed", |
| 327 | 331 | tone: "success", |
| @@ -373,7 +377,17 @@ export function envFacts( | ||
| 373 | 377 | </p>`, |
| 374 | 378 | )} |
| 375 | 379 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} |
| 376 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`; | |
| 380 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)} | |
| 381 | ${when( | |
| 382 | env.failedDeploy, | |
| 383 | // Two `sha`s, never nested inside the run link — a link inside a link | |
| 384 | // tears the paragraph apart, the same reason `userTag` gives a row. | |
| 385 | (fd) => html`<p class="env-error"> | |
| 386 | serving ${sha(env.commitOid, `${base}/commit/${env.commitOid}`)} — | |
| 387 | ${sha(fd.commitOid, `${base}/commit/${fd.commitOid}`)} did not come up: | |
| 388 | ${fd.error} (<a href="${base}/runs/${fd.runId}">its deploy</a>) | |
| 389 | </p>`, | |
| 390 | )}`; | |
| 377 | 391 | } |
| 378 | 392 | |
| 379 | 393 | /** |
test/apps.test.ts +46
| @@ -871,6 +871,52 @@ describe("apps: production, previews, data, gates, crew", () => { | ||
| 871 | 871 | ); |
| 872 | 872 | expect(openPreviews.length).toBe(1); // the bot's pull is the one open pull |
| 873 | 873 | }, 90_000); |
| 874 | ||
| 875 | test("a push that cannot boot leaves the old deployment serving and names the push that failed (issue #200)", async () => { | |
| 876 | const before = runtime.envStatus(record, "production"); | |
| 877 | expect(before?.state).toBe("running"); | |
| 878 | const servingOid = before!.commitOid; | |
| 879 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 880 | const original = | |
| 881 | (await sh(["git", "show", `${repo.defaultBranch}:forge.json`], dir)) | |
| 882 | .out + "\n"; | |
| 883 | ||
| 884 | const broken = await commitToBranch(app, alice, repo, { | |
| 885 | message: "Break forge.json", | |
| 886 | files: [{ path: "forge.json", content: "{ not json" }], | |
| 887 | }); | |
| 888 | expect(broken.ok).toBe(true); | |
| 889 | if (!broken.ok) return; | |
| 890 | const failed = await settled( | |
| 891 | (r) => r.env === "production" && r.commitOid === broken.value.commitOid, | |
| 892 | ); | |
| 893 | expect(failed.state).toBe("failed"); | |
| 894 | ||
| 895 | // The old deployment never moved: it still answers, at its own commit. | |
| 896 | const status = runtime.envStatus(record, "production"); | |
| 897 | expect(status?.state).toBe("running"); | |
| 898 | expect(status?.commitOid).toBe(servingOid); | |
| 899 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200); | |
| 900 | // But the failed push is named, not silently dropped. | |
| 901 | expect(status?.failedDeploy?.commitOid).toBe(broken.value.commitOid); | |
| 902 | expect(status?.failedDeploy?.runId).toBe(failed.id); | |
| 903 | expect(status?.failedDeploy?.error).toContain("forge.json"); | |
| 904 | ||
| 905 | // A later successful deploy clears it: no permanent furniture. | |
| 906 | const fixed = await commitToBranch(app, alice, repo, { | |
| 907 | message: "Fix forge.json", | |
| 908 | files: [{ path: "forge.json", content: original }], | |
| 909 | }); | |
| 910 | expect(fixed.ok).toBe(true); | |
| 911 | if (!fixed.ok) return; | |
| 912 | const ok = await settled( | |
| 913 | (r) => r.env === "production" && r.commitOid === fixed.value.commitOid, | |
| 914 | ); | |
| 915 | expect(ok.state).toBe("ok"); | |
| 916 | const cleared = runtime.envStatus(record, "production"); | |
| 917 | expect(cleared?.commitOid).toBe(fixed.value.commitOid); | |
| 918 | expect(cleared?.failedDeploy).toBeUndefined(); | |
| 919 | }, 90_000); | |
| 874 | 920 | }); |
| 875 | 921 | |
| 876 | 922 | describe("a preview whose copied data the build cannot read starts fresh (issue #36)", () => { |
test/studio.test.ts +57
| @@ -2162,6 +2162,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 2162 | 2162 | readonly deploying?: unknown; |
| 2163 | 2163 | readonly lastError?: string; |
| 2164 | 2164 | readonly lastRunId?: string; |
| 2165 | readonly lastErrorCommitOid?: string; | |
| 2165 | 2166 | } |
| 2166 | 2167 | const put = ( |
| 2167 | 2168 | slot: Omit<Slot, "app" | "env" | "chain" | "restarts">, |
| @@ -2319,6 +2320,62 @@ describe("the studio head says why the app is not up", () => { | ||
| 2319 | 2320 | expect(mine).toContain('href="/alice/notes/app/production/open"'); |
| 2320 | 2321 | expect(await shelfBadge(writer)).toBe("serving · tests failed"); |
| 2321 | 2322 | }); |
| 2323 | ||
| 2324 | test("a newer push that failed to come up while an older deploy keeps serving: live, not plain green, both commits named, its run linked", async () => { | |
| 2325 | const failed = createRun(app.db, { | |
| 2326 | repoId: repo.id, | |
| 2327 | kind: "deploy", | |
| 2328 | env: "production", | |
| 2329 | commitOid: "2".repeat(40), | |
| 2330 | reason: "push", | |
| 2331 | }); | |
| 2332 | claimRun(app.db, failed.id); | |
| 2333 | finishRun(app.db, failed.id, { | |
| 2334 | state: "failed", | |
| 2335 | summary: "forge.json: JSON Parse error: Unexpected token '}'", | |
| 2336 | }); | |
| 2337 | put({ | |
| 2338 | live: { | |
| 2339 | commitOid: "1".repeat(40), | |
| 2340 | handle: { port: 4321 }, | |
| 2341 | runId: deploy, | |
| 2342 | since: new Date(Date.now() - 60_000).toISOString(), | |
| 2343 | manifest: { tty: null }, | |
| 2344 | }, | |
| 2345 | lastError: "forge.json: JSON Parse error: Unexpected token '}'", | |
| 2346 | lastRunId: failed.id, | |
| 2347 | lastErrorCommitOid: "2".repeat(40), | |
| 2348 | }); | |
| 2349 | const mine = await head(writer); | |
| 2350 | // It answers — still "live", never "not up" — but not untroubled green. | |
| 2351 | expect(mine).toContain("live"); | |
| 2352 | expect(mine).not.toContain("not up"); | |
| 2353 | expect(mine).toContain('href="/alice/notes/app/production/open"'); | |
| 2354 | // Both commits, named. | |
| 2355 | expect(mine).toContain("1111111"); | |
| 2356 | expect(mine).toContain("2222222"); | |
| 2357 | expect(mine).toContain("did not come up"); | |
| 2358 | expect(mine).toContain( | |
| 2359 | "forge.json: JSON Parse error: Unexpected token '}'", | |
| 2360 | ); | |
| 2361 | // The failed run's log, one click away. | |
| 2362 | expect(mine).toContain(`href="/alice/notes/runs/${failed.id}"`); | |
| 2363 | }); | |
| 2364 | ||
| 2365 | test("a clean deploy with no failure behind it is unchanged: plain live, no failed-run link", async () => { | |
| 2366 | put({ | |
| 2367 | live: { | |
| 2368 | commitOid: "1".repeat(40), | |
| 2369 | handle: { port: 4321 }, | |
| 2370 | runId: deploy, | |
| 2371 | since: new Date(Date.now() - 60_000).toISOString(), | |
| 2372 | manifest: { tty: null }, | |
| 2373 | }, | |
| 2374 | }); | |
| 2375 | const mine = await head(writer); | |
| 2376 | expect(mine).toContain("live"); | |
| 2377 | expect(mine).not.toContain("did not come up"); | |
| 2378 | }); | |
| 2322 | 2379 | }); |
| 2323 | 2380 | |
| 2324 | 2381 | /** |
new above
offline