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

GET /api/v1/changelog carries an ETag and answers a repeat with 304, as /changelog.atom already does #9123

Merged opened this

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

Merged

mason/forge:mason/9112-get-api-v1-changelog-carries-an-etag-and → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9112-get-api-v1-changelog-carries-an-etag-and was merged into main as b2bf4fe.

Review

Sign in to review this change.
4 files changed +113 −3
src/contract/openapi/routes/getApiV1Changelog.ts +2 −1
@@ -3,7 +3,8 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 path: "/api/v1/changelog",
6 summary: "What went live and what is on its way, as /changelog shows it, for anyone: version, commit, live and onItsWay rows (repo, number, title, at) of public repositories; ?repo=all|owner/name, this forge's own by default (all when it has none)",
6 summary: "What went live and what is on its way, as /changelog shows it, for anyone: version, commit, live and onItsWay rows (repo, number, title, at) of public repositories; ?repo=all|owner/name, this forge's own by default (all when it has none); carries a weak ETag of the live version, the chosen repo and the rows, and a repeat sending If-None-Match gets 304 with no body and the same ETag and cache-control",
7 alsoStatus: [304],
78 auth: false,
89 query: { repo: "string" },
910 refusals: {},
src/web/feeds.ts +16 −1
@@ -194,6 +194,21 @@ ${entries.join("\n")}
194194
195195 const CHANGELOG_ENTRIES = 50;
196196
197 /**
198 * The weak tag of a changelog answer: the live version, the rows' repo,
199 * number, title and time, and `scope` (the chosen repo, the viewer when the
200 * answer depends on who asks). The Atom feed and the JSON share it, so the
201 * two cannot drift; with no scope the tag is the Atom's own, unchanged.
202 */
203 export function changelogTag(
204 version: string,
205 rows: readonly { repo: string; number: number; title: string; at: string }[],
206 scope: readonly unknown[] = [],
207 ): string {
208 const key = [version, rows.map((l) => [l.repo, l.number, l.title, l.at]), ...scope];
209 return `W/"${Bun.hash(JSON.stringify(key)).toString(36)}"`;
210 }
211
197212 /**
198213 * `GET /changelog.atom`: what went live, as /changelog lists it (the forge's
199214 * own repository, public pulls only). Landed-but-not-shipped is left out.
@@ -203,7 +218,7 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> {
203218 const answer = await timePhaseAsync(ctx.request, "releases", () => shippedAnswer(ctx.app));
204219 const origin = ctx.url.origin;
205220 const live = answer.live.slice(0, CHANGELOG_ENTRIES);
206 const etag = `W/"${Bun.hash(JSON.stringify([answer.version, live.map((l) => [l.repo, l.number, l.title, l.at])])).toString(36)}"`;
221 const etag = changelogTag(answer.version, live);
207222 const headers = { etag, "cache-control": "public, max-age=300" };
208223 if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
209224 const entries = live.map((l) => {
src/web/pages/changelog.ts +8 −1
@@ -16,6 +16,7 @@ import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1616 import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts";
1717 import { liveRegionsJs } from "../client.ts";
1818 import { pageHead } from "../chrome.ts";
19 import { changelogTag, holds } from "../feeds.ts";
1920 import { json } from "../../http/respond.ts";
2021 import { shippedAnswer } from "../../release/answer.ts";
2122 import { html, type Html } from "../html.ts";
@@ -176,7 +177,13 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
176177 // GET /api/v1/changelog?repo=all|owner/name: the page's rows as JSON, public repositories only.
177178 export async function changelogJson(ctx: Ctx): Promise<Response> {
178179 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
179 return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo")), 200, { "cache-control": "public, max-age=5" });
180 const asked = ctx.url.searchParams.get("repo");
181 const answer = await shippedAnswer(ctx.app, asked);
182 // The answer is the same for every viewer (public repositories only), so a shared cache may keep it.
183 const etag = changelogTag(answer.version, [...answer.live, ...answer.onItsWay], [asked ?? "", answer.live.length]);
184 const headers = { etag, "cache-control": "public, max-age=5" };
185 if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
186 return json(answer, 200, headers);
180187 }
181188
182189 /**
test/changelog-etag.test.ts added +87
@@ -0,0 +1,87 @@
1 /** `GET /api/v1/changelog` carries a weak ETag and answers a repeat with 304, as the Atom feed does. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { forgetLive, keepLive } from "../src/release/landings.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 describe("GET /api/v1/changelog ETag", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 const saved = process.env["FORGE_COMMIT"];
17 const get = (query = "", headers: Record<string, string> = {}) => fetch(`${servers.url}/api/v1/changelog${query}`, { headers });
18
19 beforeAll(async () => {
20 process.env["FORGE_COMMIT"] = "a".repeat(40);
21 dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-etag-"));
22 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
23 servers = await serve(app);
24 const u = await createUser(app.db, {
25 origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true,
26 });
27 if (!u.ok) throw new Error(u.error);
28 const made = await createRepo(app, u.value, { name: "forge", visibility: "public" });
29 if (!made.ok) throw new Error(String(made.error));
30 const other = await createRepo(app, u.value, { name: "notes", visibility: "public" });
31 if (!other.ok) throw new Error(String(other.error));
32 const insertIssue = app.db.query(
33 `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', ?4, ?5, 'closed')`,
34 );
35 const insertPull = app.db.query(
36 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid) VALUES (?1, ?2, 'b', 'base', '', ?3, ?4)`,
37 );
38 insertIssue.run("i1", made.value.id, 1, "First", u.value.id);
39 insertPull.run("i1", made.value.defaultBranch, "2026-10-01T10:00:00.000Z", "oid-1");
40 insertIssue.run("i2", made.value.id, 2, "Second", u.value.id);
41 insertPull.run("i2", made.value.defaultBranch, "2026-10-02T10:00:00.000Z", "oid-2");
42 insertIssue.run("i3", other.value.id, 1, "Elsewhere", u.value.id);
43 insertPull.run("i3", other.value.defaultBranch, "2026-09-30T10:00:00.000Z", "oid-3");
44 keepLive("alice/forge", ["oid-1"], 0);
45 }, 30_000);
46
47 afterAll(async () => {
48 forgetLive();
49 if (saved === undefined) delete process.env["FORGE_COMMIT"];
50 else process.env["FORGE_COMMIT"] = saved;
51 servers.stop();
52 app.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 test("a repeat is a 304 with no body and the same tag and cache-control", async () => {
57 const first = await get();
58 expect(first.status).toBe(200);
59 const etag = first.headers.get("etag") ?? "";
60 expect(etag).toMatch(/^W\/"/);
61 expect(first.headers.get("cache-control")).toBe("public, max-age=5");
62 await first.text();
63 const again = await get("", { "if-none-match": etag });
64 expect(again.status).toBe(304);
65 expect(await again.text()).toBe("");
66 expect(again.headers.get("etag")).toBe(etag);
67 expect(again.headers.get("cache-control")).toBe("public, max-age=5");
68 });
69
70 test("?repo=all and the default carry different tags", async () => {
71 const a = await get();
72 const b = await get("?repo=all");
73 expect(a.headers.get("etag")).not.toBe(b.headers.get("etag"));
74 const stale = await get("?repo=all", { "if-none-match": a.headers.get("etag") ?? "" });
75 expect(stale.status).toBe(200);
76 });
77
78 test("a newly shipped pull makes the next answer a 200 with a new tag", async () => {
79 const before = (await get()).headers.get("etag") ?? "";

Passes on main, because a missing etag is not equal to "" and the status is 200 anyway. Expect before to match /^W\//, then expect after.headers.get("etag") to match /^W\// and differ from before.

80 keepLive("alice/forge", ["oid-1", "oid-2"], 0);
81 const after = await get("", { "if-none-match": before });
82 expect(before).toMatch(/^W\//);
83 expect(after.status).toBe(200);
84 expect(after.headers.get("etag")).toMatch(/^W\//);
85 expect(after.headers.get("etag")).not.toBe(before);
86 });
87 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs