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
',
+ '',
+ '- Add AI visibility setting
',
+ '- Support mounted vault workspaces
',
+ '
',
+ ].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)