Public: anyone can read this; the crew can change it.
The changelog is an Atom feed too: /changelog.atom lists what went live, and the page links it #8417
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8213-the-changelog-is-an-atom-feed-too-change → main 5 commits fork 1 thread · 1 unresolved
8 files
changed +131
−4
docs/features/changelog.md +6
| @@ -15,3 +15,9 @@ The list is a live region (`pull.merged`, `app.serving`): it swaps in place with | ||
| 15 | 15 | ## For agents |
| 16 | 16 | |
| 17 | 17 | `GET /api/v1/changelog` is the page's list as JSON, public, cached five seconds: `{version, commit, live, onItsWay}`, each landing `{repo, number, title, at, live}`. `?repo=all` or `?repo=owner/name` filters; the default is the forge's own repository, as on the page. MCP `releases` with `{shipped: true, repo?}` and `GET /api/v1/releases?shipped=true[&repo=]` answer the same rows with the same default. Only public repositories are read; concurrent requests share one read of git and the merged rows, so an anonymous caller costs no more than a page view. The page links the JSON with `rel="alternate"`. The terminal's Changelog screen is the release-commit view (`release/changelog.ts`); it does not show the landings list. |
| 18 | ||
| 19 | ## As a feed (#8213) | |
| 20 | ||
| 21 | `GET /changelog.atom` is the same list as an Atom feed, public, for a feed reader. It holds the newest 50 live landings of the forge's own repository (`FORGE_REPO`, public only), one entry each titled `Went live: <title>` and linked to its pull; what has landed but is not yet live is left out. The 50 is the feed's own cap (`CHANGELOG_ENTRIES` in `web/feeds.ts`); the page and `/api/v1/changelog` are not capped. It is cached for five minutes (`cache-control: public, max-age=300`) and carries a weak `ETag`, so a reader that sends `If-None-Match` gets a 304. The page links it with `rel="alternate"`. | |
| 22 | ||
| 23 | An agent reads the same data as JSON, not Atom: MCP `releases` with `{shipped: true}` or `GET /api/v1/changelog` (see above). The terminal has no screen for the feed, since a feed is for a reader program; its Changelog screen is the release-commit view. |
src/core/reserved.ts +1
| @@ -107,6 +107,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 107 | 107 | "sitemap.xml", |
| 108 | 108 | "releases", |
| 109 | 109 | "releases.json", |
| 110 | "changelog.atom", | |
| 110 | 111 | "sw.js", |
| 111 | 112 | "tell", |
| 112 | 113 | "talk", |
src/http/server.ts +2 −1
| @@ -135,7 +135,7 @@ import { confirmPage, searchPage } from "../web/pages/search.ts"; | ||
| 135 | 135 | import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts"; |
| 136 | 136 | import { followingSubmit } from "../web/pages/following.ts"; |
| 137 | 137 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 138 | import { activityAtom } from "../web/feeds.ts"; | |
| 138 | import { activityAtom, changelogAtom } from "../web/feeds.ts"; | |
| 139 | 139 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 140 | 140 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 141 | 141 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| @@ -745,6 +745,7 @@ export function buildRouter(): Router<Handler> { | ||
| 745 | 745 | router.add("GET", "/audit", auditPage); |
| 746 | 746 | router.add("GET", "/releases", releasesPage); |
| 747 | 747 | router.add("GET", "/changelog", changelogPage); |
| 748 | router.add("GET", "/changelog.atom", changelogAtom); | |
| 748 | 749 | router.add("GET", "/:owner/:name/api", appApiPage); |
| 749 | 750 | router.add("POST", "/releases/:sha/rollback", rollbackSubmit); |
| 750 | 751 | router.add("POST", "/releases/hold", releaseHoldSubmit); |
src/web/feeds.ts +42 −1
| @@ -20,7 +20,8 @@ import { listEvents, type ForgeEvent } from "../core/events.ts"; | ||
| 20 | 20 | import { getIssuesByRef } from "../core/issues.ts"; |
| 21 | 21 | import { canRead, forkParentFor, getRepo } from "../core/repos.ts"; |
| 22 | 22 | import { getUsersByIds } from "../core/users.ts"; |
| 23 | import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts"; | |
| 23 | import { shippedAnswer } from "../release/answer.ts"; | |
| 24 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; | |
| 24 | 25 | import type { Ctx } from "../http/context.ts"; |
| 25 | 26 | import { notFound } from "../http/respond.ts"; |
| 26 | 27 | |
| @@ -189,3 +190,43 @@ ${entries.join("\n")} | ||
| 189 | 190 | }, |
| 190 | 191 | }); |
| 191 | 192 | } |
| 193 | ||
| 194 | const CHANGELOG_ENTRIES = 50; | |
| 195 | ||
| 196 | /** | |
| 197 | * `GET /changelog.atom`: what went live, as /changelog lists it (the forge's | |
| 198 | * own repository, public pulls only). Landed-but-not-shipped is left out. | |
| 199 | */ | |
| 200 | export async function changelogAtom(ctx: Ctx): Promise<Response> { | |
| 201 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 202 | const answer = await timePhaseAsync(ctx.request, "releases", () => shippedAnswer(ctx.app)); | |
| 203 | const origin = ctx.url.origin; | |
| 204 | const live = answer.live.slice(0, CHANGELOG_ENTRIES); | |
| 205 | const etag = `W/"${Bun.hash(JSON.stringify([answer.version, live.map((l) => [l.repo, l.number, l.title, l.at])])).toString(36)}"`; | |
| 206 | const headers = { etag, "cache-control": "public, max-age=300" }; | |
| 207 | if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers }); | |
| 208 | const entries = live.map((l) => { | |
| 209 | const link = `${origin}/${l.repo}/pulls/${l.number}`; | |
| 210 | return ` <entry> | |
| 211 | <id>urn:forge:landing:${xml(l.repo)}#${l.number}</id> | |
| 212 | <title>${xml(`Went live: ${l.title}`)}</title> | |
| 213 | <updated>${xml(stamp(l.at))}</updated> | |
| 214 | <link rel="alternate" type="text/html" href="${xml(link)}"/> | |
| 215 | </entry>`; | |
| 216 | }); | |
| 217 | const updated = live[0]?.at ?? new Date(0).toISOString(); | |
| 218 | const body = `<?xml version="1.0" encoding="utf-8"?> | |
| 219 | <feed xmlns="http://www.w3.org/2005/Atom"> | |
| 220 | <id>${xml(origin)}/changelog.atom</id> | |
| 221 | <title>Changelog</title> | |
| 222 | <subtitle>What went live</subtitle> | |
| 223 | <author><name>forge</name></author> | |
| 224 | <updated>${xml(stamp(updated))}</updated> | |
| 225 | <link rel="self" type="application/atom+xml" href="${xml(origin)}/changelog.atom"/> | |
| 226 | <link rel="alternate" type="text/html" href="${xml(origin)}/changelog"/> | |
| 227 | <generator>forge</generator> | |
| 228 | ${entries.join("\n")} | |
| 229 | </feed> | |
| 230 | `; | |
| 231 | return new Response(body, { headers: { ...headers, "content-type": "application/atom+xml; charset=utf-8" } }); | |
| 232 | } |
src/web/pages/changelog.ts +1 −1
| @@ -158,7 +158,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 158 | 158 | const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user); |
| 159 | 159 | const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters} |
| 160 | 160 | <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", newestLive(ctx.app.db), readable)}</div>`; |
| 161 | const response = page({ ctx, title: "Changelog", body, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); | |
| 161 | const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); | |
| 162 | 162 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 163 | 163 | return response; |
| 164 | 164 | } |
test/changelog-atom.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** `GET /changelog.atom` lists what went live, escaped, without what is only on its way; the page links it. */ | |
| 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 { checkAtom } from "./helpers/atom.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | describe("GET /changelog.atom", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | const saved = process.env["FORGE_COMMIT"]; | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | process.env["FORGE_COMMIT"] = "a".repeat(40); | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-atom-")); | |
| 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 insertIssue = app.db.query( | |
| 31 | `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', ?4, ?5, 'closed')`, | |
| 32 | ); | |
| 33 | const insertPull = app.db.query( | |
| 34 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid) VALUES (?1, ?2, 'b', 'base', '', ?3, ?4)`, | |
| 35 | ); | |
| 36 | insertIssue.run("i1", made.value.id, 1, "Fix <&> escaping", u.value.id); | |
| 37 | insertPull.run("i1", made.value.defaultBranch, "2026-10-01T10:00:00.000Z", "oid-live"); | |
| 38 | insertIssue.run("i2", made.value.id, 2, "Not shipped yet", u.value.id); | |
| 39 | insertPull.run("i2", made.value.defaultBranch, "2026-10-02T10:00:00.000Z", "oid-new"); | |
| 40 | keepLive("alice/forge", ["oid-live"], 0); | |
| 41 | }, 30_000); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | forgetLive(); | |
| 45 | if (saved === undefined) delete process.env["FORGE_COMMIT"]; | |
| 46 | else process.env["FORGE_COMMIT"] = saved; | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("is valid Atom with the live landing escaped and the unshipped one left out", async () => { | |
| 53 | const res = await fetch(`${servers.url}/changelog.atom`); | |
| 54 | expect(res.status).toBe(200); | |
| 55 | expect(res.headers.get("content-type")).toContain("application/atom+xml"); | |
| 56 | expect(res.headers.get("cache-control")).toBe("public, max-age=300"); | |
| 57 | const text = await res.text(); | |
| 58 | const { titles } = checkAtom(text, `${servers.url}/changelog.atom`); | |
| 59 | expect(titles).toEqual(["Went live: Fix <&> escaping"]); | |
| 60 | expect(text).toContain(`href="${servers.url}/alice/forge/pulls/1"`); | |
| 61 | expect(text).toContain("Fix <&> escaping"); | |
| 62 | expect(text).not.toContain("Not shipped yet"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("answers 304 to its ETag", async () => { | |
| 66 | const first = await fetch(`${servers.url}/changelog.atom`); | |
| 67 | const etag = first.headers.get("etag")!; | |
| 68 | expect(etag).toBeTruthy(); | |
| 69 | const again = await fetch(`${servers.url}/changelog.atom`, { headers: { "if-none-match": etag } }); | |
| 70 | expect(again.status).toBe(304); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the page links it", async () => { | |
| 74 | const html = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 75 | expect(html).toMatch(/rel="alternate"\s+type="application\/atom\+xml"[^>]*href="\/changelog\.atom"/); | |
| 76 | }); | |
| 77 | }); |
test/design-audit.test.ts +1 −1
| @@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | const NOT_PAGES = |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/forge-looks.test.ts +1
| @@ -25,6 +25,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 25 | 25 | "/openapi.json": "a redirect to the contract document", |
| 26 | 26 | "/favicon.ico": "the tab icon, an image", |
| 27 | 27 | "/releases.json": "JSON feed of /releases", |
| 28 | "/changelog.atom": "Atom feed of /changelog", | |
| 28 | 29 | "/sitemap.xml": "XML for crawlers", |
| 29 | 30 | "/manifest.webmanifest": "the install manifest, JSON", |
| 30 | 31 | "/opensearch.xml": "the browser search description, XML", |
new above
offline
CHANGELOG_ENTRIES = 50 is a magic cap that the page and /api/v1/changelog do not share. Document it in docs/features/changelog.md, or reuse a shared constant if one exists.