Public: anyone can read this; the crew can change it.
Self-deploy rollback, part 2: a releases page lists each kept release with sha, landed pull, when and healthy-since, and a writer can roll back from it #1867
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1824-self-deploy-rollback-part-2-a-releases-p → main 4 commits fork 6 threads · 6 unresolved
README.md +1 −1
Diff not shown — a line 4,012 characters wide (limit 2,000). +1 −1
View filescripts/gate.ts +1
| @@ -32,6 +32,7 @@ export const GATE: readonly string[] = [ | ||
| 32 | 32 | "test/lands-once.test.ts", |
| 33 | 33 | "test/conflicts-tui.test.ts", |
| 34 | 34 | "test/accounts-retire.test.ts", |
| 35 | "test/releases-page.test.ts", | |
| 35 | 36 | "test/client-home.test.ts", |
| 36 | 37 | "test/cloud-use.test.ts", |
| 37 | 38 | "test/accounts.test.ts", |
src/core/reserved.ts +1
| @@ -95,6 +95,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 95 | 95 | "signin", |
| 96 | 96 | "signup", |
| 97 | 97 | "sitemap.xml", |
| 98 | "releases", | |
| 98 | 99 | "releases.json", |
| 99 | 100 | "sw.js", |
| 100 | 101 | "tell", |
src/http/server.ts +3
| @@ -125,6 +125,7 @@ import { | ||
| 125 | 125 | backupsPage, |
| 126 | 126 | backupsTest, |
| 127 | 127 | } from "../web/pages/backups.ts"; |
| 128 | import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; | |
| 128 | 129 | import { |
| 129 | 130 | cloudAdd, |
| 130 | 131 | cloudAudit, |
| @@ -633,6 +634,8 @@ export function buildRouter(): Router<Handler> { | ||
| 633 | 634 | router.add("GET", "/-/confirm", confirmPage); |
| 634 | 635 | router.add("GET", "/new", newRepoPage); |
| 635 | 636 | router.add("GET", "/audit", auditPage); |
| 637 | router.add("GET", "/releases", releasesPage); | |
| 638 | router.add("POST", "/releases/:sha/rollback", rollbackSubmit); | |
| 636 | 639 | router.add("GET", "/audit.csv", auditCsv); |
| 637 | 640 | router.add("POST", "/audit/:owner/:name/probe", auditProbeSubmit); |
| 638 | 641 | router.add("POST", "/new", newRepoSubmit); |
src/web/pages/releases.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | /** | |
| 2 | * Releases: each kept release of the forge's own code with its short sha, the | |
| 3 | * pull that landed it, when, and — for the one serving — since when it has | |
| 4 | * been healthy. A writer of the forge's repository gets a Roll back button on | |
| 5 | * a kept release whose schema is not behind the live database's; the POST | |
| 6 | * goes through the same refusals and the same health-read swap as | |
| 7 | * `--rollback`, then this process drains. | |
| 8 | */ | |
| 9 | ||
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { canRead, canWrite, getRepo, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 12 | import type { User } from "../../core/users.ts"; | |
| 13 | import { running } from "../../kernel/version.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { listReleases, type Release } from "../../release/record.ts"; | |
| 16 | import { releaseDir } from "../../release/checkout.ts"; | |
| 17 | import { liveSchema, releaseSchema, rollback } from "../../release/rollback.ts"; | |
| 18 | import { beginSwap, endSwap, handover } from "../../release/swap.ts"; | |
| 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 20 | import { readForm } from "../form.ts"; | |
| 21 | import { html, when, type Html } from "../html.ts"; | |
| 22 | import { flashTo, page } from "../layout.ts"; | |
| 23 | import { badge, btn, card, empty, sha, time } from "../ui.ts"; | |
| 24 | ||
| 25 | /** When this process began serving: "healthy since" for the release it runs. */ | |
| 26 | const BOOTED = new Date().toISOString(); | |
| 27 | ||
| 28 | /** The forge's own code repository, when this forge names one. */ | |
| 29 | function forgeRepoOf(ctx: Ctx): Repo | undefined { | |
| 30 | const named = ctx.app.config.forgeRepo; | |
| 31 | if (named === undefined) return undefined; | |
| 32 | const [owner, name] = named.split("/"); | |
| 33 | return owner === undefined || name === undefined ? undefined : getRepo(ctx.app.db, owner, name); | |
| 34 | } | |
| 35 | ||
| 36 | /** Who may see the release history: the forge repository's readers and a site admin. */ | |
| 37 | function mayRead(ctx: Ctx, user: User | undefined): boolean { | |
| 38 | if (user === undefined) return false; | |
| 39 | if (user.isAdmin) return true; | |
| 40 | const repo = forgeRepoOf(ctx); | |
| 41 | return repo !== undefined && canRead(ctx.app.db, repo, user); | |
| 42 | } | |
| 43 | ||
| 44 | function mayRollBack(ctx: Ctx, user: User | undefined): boolean { | |
High: an agent that writes FORGE_REPO passes this check, so a crew token can roll back production and drain the forge. Refuse |
||
| 45 | // Undoing a deployment is a person's decision, as a revert is (#1824). | |
| 46 | if (user === undefined || user.kind === "agent") return false; | |
Good. This matches the person-only rule in |
||
| 47 | const repo = forgeRepoOf(ctx); | |
| 48 | return repo !== undefined && canWrite(ctx.app.db, repo, user); | |
| 49 | } | |
| 50 | ||
| 51 | /** "live: <sha> since <time>" for the home, or nothing when no release is known to be live. */ | |
| 52 | export function liveLine(ctx: Ctx): Html | undefined { | |
| 53 | const live = running().commit; | |
| 54 | if (live === null || !mayRead(ctx, ctx.auth.user)) return undefined; | |
| 55 | return html`<p class="small muted" id="live-release"> | |
| 56 | <a href="/releases">live: <span class="sha">${live.slice(0, 7)}</span> since ${time(BOOTED)}</a> | |
| 57 | </p>`; | |
| 58 | } | |
| 59 | ||
| 60 | async function canRollBackTo(ctx: Ctx, r: Release): Promise<boolean> { | |
| 61 | const dataDir = ctx.app.config.dataDir; | |
| 62 | const dir = releaseDir(dataDir, r.sha); | |
| 63 | if (!r.ok || !/^[0-9a-f]{40}$/.test(r.sha) || !existsSync(dir)) return false; | |
| 64 | const known = await releaseSchema(dir); | |
| 65 | return known !== undefined && known >= liveSchema(dataDir); | |
| 66 | } | |
| 67 | ||
| 68 | function pullCell(ctx: Ctx, r: Release): Html { | |
| 69 | if (r.pullNumber === null) return html`—`; | |
outdated
High: this only checks sign-in. Gate on |
||
| 70 | const repo = getRepoById(ctx.app.db, r.repoId); | |
| 71 | return repo === undefined | |
| 72 | ? html`#${r.pullNumber}` | |
| 73 | : html`<a href="/${repo.ownerName}/${repo.name}/pulls/${r.pullNumber}">#${r.pullNumber}</a>`; | |
| 74 | } | |
| 75 | ||
| 76 | export async function releasesPage(ctx: Ctx): Promise<Response> { | |
| 77 | const user = ctx.auth.user; | |
| 78 | if (user === undefined) return signInFirst(ctx); | |
| 79 | if (!mayRead(ctx, user)) return notFoundPage(ctx); | |
| 80 | const releases = listReleases(ctx.app.db); | |
| 81 | const live = running().commit; | |
| 82 | const writer = mayRollBack(ctx, user); | |
| 83 | const rows: Html[] = []; | |
| 84 | for (const r of releases) { | |
| 85 | const isLive = live !== null && r.sha === live; | |
| 86 | const button = writer && !isLive && (await canRollBackTo(ctx, r)); | |
| 87 | rows.push(html`<tr> | |
| 88 | <td>${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))} | |
| 89 | ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}</td> | |
| 90 | <td>${pullCell(ctx, r)}</td> | |
| 91 | <td>${time(r.createdAt)}</td> | |
| 92 | <td>${isLive ? html`since ${time(BOOTED)}` : html`—`}</td> | |
| 93 | <td>${when(button, () => | |
| 94 | html`<form method="post" action="/releases/${r.sha}/rollback">${btn("Roll back", { type: "submit" })}</form>`, | |
| 95 | )}</td> | |
| 96 | </tr>`); | |
| 97 | } | |
| 98 | const body = | |
| 99 | rows.length === 0 | |
| 100 | ? empty({ icon: "clock", title: "No releases yet", note: "A pull landed on the forge's own repository is recorded here." }) | |
| 101 | : card(html`<table class="table"> | |
| 102 | <thead><tr><th>Release</th><th>Pull</th><th>Landed</th><th>Healthy</th><th></th></tr></thead> | |
| 103 | <tbody>${rows}</tbody> | |
| 104 | </table>`); | |
| 105 | return page({ | |
| 106 | ctx, | |
| 107 | title: "Releases", | |
| 108 | body: html`<div class="page-head"><h1>Releases</h1></div>${body}`, | |
| 109 | }); | |
| 110 | } | |
| 111 | ||
| 112 | export async function rollbackSubmit(ctx: Ctx): Promise<Response> { | |
| 113 | const user = ctx.auth.user; | |
| 114 | if (user === undefined) return new Response("forbidden", { status: 403 }); | |
| 115 | const form = await readForm(ctx, "rw"); | |
| 116 | if (form instanceof Response) return form; | |
outdated
This is the only authorisation check before |
||
| 117 | if (!mayRollBack(ctx, user)) return new Response("forbidden", { status: 403 }); | |
| 118 | const target = ctx.params["sha"] ?? ""; | |
| 119 | const refuse = (message: string): Response => | |
| 120 | flashTo(ctx, "/releases", { tone: "danger", message }); | |
| 121 | if (!/^[0-9a-f]{40}$/.test(target)) return notFoundPage(ctx); | |
| 122 | const drain = handover.drain; | |
| 123 | const port = ctx.app.config.port ?? 0; | |
| 124 | if (ctx.app.config.selfSwap !== true || drain === undefined || port <= 0) | |
| 125 | return refuse("This forge does not swap releases itself (FORGE_SELF_SWAP is off)."); | |
| 126 | const turn = beginSwap(); | |
| 127 | if (!turn.ok) return refuse(`Not rolled back: ${turn.line}.`); | |
| 128 | const result = await rollback({ dataDir: ctx.app.config.dataDir, sha: target, port, env: process.env }); | |
| 129 | endSwap(result.ok); | |
| 130 | if (!result.ok) return refuse(`Not rolled back: ${result.line}.`); | |
| 131 | ctx.app.log.info("rolled back; draining this process", { sha: target.slice(0, 12), by: user.username }); | |
| 132 | drain(); | |
| 133 | return flashTo(ctx, "/releases", { message: `Rolled back to ${target.slice(0, 7)}; it reads healthy.` }); | |
| 134 | } |
src/web/studio/home.ts +2 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { liveLine } from "../pages/releases.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | frontPage, |
| 11 | 12 | frontPicksJson, |
| @@ -214,7 +215,7 @@ export async function studioHome( | ||
| 214 | 215 | }); |
| 215 | 216 | } |
| 216 | 217 | const body = html`<h1 class="sr-only">Home</h1> |
| 217 | ${firstSteps ?? everydayCard(ctx, user)} ${address} | |
| 218 | ${firstSteps ?? everydayCard(ctx, user)} ${address} ${liveLine(ctx)} | |
| 218 | 219 | ${when( |
| 219 | 220 | steps, |
| 220 | 221 | () => html`<p class="small muted" id="lean-home"> |
test/releases-page.test.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * The Releases page (#1824): it lists each kept release, escaped, with the | |
| 3 | * live one marked and the pull linked; a writer of the forge's repository | |
| 4 | * gets a Roll back button on a kept, schema-compatible release and anyone | |
| 5 | * else gets none and a 403; a cross-origin POST is refused; the home links | |
| 6 | * the live release. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { releaseDir } from "../src/release/checkout.ts"; | |
| 18 | import { recordRelease } from "../src/release/record.ts"; | |
| 19 | import { proveAll } from "./helpers/verified.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const LIVE = "a".repeat(40); | |
| 25 | const KEPT = "b".repeat(40); | |
| 26 | const PRUNED = "c".repeat(40); | |
| 27 | ||
| 28 | describe("the releases page", () => { | |
| 29 | let root = ""; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | const was = process.env["FORGE_COMMIT"]; | |
| 33 | ||
| 34 | const login = async (username: string, password: string) => { | |
| 35 | const r = await fetch(`${servers.url}/signin`, { | |
| 36 | method: "POST", | |
| 37 | headers: { origin: servers.url }, | |
| 38 | body: new URLSearchParams({ username, password }), | |
| 39 | redirect: "manual", | |
| 40 | }); | |
| 41 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 42 | }; | |
| 43 | const post = (path: string, cookie: string, origin: string) => | |
| 44 | fetch(`${servers.url}${path}`, { | |
| 45 | method: "POST", | |
| 46 | headers: { cookie, origin }, | |
| 47 | redirect: "manual", | |
| 48 | }); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | process.env["FORGE_COMMIT"] = LIVE; | |
| 52 | root = mkdtempSync(join(tmpdir(), "forge-releases-")); | |
| 53 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 54 | for (const [username, password, adminIfFirst] of [ | |
| 55 | ["alice", "a-good-passphrase", true], | |
| 56 | ["bob", "another-good-passphrase", false], | |
| 57 | ] as const) { | |
| 58 | const u = await createUser(app.db, { | |
| 59 | trusted: true, | |
| 60 | username, | |
| 61 | email: `${username}@forge.local`, | |
| 62 | password, | |
| 63 | adminIfFirst, | |
| 64 | }); | |
| 65 | if (!u.ok) throw new Error(u.error); | |
| 66 | } | |
| 67 | proveAll(app); | |
| 68 | const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username='alice'").get()!; | |
| 69 | const made = await createRepo(app, { id: alice.id, username: "alice" } as never, { name: "forge" }); | |
| 70 | if (!made.ok) throw new Error(made.error); | |
| 71 | const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='forge'").get()!.id; | |
| 72 | for (const [s, pull, line] of [ | |
| 73 | [LIVE, 7, "ok"], | |
| 74 | [KEPT, 6, "<script>alert(1)</script>"], | |
| 75 | [PRUNED, null, "ok"], | |
| 76 | ] as const) | |
| 77 | recordRelease(app.db, { sha: s, repoId, pullNumber: pull, ok: true, line }); | |
| 78 | const dir = releaseDir(root, KEPT); | |
| 79 | mkdirSync(join(dir, "src", "core"), { recursive: true }); | |
| 80 | writeFileSync(join(dir, "src", "core", "schema.ts"), "export const migrations = " + JSON.stringify(Array(10_000).fill("SELECT 1")) + ";\n"); | |
| 81 | servers = await serve(app); | |
| 82 | }); | |
| 83 | afterAll(() => { | |
| 84 | if (was === undefined) delete process.env["FORGE_COMMIT"]; | |
| 85 | else process.env["FORGE_COMMIT"] = was; | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | rmSync(root, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("it lists the releases, marks the live one and links the pull", async () => { | |
| 92 | const cookie = await login("alice", "a-good-passphrase"); | |
| 93 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text(); | |
| 94 | expect(body).toContain("aaaaaaa"); | |
| 95 | expect(body).toContain("bbbbbbb"); | |
| 96 | expect(body).toContain("live"); | |
| 97 | expect(body).toContain('href="/alice/forge/pulls/7"'); | |
| 98 | expect(body).toContain('href="/alice/forge/pulls/6"'); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a writer gets a button on a kept release, not on the live or a pruned one", async () => { | |
| 102 | const cookie = await login("alice", "a-good-passphrase"); | |
| 103 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text(); | |
| 104 | expect(body).toContain(`/releases/${KEPT}/rollback`); | |
| 105 | expect(body).not.toContain(`/releases/${LIVE}/rollback`); | |
| 106 | expect(body).not.toContain(`/releases/${PRUNED}/rollback`); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a non-reader gets a 404 and no live line; a reader who cannot write sees the list with no button", async () => { | |
| 110 | const cookie = await login("bob", "another-good-passphrase"); | |
outdated
This asserts the leak: a non-writer (and non-reader) is shown the release list. Expect a 404 for a non-reader, and add a reader-but-not-writer case. |
||
| 111 | const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 112 | expect((await get("/releases")).status).toBe(404); | |
| 113 | const home = await (await get("/")).text(); | |
| 114 | expect(home).not.toContain("live-release"); | |
| 115 | expect(home).not.toContain("aaaaaaa"); | |
| 116 | expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403); | |
| 117 | ||
| 118 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); | |
| 119 | const body = await (await get("/releases")).text(); | |
| 120 | expect(body).toContain("bbbbbbb"); | |
| 121 | expect(body).not.toContain("/rollback"); | |
| 122 | expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403); | |
| 123 | expect((await post(`/releases/${KEPT}/rollback`, "", servers.url)).status).toBe(403); | |
| 124 | expect(await (await get("/")).text()).toContain("live-release"); | |
| 125 | app.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'forge'").run(); | |
outdated
A successful rollback also redirects to /releases, so this would pass either way. Assert the refusal flash or that no swap started. |
||
| 126 | }); | |
| 127 | ||
| 128 | test("an agent that can write the repository gets no button and a 403", async () => { | |
| 129 | const u = await createUser(app.db, { | |
| 130 | trusted: true, | |
| 131 | username: "crew1", | |
| 132 | email: "[email protected]", | |
| 133 | password: "crew-good-passphrase", | |
| 134 | adminIfFirst: false, | |
| 135 | }); | |
| 136 | if (!u.ok) throw new Error(u.error); | |
| 137 | app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'crew1'").run(); | |
| 138 | const cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 139 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text(); | |
| 140 | expect(body).toContain("bbbbbbb"); | |
| 141 | expect(body).not.toContain("/rollback"); | |
| 142 | expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a cross-origin POST is refused", async () => { | |
| 146 | const cookie = await login("alice", "a-good-passphrase"); | |
| 147 | expect((await post(`/releases/${KEPT}/rollback`, cookie, "https://evil.example")).status).toBe(403); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a writer's POST with swapping off is told so and nothing swaps", async () => { | |
| 151 | const cookie = await login("alice", "a-good-passphrase"); | |
| 152 | const res = await post(`/releases/${KEPT}/rollback`, cookie, servers.url); | |
| 153 | expect(res.status).toBe(303); | |
| 154 | expect(res.headers.get("location")).toBe("/releases"); | |
| 155 | const flash = (res.headers.getSetCookie() ?? []).map((c) => c.split(";")[0]).join("; "); | |
| 156 | const shown = await (await fetch(`${servers.url}/releases`, { headers: { cookie: `${cookie}; ${flash}` } })).text(); | |
| 157 | expect(shown).toContain("FORGE_SELF_SWAP is off"); | |
| 158 | }); | |
| 159 | ||
| 160 | test("the home links the live release", async () => { | |
| 161 | const cookie = await login("alice", "a-good-passphrase"); | |
| 162 | const body = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 163 | expect(body).toContain('href="/releases"'); | |
| 164 | expect(body).toContain("live:"); | |
| 165 | expect(body).toContain("aaaaaaa"); | |
| 166 | }); | |
| 167 | ||
| 168 | test("a signed-out visitor is sent to sign in", async () => { | |
| 169 | const res = await fetch(`${servers.url}/releases`, { redirect: "manual" }); | |
| 170 | expect(res.status).toBe(303); | |
| 171 | }); | |
| 172 | }); |
The live commit goes on every signed-in home. Apply the same reader check before rendering it.