From b2d23a8559dd540ce6dc7f25f143daa71136d065 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 13 May 2026 14:35:13 +0200 Subject: [PATCH] fix: dedupe calendar stable releases --- src/utils/releaseHistoryPage.test.ts | 46 ++++++++++++++++++ src/utils/releaseHistoryPage.ts | 71 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts index da801baa..6b99c0f7 100644 --- a/src/utils/releaseHistoryPage.test.ts +++ b/src/utils/releaseHistoryPage.test.ts @@ -121,6 +121,52 @@ describe('buildReleaseHistoryPage', () => { expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9')) }) + it('deduplicates equivalent stable calendar tags and keeps the richer notes', () => { + const html = buildReleaseHistoryPage([ + { + assets: [ + { + browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg', + name: 'Tolaria_2026.5.13_macOS_Silicon.dmg', + }, + ], + body: '## What's Changed\n\n', + body_html: '

What's Changed

', + html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.5.13', + name: 'Tolaria 2026.5.13', + prerelease: false, + published_at: '2026-05-13T09:30:44Z', + tag_name: 'stable-v2026.5.13', + }, + { + assets: [ + { + browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg', + name: 'Tolaria_2026.5.13_macOS_Silicon.dmg', + }, + ], + body_html: [ + '

What's Changed

', + '

Features

', + '', + ].join(''), + html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/v2026-05-13', + name: 'Tolaria v2026-05-13', + prerelease: false, + published_at: '2026-05-13T09:21:30Z', + tag_name: 'v2026-05-13', + }, + ]) + + expect(html).toContain('Stable1') + expect(html).toContain('Tolaria v2026-05-13') + expect(html).toContain('Add AI visibility setting') + expect(html).not.toContain('Tolaria 2026.5.13') + }) + it('filters draft releases and shows an empty state for channels without published builds', () => { const html = buildReleaseHistoryPage([ { diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 3bcc76e3..05c5fca1 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -516,6 +516,10 @@ const RELEASE_CHANNEL_LABELS: Record = { const RELEASE_CHANNEL_LABELS_BY_CHANNEL = new Map( Object.entries(RELEASE_CHANNEL_LABELS) as Array<[ReleaseChannel, string]>, ) +const STABLE_TAG_DATE_PATTERNS = [ + /^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$/, + /^v(\d{4})-(\d{1,2})-(\d{1,2})$/, +] function escapeMarkupText(value: string): string { return value @@ -635,6 +639,71 @@ function readableNotesUrlForRelease(channel: ReleaseChannel, tagName: string): s return `release-notes/${encodeURIComponent(tagName)}.md` } +function normalizeStableTagDate(tagName: string): string | null { + for (const pattern of STABLE_TAG_DATE_PATTERNS) { + const match = tagName.match(pattern) + if (!match) continue + + const [, year, month, day] = match + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` + } + + return null +} + +function stableReleaseDedupeKey(release: ReleaseEntry): string { + const stableDate = normalizeStableTagDate(release.tagName) + return stableDate === null ? `tag:${release.tagName}` : `date:${stableDate}` +} + +function releaseNotesListItemCount(notesHtml: string): number { + return notesHtml.match(/() + const downloads: ReleaseDownload[] = [] + + for (const download of [...primary, ...secondary]) { + if (seenUrls.has(download.url)) continue + + seenUrls.add(download.url) + downloads.push(download) + } + + return downloads +} + +function preferredRelease(left: ReleaseEntry, right: ReleaseEntry): ReleaseEntry { + const leftScore = releasePreferenceScore(left) + const rightScore = releasePreferenceScore(right) + const selectedRelease = rightScore !== leftScore + ? (rightScore > leftScore ? right : left) + : (right.publishedTimestamp > left.publishedTimestamp ? right : left) + const fallbackRelease = selectedRelease === right ? left : right + + return { + ...selectedRelease, + downloads: mergeReleaseDownloads(selectedRelease.downloads, fallbackRelease.downloads), + } +} + +function deduplicateStableReleases(releases: ReleaseEntry[]): ReleaseEntry[] { + const releasesByDate = new Map() + + for (const release of releases) { + const key = stableReleaseDedupeKey(release) + const existingRelease = releasesByDate.get(key) + releasesByDate.set(key, existingRelease ? preferredRelease(existingRelease, release) : release) + } + + return Array.from(releasesByDate.values()) +} + function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null { if (release.draft === true) return null @@ -678,6 +747,8 @@ function collectReleaseSections(payload: unknown): ReleaseSections { appendReleaseSection(sections, normalizedRelease) } + sections.stable = deduplicateStableReleases(sections.stable) + for (const channel of ['stable', 'alpha'] as const) { const section = Reflect.get(sections, channel) as ReleaseEntry[] section.sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)