Public: anyone can read this; the crew can change it.

The changelog calls every merged pull live, because production has no FORGE_REPO #7736

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7734-the-changelog-calls-every-merged-pull-li → main 3 commits fork 5 threads · 5 unresolved

  • Merged. mason/7734-the-changelog-calls-every-merged-pull-li was merged into main as 817ab18.

Review

Sign in to review this change.
2 files changed +145 −5
src/release/landings.ts +45 −4
@@ -14,7 +14,7 @@
1414
1515 import type { Database } from "bun:sqlite";
1616 import type { App } from "../app.ts";
17 import { getRepo } from "../core/repos.ts";
17 import { getRepo, type Repo } from "../core/repos.ts";
1818 import { runGit } from "../git/env.ts";
1919 import { repoDir } from "../git/repos.ts";
2020 import { running } from "../kernel/version.ts";
@@ -85,6 +85,7 @@ export function keepLive(own: string, oids: Iterable<string>, at: number): void
8585 /** Forget it, so the alarm is silent until the next read. */
8686 export function forgetLive(): void {
8787 kept = undefined;
88 found.clear();
8889 }
8990
9091 function isLive(row: MergedRow, state: LiveState | undefined): boolean {
@@ -115,11 +116,51 @@ export function waitingSince(db: Database): string | undefined {
115116 return onItsWay.at(-1)?.at;
116117 }
117118
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 */
outdated

A cached "" here is permanent for this commit. Give it a TTL, or don't cache when rows is empty, and clear found in forgetLive.

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 if (repo.visibility !== "public") continue; here, as the doc comment promises.

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
118161 /** Ask git which of the forge's own merge commits the running commit contains. */
119162 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);
123164 if (repo === undefined) return;
124165 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
125166 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";
77 import { createToken } from "../src/core/tokens.ts";
88 import { createUser, type User } from "../src/core/users.ts";
99 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";
1113
1214 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
1315 if (!r.ok) throw new Error(JSON.stringify(r.error));
@@ -130,6 +132,103 @@ describe("changelog page", () => {
130132 expect(got["onItsWay"].map((l: { title: string }) => l.title)).toEqual(["Third thing"]);
131133 });
132134
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
133232 test("the footer links to it", async () => {
134233 const body = await (await fetch(`${servers.url}/changelog`)).text();
135234 expect(body).toContain('href="/changelog"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs