Public: anyone can read this; the crew can change it.
The changelog calls every merged pull live, because production has no FORGE_REPO #7736
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7734-the-changelog-calls-every-merged-pull-li → main 3 commits fork 5 threads · 5 unresolved
2 files
changed +145
−5
src/release/landings.ts +45 −4
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import type { Database } from "bun:sqlite"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | import { getRepo } from "../core/repos.ts"; | |
| 17 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 18 | 18 | import { runGit } from "../git/env.ts"; |
| 19 | 19 | import { repoDir } from "../git/repos.ts"; |
| 20 | 20 | import { running } from "../kernel/version.ts"; |
| @@ -85,6 +85,7 @@ export function keepLive(own: string, oids: Iterable<string>, at: number): void | ||
| 85 | 85 | /** Forget it, so the alarm is silent until the next read. */ |
| 86 | 86 | export function forgetLive(): void { |
| 87 | 87 | kept = undefined; |
| 88 | found.clear(); | |
| 88 | 89 | } |
| 89 | 90 | |
| 90 | 91 | function isLive(row: MergedRow, state: LiveState | undefined): boolean { |
| @@ -115,11 +116,51 @@ export function waitingSince(db: Database): string | undefined { | ||
| 115 | 116 | return onItsWay.at(-1)?.at; |
| 116 | 117 | } |
| 117 | 118 | |
| 119 | /** How long a miss is believed: a repository can turn public, or merge its first pull, after boot. */ | |
| 120 | const MISS_MS = 60_000; | |
| 121 | ||
| 122 | /** The repository the running commit was built from, per running commit; "" when none held it at `at`. */ | |
| 123 | const found = new Map<string, { repo: string; at: number }>(); | |
| 124 | ||
| 125 | /** | |
| 126 | * The forge's own repository: the one `FORGE_REPO` names or, where a deploy | |
| 127 | * names none, the public repository with merged pulls whose history holds the | |
| 128 | * running commit. When several do (a public fork), the one with the most | |
| 129 | * merged pulls wins, then the first by name. Read-only: nothing here changes | |
| 130 | * how releases cut. | |
| 131 | */ | |
| 132 | async function ownRepo(app: App, rows: readonly MergedRow[], now = Date.now()): Promise<Repo | undefined> { | |
| 133 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); | |
| 134 | if (owner !== "" && name !== "") return getRepo(app.db, owner, name); | |
| 135 | const commit = running().commit; | |
| 136 | if (commit === null) return undefined; | |
outdated
With rows ordered newest-merged first, a fork holding the same commit can win. Make the choice deterministic and test it. |
||
| 137 | const known = found.get(commit); | |
| 138 | if (known?.repo === "" && now - known.at < MISS_MS) return undefined; | |
| 139 | for (const named of known?.repo ? [known.repo] : candidates(rows)) { | |
outdated
No visibility check: add |
||
| 140 | const [o = "", n = ""] = named.split("/"); | |
| 141 | const repo = getRepo(app.db, o, n); | |
outdated
Failed lookups are not remembered; cache 'none' per commit so a miss does not spawn git on every request. |
||
| 142 | if (repo === undefined || repo.visibility !== "public") continue; | |
| 143 | const has = await runGit(["cat-file", "-e", `${commit}^{commit}`], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) }); | |
| 144 | if (!has.ok) continue; | |
| 145 | found.set(commit, { repo: named, at: now }); | |
| 146 | return repo; | |
| 147 | } | |
| 148 | // A miss is kept a minute so requests cost no git; with no merged pulls yet there is nothing to remember. | |
| 149 | if (rows.length > 0) found.set(commit, { repo: "", at: now }); | |
| 150 | else found.delete(commit); | |
| 151 | return undefined; | |
| 152 | } | |
| 153 | ||
| 154 | /** The repositories of `rows`, most merged pulls first, then by name. */ | |
| 155 | export function candidates(rows: readonly MergedRow[]): string[] { | |
| 156 | const count = new Map<string, number>(); | |
| 157 | for (const r of rows) count.set(r.repo, (count.get(r.repo) ?? 0) + 1); | |
| 158 | return [...count].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0)).map(([r]) => r); | |
| 159 | } | |
| 160 | ||
| 118 | 161 | /** Ask git which of the forge's own merge commits the running commit contains. */ |
| 119 | 162 | async function askLive(app: App, rows: readonly MergedRow[]): Promise<void> { |
| 120 | const own = app.config.forgeRepo ?? ""; | |
| 121 | const [owner = "", name = ""] = own.split("/"); | |
| 122 | const repo = owner === "" || name === "" ? undefined : getRepo(app.db, owner, name); | |
| 163 | const repo = await ownRepo(app, rows); | |
| 123 | 164 | if (repo === undefined) return; |
| 124 | 165 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 125 | 166 | const head = running().commit ?? (await runGit(["rev-parse", "--verify", "-q", `refs/heads/${repo.defaultBranch}`], { cwd: dir })); |
test/changelog.test.ts +100 −1
| @@ -7,7 +7,9 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 7 | 7 | import { createToken } from "../src/core/tokens.ts"; |
| 8 | 8 | import { createUser, type User } from "../src/core/users.ts"; |
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | import { forgetLive } from "../src/release/landings.ts"; | |
| 10 | import { runGit } from "../src/git/env.ts"; | |
| 11 | import { repoDir } from "../src/git/repos.ts"; | |
| 12 | import { candidates, forgetLive } from "../src/release/landings.ts"; | |
| 11 | 13 | |
| 12 | 14 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { |
| 13 | 15 | if (!r.ok) throw new Error(JSON.stringify(r.error)); |
| @@ -130,6 +132,103 @@ describe("changelog page", () => { | ||
| 130 | 132 | expect(got["onItsWay"].map((l: { title: string }) => l.title)).toEqual(["Third thing"]); |
| 131 | 133 | }); |
| 132 | 134 | |
| 135 | test("with no FORGE_REPO the repository holding the running commit is the forge's own", async () => { | |
| 136 | delete app.config.forgeRepo; | |
| 137 | forgetLive(); | |
| 138 | try { | |
| 139 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 140 | expect(body.indexOf("On its way")).toBeLessThan(body.indexOf("Third thing")); | |
| 141 | expect(body.indexOf("Third thing")).toBeLessThan(body.indexOf("Second thing")); | |
| 142 | expect(body).not.toContain("A note app change"); | |
| 143 | const got = await mcp({ shipped: true }); | |
| 144 | expect(got["onItsWay"].map((l: { title: string }) => l.title)).toEqual(["Third thing"]); | |
| 145 | // Production serves the third commit: it moves to live. | |
| 146 | process.env["FORGE_COMMIT"] = oids[2]!; | |
| 147 | const later = await mcp({ shipped: true }); | |
| 148 | expect(later["onItsWay"]).toEqual([]); | |
| 149 | expect(later["live"].map((l: { title: string }) => l.title)).toContain("Third thing"); | |
| 150 | } finally { | |
| 151 | process.env["FORGE_COMMIT"] = oids[1]!; | |
| 152 | app.config.forgeRepo = "alice/forge"; | |
| 153 | forgetLive(); | |
| 154 | } | |
| 155 | }); | |
| 156 | ||
| 157 | test("a private repository holding the running commit is never the forge's own", async () => { | |
| 158 | delete app.config.forgeRepo; | |
| 159 | forgetLive(); | |
| 160 | app.db.run("UPDATE repo SET visibility = 'private' WHERE name = 'forge'"); | |
| 161 | process.env["FORGE_COMMIT"] = oids[0]!; | |
| 162 | try { | |
| 163 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 164 | expect(body).not.toContain("Second thing"); | |
| 165 | } finally { | |
| 166 | app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'"); | |
| 167 | process.env["FORGE_COMMIT"] = oids[1]!; | |
| 168 | app.config.forgeRepo = "alice/forge"; | |
| 169 | forgetLive(); | |
| 170 | } | |
| 171 | }); | |
| 172 | ||
| 173 | test("with several public repositories, the one holding the running commit is the forge's own", async () => { | |
| 174 | const fork = must(await createRepo(app, alice, { name: "fork", visibility: "public" })); | |
| 175 | const c = must(await commitToBranch(app, alice, fork, { message: "fork work", files: [{ path: "x.txt", content: "x\n" }] })); | |
| 176 | const day = new Date().toISOString().slice(0, 10); | |
| 177 | merge("fork", "Fork change", `${day}T23:00:00.000Z`, c.commitOid); | |
| 178 | delete app.config.forgeRepo; | |
| 179 | forgetLive(); | |
| 180 | process.env["FORGE_COMMIT"] = c.commitOid; | |
| 181 | try { | |
| 182 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 183 | expect(body).toContain("Fork change"); | |
| 184 | expect(body).not.toContain("Second thing"); | |
| 185 | } finally { | |
| 186 | process.env["FORGE_COMMIT"] = oids[1]!; | |
| 187 | app.config.forgeRepo = "alice/forge"; | |
| 188 | forgetLive(); | |
| 189 | } | |
| 190 | }); | |
| 191 | ||
| 192 | test("when a fork holds the running commit too, the repository with the most merged pulls wins", async () => { | |
| 193 | // The fork takes the forge's history; its own pull is the newest merged. | |
| 194 | const fetched = await runGit(["fetch", "-q", repoDir(app.reposRoot, "alice", "forge"), "+refs/heads/main:refs/heads/upstream"], { | |
| 195 | cwd: repoDir(app.reposRoot, "alice", "fork"), | |
| 196 | }); | |
| 197 | expect(fetched.ok).toBe(true); | |
| 198 | delete app.config.forgeRepo; | |
| 199 | forgetLive(); | |
| 200 | try { | |
| 201 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 202 | expect(body.indexOf("On its way")).toBeLessThan(body.indexOf("Third thing")); | |
| 203 | expect(body).not.toContain("Fork change"); | |
| 204 | } finally { | |
| 205 | app.config.forgeRepo = "alice/forge"; | |
| 206 | app.db.run("DELETE FROM issue WHERE title = 'Fork change'"); | |
| 207 | forgetLive(); | |
| 208 | } | |
| 209 | }); | |
| 210 | ||
| 211 | test("a miss with no merged pulls yet is not remembered", async () => { | |
| 212 | delete app.config.forgeRepo; | |
| 213 | forgetLive(); | |
| 214 | app.db.run("UPDATE repo SET visibility = 'private'"); | |
| 215 | try { | |
| 216 | expect(await (await fetch(`${servers.url}/changelog`)).text()).toContain("Nothing has shipped yet"); | |
| 217 | app.db.run("UPDATE repo SET visibility = 'public'"); | |
| 218 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 219 | expect(body.indexOf("On its way")).toBeLessThan(body.indexOf("Third thing")); | |
| 220 | } finally { | |
| 221 | app.db.run("UPDATE repo SET visibility = 'public'"); | |
| 222 | app.config.forgeRepo = "alice/forge"; | |
| 223 | forgetLive(); | |
| 224 | } | |
| 225 | }); | |
| 226 | ||
| 227 | test("candidates: most merged pulls first, then by name", () => { | |
| 228 | const r = (repo: string) => ({ repo, number: 1, title: "", at: "", oid: "" }); | |
| 229 | expect(candidates([r("b/fork"), r("a/forge"), r("a/forge"), r("c/x"), r("a/y")])).toEqual(["a/forge", "a/y", "b/fork", "c/x"]); | |
| 230 | }); | |
| 231 | ||
| 133 | 232 | test("the footer links to it", async () => { |
| 134 | 233 | const body = await (await fetch(`${servers.url}/changelog`)).text(); |
| 135 | 234 | expect(body).toContain('href="/changelog"'); |
new above
offline
A cached "" here is permanent for this commit. Give it a TTL, or don't cache when
rowsis empty, and clearfoundin forgetLive.