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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9112-get-api-v1-changelog-carries-an-etag-and → main 3 commits fork 1 thread · 1 unresolved
4 files
changed +113
−3
src/contract/openapi/routes/getApiV1Changelog.ts +2 −1
| @@ -3,7 +3,8 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | 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], | |
| 7 | 8 | auth: false, |
| 8 | 9 | query: { repo: "string" }, |
| 9 | 10 | refusals: {}, |
src/web/feeds.ts +16 −1
| @@ -194,6 +194,21 @@ ${entries.join("\n")} | ||
| 194 | 194 | |
| 195 | 195 | const CHANGELOG_ENTRIES = 50; |
| 196 | 196 | |
| 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 | ||
| 197 | 212 | /** |
| 198 | 213 | * `GET /changelog.atom`: what went live, as /changelog lists it (the forge's |
| 199 | 214 | * own repository, public pulls only). Landed-but-not-shipped is left out. |
| @@ -203,7 +218,7 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> { | ||
| 203 | 218 | const answer = await timePhaseAsync(ctx.request, "releases", () => shippedAnswer(ctx.app)); |
| 204 | 219 | const origin = ctx.url.origin; |
| 205 | 220 | 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); | |
| 207 | 222 | const headers = { etag, "cache-control": "public, max-age=300" }; |
| 208 | 223 | if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers }); |
| 209 | 224 | 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"; | ||
| 16 | 16 | import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts"; |
| 17 | 17 | import { liveRegionsJs } from "../client.ts"; |
| 18 | 18 | import { pageHead } from "../chrome.ts"; |
| 19 | import { changelogTag, holds } from "../feeds.ts"; | |
| 19 | 20 | import { json } from "../../http/respond.ts"; |
| 20 | 21 | import { shippedAnswer } from "../../release/answer.ts"; |
| 21 | 22 | import { html, type Html } from "../html.ts"; |
| @@ -176,7 +177,13 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 176 | 177 | // GET /api/v1/changelog?repo=all|owner/name: the page's rows as JSON, public repositories only. |
| 177 | 178 | export async function changelogJson(ctx: Ctx): Promise<Response> { |
| 178 | 179 | 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); | |
| 180 | 187 | } |
| 181 | 188 | |
| 182 | 189 | /** |
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") ?? ""; | |
| 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 | }); |
new above
offline
Passes on main, because a missing etag is not equal to "" and the status is 200 anyway. Expect
beforeto match /^W\//, then expectafter.headers.get("etag")to match /^W\// and differ frombefore.