diff --git a/src/routes/api/the-nook/_releases.ts b/src/routes/api/the-nook/_releases.ts index 063521f..e72ea6d 100644 --- a/src/routes/api/the-nook/_releases.ts +++ b/src/routes/api/the-nook/_releases.ts @@ -5,9 +5,11 @@ * assembles the list inside the route component. A native client must not * couple to that markup, and the appcast alone is not the full story: releases * older than the appcast's retention live only in the checked-in archive. So - * this module mirrors the page's merge — live appcast items, then archived - * releases the appcast has aged out — and normalizes it into data a client can - * render without parsing HTML. + * this module owns that merge — live appcast items, their notes filled from the + * archive wherever the feed lost them, then archived releases the appcast has + * aged out — and normalizes it into data a client can render without parsing + * HTML. `withArchivedNotes` is exported so /changelog runs the identical rule + * rather than a second copy of it. * * Underscore-prefixed file — not a route. */ @@ -122,17 +124,45 @@ type ArchivedReleaseShape = { notesHtml: string; }; +/** + * Notes the appcast lost, filled from the archive. + * + * An item with no notes renders as a version header over a blank row, in the + * app and on /changelog alike. That is not hypothetical: Sparkle rebuilds every + * item from its DMG and removes the description of one whose DMG carries no + * notes file, so 0.8.0 and 0.7.0 published with notes and reached both + * surfaces with none (NOOK-304). + * + * The archive copy is used only where the live one is missing. Where both + * exist the published item wins, because that is the text the updater itself + * showed, and it carries the build number the archive does not have. + */ +export function withArchivedNotes< + T extends { version: string; notesHtml: string } +>( + live: readonly T[], + archived: readonly { version: string; notesHtml: string }[] +): T[] { + const notesByVersion = new Map(archived.map((r) => [r.version, r.notesHtml])); + return live.map((release) => { + if (release.notesHtml.trim()) return release; + const fallback = notesByVersion.get(release.version); + return fallback ? { ...release, notesHtml: fallback } : release; + }); +} + /** * Live releases first, newest first, then any archived release the appcast no * longer lists. Order is concatenation rather than a sort because both inputs - * are newest-first and the archive only ever holds older releases — matching - * how /changelog builds its list. + * are newest-first and the archive only ever holds older releases — and because + * /changelog renders this same list, the page and this feed cannot disagree + * about what a release said. */ export function mergeReleases( live: readonly AppcastRelease[], archived: readonly ArchivedReleaseShape[] ): AppRelease[] { - const releases = live.map(toAppRelease); + const releases = withArchivedNotes(live, archived).map(toAppRelease); const seen = new Set(releases.map((r) => r.version)); for (const release of archived) { if (seen.has(release.version)) continue; diff --git a/src/routes/api/the-nook/releases.test.ts b/src/routes/api/the-nook/releases.test.ts index 8700454..c26587d 100644 --- a/src/routes/api/the-nook/releases.test.ts +++ b/src/routes/api/the-nook/releases.test.ts @@ -79,6 +79,9 @@ async function body(): Promise<{ releases: Release[] }> { return (await res.json()) as { releases: Release[] }; } +// The fixture appcast: two releases it lists live, newest first. +const LIVE = ["0.6.3", "0.6.2"]; + beforeEach(() => { appcastXml = appcast( item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) + @@ -97,7 +100,7 @@ describe("GET /api/the-nook/releases.json", () => { expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); const { releases } = await body(); - expect(releases.map((r) => r.version)).toEqual(["0.6.3", "0.6.2", ...archivedVersions]); + expect(releases.map((r) => r.version)).toEqual([...LIVE, ...agedOut(...LIVE)]); expect(releases[0].build).toBe("10"); }); @@ -125,9 +128,9 @@ describe("GET /api/the-nook/releases.json", () => { it("appends the archived releases the appcast has aged out", async () => { const releases = (await body()).releases; - const archived = releases.filter((r) => !["0.6.3", "0.6.2"].includes(r.version)); + const archived = releases.filter((r) => !LIVE.includes(r.version)); - expect(archived.map((r) => r.version)).toEqual(archivedVersions); + expect(archived.map((r) => r.version)).toEqual(agedOut(...LIVE)); expect(archived[0]!.build).toBeNull(); // Archive dates are already calendar dates and pass through untouched. expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date); @@ -158,6 +161,82 @@ describe("GET /api/the-nook/releases.json", () => { }); }); +describe("notes the appcast lost", () => { + // Sparkle rebuilds every item from its DMG and removes the description of an + // item whose DMG carries no notes file, so a release the appcast still lists + // can arrive with nothing to show. 0.8.0 and 0.7.0 did exactly that, in both + // this feed and /changelog (NOOK-304): a version header over an empty row. + const itemWithoutNotes = (version: string) => ` + + ${version} + Fri, 02 Oct 2026 23:29:42 -0400 + 9 + ${version} + + `; + + it("falls back to the archive's copy for a release listed without notes", async () => { + appcastXml = appcast( + item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) + + itemWithoutNotes("0.5.0") + ); + invalidateAppcastCache(); + + const lost = (await body()).releases.find((r) => r.version === "0.5.0")!; + + expect(lost.sections).toEqual( + sectionsFromHtml( + ARCHIVED_RELEASES.find((r) => r.version === "0.5.0")!.notesHtml + ) + ); + expect(lost.sections.length).toBeGreaterThan(0); + // Still the live row, not the archived one: it carries the build number. + expect(lost.build).toBe("9"); + }); + + it("keeps the published notes when the archive has them too", async () => { + appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES)); + invalidateAppcastCache(); + + const [release] = (await body()).releases; + + expect(release!.sections).toEqual(sectionsFromHtml(NOTES)); + }); +}); + +describe("the checked-in archive", () => { + // The durable copy of what every release said. The appcast carries only the + // DMGs the release machine still holds, and it loses notes when it rebuilds, + // so a release missing here is a release the changelog can blank out later + // with nothing left to fall back to. + it("holds every release that shipped notes, newest first", () => { + expect(ARCHIVED_RELEASES.map((r) => r.version)).toEqual([ + "0.9.0", + "0.8.0", + "0.7.0", + "0.6.3", + "0.6.2", + "0.6.0", + "0.5.0", + "0.4.1", + "0.4.0", + "0.3.0", + "0.2.0" + // 0.6.1 and 0.3.1 shipped without written notes and are absent by design. + ]); + }); + + it("holds notes for every release it lists, and a date for each", () => { + for (const release of ARCHIVED_RELEASES) { + expect( + sectionsFromHtml(release.notesHtml).length, + `${release.version} has no notes` + ).toBeGreaterThan(0); + expect(isoDateOrNull(release.date), release.version).not.toBeNull(); + } + }); +}); + describe("sectionsFromHtml", () => { it("labels an unlabelled list as Notes", () => { expect(sectionsFromHtml("")).toEqual([ @@ -194,5 +273,11 @@ describe("isoDateOrNull", () => { }); }); -/** The archive in full, in file order (newest first) — the tail of the list. */ -const archivedVersions = ARCHIVED_RELEASES.map((r) => r.version); +/** + * The tail a case should expect: the whole archive in file order (newest + * first) minus the versions its fixture appcast lists live. A version is + * listed once, from the appcast, and the archive copy behind it only fills + * notes the feed lost — so the tail shrinks as the archive grows. + */ +const agedOut = (...live: string[]) => + ARCHIVED_RELEASES.map((r) => r.version).filter((v) => !live.includes(v)); diff --git a/src/routes/nook/changelog-archive.ts b/src/routes/nook/changelog-archive.ts index 38a2f42..17bcc4c 100644 --- a/src/routes/nook/changelog-archive.ts +++ b/src/routes/nook/changelog-archive.ts @@ -1,8 +1,20 @@ -// Releases older than the appcast's retention, recovered from the -// changelog file's git history (The-Nook 1ad8a69 / db165cd) and checked -// in so /changelog renders the full story. 0.3.1 and 0.6.1 shipped -// without written notes and are absent by design. Generated with the -// same section->HTML rules as embed_release_notes.py. +// Every release of The Nook, newest first, checked in as the durable copy of +// what each one said. +// +// The appcast is the live feed — it carries the build numbers — but it is not +// a store: it holds only the DMGs the release machine still has, and +// generate_appcast drops the description of any rebuilt item whose DMG ships +// no notes file of its own. So a release the appcast lists can arrive with +// nothing to show, and one it has aged out of vanishes outright. Both are +// covered from here: /changelog and releases.json fill an item that has no +// notes from these (NOOK-304). +// +// Append the release being folded at each release cut, or the next release +// ages out into a blank row exactly as 0.6.0-0.8.0 did. The source text is +// `CHANGELOG.md` in the-nook, which keeps every release for this reason, and +// these notesHtml bodies are rendered by its own embed_release_notes.py rules. +// +// 0.3.1 and 0.6.1 shipped without written notes and are absent by design. export interface ArchivedRelease { version: string; date: string; @@ -10,6 +22,36 @@ export interface ArchivedRelease { } export const ARCHIVED_RELEASES: ArchivedRelease[] = [ + { + "version": "0.9.0", + "date": "2026-10-12", + "notesHtml": "

Added

Changed

Fixed

" + }, + { + "version": "0.8.0", + "date": "2026-10-12", + "notesHtml": "

Added

Changed

Fixed

" + }, + { + "version": "0.7.0", + "date": "2026-10-11", + "notesHtml": "

Security

Added

Changed

Fixed

" + }, + { + "version": "0.6.3", + "date": "2026-10-02", + "notesHtml": "

Added

Changed

Fixed

" + }, + { + "version": "0.6.2", + "date": "2026-09-24", + "notesHtml": "

Added

Changed

Fixed

" + }, + { + "version": "0.6.0", + "date": "2026-09-07", + "notesHtml": "

Added

Changed

Fixed

" + }, { "version": "0.5.0", "date": "2026-09-07", diff --git a/src/routes/nook/changelog.tsx b/src/routes/nook/changelog.tsx index aeb04ef..54788a8 100644 --- a/src/routes/nook/changelog.tsx +++ b/src/routes/nook/changelog.tsx @@ -3,8 +3,12 @@ * * The appcast already ships HTML release notes to the updater on every * launch, so this page renders exactly what Sparkle publishes: one source - * of truth, no second place to write release notes. Doubles as the raw - * material for the release-announcement job (marketing/README.md). + * of truth, no second place to write release notes. The checked-in archive + * fills an item the appcast lost its notes to — the feed is rebuilt from the + * DMGs on every publish, and Sparkle drops the notes of an item whose DMG + * carries none, which is how 0.8.0 and 0.7.0 came to render as blank rows. + * Doubles as the raw material for the release-announcement job + * (marketing/README.md). * * Layout: the version list lives in a fixed-height vertical scroller, so * expanding a release never shifts the page — history is browsed inside @@ -17,15 +21,20 @@ import { PageHead } from "~/components/PageHead"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import SubdomainHeader from "~/components/SubdomainHeader"; import { fetchAppcastReleases } from "~/routes/api/the-nook/_appcast"; +import { withArchivedNotes } from "~/routes/api/the-nook/_releases"; import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive"; const getReleases = query(async () => { "use server"; - // Appcast items are authoritative (they carry build numbers); releases - // older than the appcast's retention come from the checked-in archive, - // recovered from the changelog file's git history. Both normalize to - // AppcastRelease so the page renders one shape. - const live = await fetchAppcastReleases(); + // Appcast items are authoritative (they carry build numbers), with the + // archive filling any item whose notes the rebuilt feed lost. Releases the + // appcast no longer lists at all come from the archive. Both normalize to + // AppcastRelease so the page renders one shape, and the fill is the same + // helper releases.json runs, so the two cannot tell different stories. + const live = withArchivedNotes( + await fetchAppcastReleases(), + ARCHIVED_RELEASES + ); const liveVersions = new Set(live.map((r) => r.version)); const archived = ARCHIVED_RELEASES.filter( (r) => !liveVersions.has(r.version)