nook: fill release notes the appcast lost from the archive
0.8.0 and 0.7.0 rendered as a version header over an empty row on /changelog and in releases.json, the feed the macOS app's Changelog pane renders. Older still, 0.6.0/0.6.2/0.6.3 were gone from the page outright. Both come from one gap. The appcast is the only live source and it is not a store: it holds just the DMGs the release machine keeps, and generate_appcast drops the description of any rebuilt item whose DMG ships no notes file of its own — so a release it still lists can arrive with nothing to show, and one it has aged out of vanishes. The checked-in archive was meant to cover that, but it stopped at 0.5.0, and mergeReleases skipped an archived version whenever the appcast mentioned it, so it could never heal a row that existed and was blank. The archive now carries every release that shipped notes, 0.9.0 down to 0.2.0, and withArchivedNotes fills an item only where the feed has nothing, leaving a published description alone. /changelog and releases.json run the same rule rather than two copies, so the page and the feed cannot disagree about what a release said. 0.6.1 and 0.3.1 stay absent by design — they shipped with no notes written. Tests: bun test src/routes/api/the-nook/ (51 pass; the 4 new cases fail on this branch's parent); bun test unchanged at 49 failures, all in src/server/security, which needs an .env a worktree does not carry. Proved end to end against the live appcast as published by the 0.9.0 release: 11 releases, all with sections. NOOK-305 Co-Authored-By: qwen3.8-flash <noreply@freno.me>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) => `
|
||||
<item>
|
||||
<title>${version}</title>
|
||||
<pubDate>Fri, 02 Oct 2026 23:29:42 -0400</pubDate>
|
||||
<sparkle:version>9</sparkle:version>
|
||||
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
|
||||
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
|
||||
</item>`;
|
||||
|
||||
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("<ul><li>Fixes</li></ul>")).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));
|
||||
|
||||
Reference in New Issue
Block a user