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

Merged opened this

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

Merged

scout/forge:scout/8213-the-changelog-is-an-atom-feed-too-change → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/8213-the-changelog-is-an-atom-feed-too-change was merged into main as 0500a21.

Review

Sign in to review this change.
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
1515 ## For agents
1616
1717 `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[] = [
107107 "sitemap.xml",
108108 "releases",
109109 "releases.json",
110 "changelog.atom",
110111 "sw.js",
111112 "tell",
112113 "talk",
src/http/server.ts +2 −1
@@ -135,7 +135,7 @@ import { confirmPage, searchPage } from "../web/pages/search.ts";
135135 import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts";
136136 import { followingSubmit } from "../web/pages/following.ts";
137137 import { reconnectSubmit } from "../web/pages/frozen.ts";
138 import { activityAtom } from "../web/feeds.ts";
138 import { activityAtom, changelogAtom } from "../web/feeds.ts";
139139 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
140140 import { apiPage, appApiPage } from "../web/pages/api.ts";
141141 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
@@ -745,6 +745,7 @@ export function buildRouter(): Router<Handler> {
745745 router.add("GET", "/audit", auditPage);
746746 router.add("GET", "/releases", releasesPage);
747747 router.add("GET", "/changelog", changelogPage);
748 router.add("GET", "/changelog.atom", changelogAtom);
748749 router.add("GET", "/:owner/:name/api", appApiPage);
749750 router.add("POST", "/releases/:sha/rollback", rollbackSubmit);
750751 router.add("POST", "/releases/hold", releaseHoldSubmit);
src/web/feeds.ts +42 −1
@@ -20,7 +20,8 @@ import { listEvents, type ForgeEvent } from "../core/events.ts";
2020 import { getIssuesByRef } from "../core/issues.ts";
2121 import { canRead, forkParentFor, getRepo } from "../core/repos.ts";
2222 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";
2425 import type { Ctx } from "../http/context.ts";
2526 import { notFound } from "../http/respond.ts";
2627
@@ -189,3 +190,43 @@ ${entries.join("\n")}
189190 },
190191 });
191192 }
193
194 const CHANGELOG_ENTRIES = 50;

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.

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> {
158158 const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user);
159159 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}
160160 <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}` });
162162 notePhase(ctx.request, "render", performance.now() - rendering);
163163 return response;
164164 }
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 &lt;&amp;&gt; 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");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 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\/)/;
1919
2020 const getRoutes = [
2121 ...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> = {
2525 "/openapi.json": "a redirect to the contract document",
2626 "/favicon.ico": "the tab icon, an image",
2727 "/releases.json": "JSON feed of /releases",
28 "/changelog.atom": "Atom feed of /changelog",
2829 "/sitemap.xml": "XML for crawlers",
2930 "/manifest.webmanifest": "the install manifest, JSON",
3031 "/opensearch.xml": "the browser search description, XML",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs