From d89b5c9e14db3f4a10b443be4df406e0d3fbf7ce Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Tue, 18 Aug 2026 19:17:38 +0200 Subject: [PATCH] feat(releases): detect prerelease-only repos and mark prereleases in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub defines /releases/latest as the newest NON-prerelease, so a plugin that only ships betas 404s there and looks like it has no releases at all. Every fetchLatestRelease caller silently gave up on such a repo: the admin replay returned 'No published release found on the upstream repo', and both submit paths ingested no manifest and no release. Both provider paths now list releases and rank them by semver instead. That also puts this in step with persistRelease, which already ranks by semver — /releases/latest would have pinned a stable 1.0.0 as latest even after 1.1.0-beta.1 shipped, so the two disagreed about the current version. Prereleases stay first-class and are now labelled: a pre-release badge sits next to the version on the plugin card, the detail header, the download section, and each affected row in the releases list. Prerelease-ness is derived from the version string — ingest already enforces strict semver, so no schema change is needed. Localized in all six languages. --- apps/api/src/lib/release-fetch.ts | 63 +++++++--- apps/api/tests/lib/release-fetch.test.ts | 119 ++++++++++++++++++ apps/frontend/messages/de.json | 1 + apps/frontend/messages/en.json | 1 + apps/frontend/messages/es.json | 1 + apps/frontend/messages/fr.json | 1 + apps/frontend/messages/it.json | 1 + apps/frontend/messages/zh-CN.json | 1 + .../src/lib/components/PluginCard.svelte | 12 +- apps/frontend/src/lib/utils.ts | 8 ++ .../src/routes/plugins/[slug]/+page.svelte | 18 +++ 11 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 apps/api/tests/lib/release-fetch.test.ts diff --git a/apps/api/src/lib/release-fetch.ts b/apps/api/src/lib/release-fetch.ts index 1eb5b4e..25936e6 100644 --- a/apps/api/src/lib/release-fetch.ts +++ b/apps/api/src/lib/release-fetch.ts @@ -1,6 +1,7 @@ import type { NormalizedRelease } from './webhook' import type { RepoRef } from './providers' import { UpstreamUnauthorizedError } from './oauth-tokens' +import { compareSemver } from './semver' // Pulls the newest release straight from the provider API, in the same shape // the webhook handler would have ingested. Used wherever we need to materialize @@ -17,6 +18,20 @@ export async function fetchLatestRelease(accessToken: string, ref: RepoRef): Pro return fetchGitlab(instance.baseUrl, accessToken, ref) } +type GithubRelease = { + tag_name?: string + draft?: boolean + prerelease?: boolean + html_url?: string + assets?: Array<{ name: string; browser_download_url: string }> +} + +// Lists releases instead of asking for /releases/latest: that endpoint skips +// every prerelease, so a plugin that only ships betas looks like it has no +// releases at all. Listing also keeps this in step with persistRelease, which +// ranks releases by semver — /releases/latest would pin a stable 1.0.0 as +// "latest" even after 1.1.0-beta.1 shipped, and the two would disagree about +// which version the registry considers current. async function fetchGithubFlavored( apiBase: string, accessToken: string, @@ -28,44 +43,54 @@ async function fetchGithubFlavored( Accept: 'application/vnd.github+json', } if (userAgent) headers['User-Agent'] = userAgent - const res = await fetch(`${apiBase}/repos/${ref.owner}/${ref.repo}/releases/latest`, { headers }) + const res = await fetch(`${apiBase}/repos/${ref.owner}/${ref.repo}/releases?per_page=100`, { headers }) if (res.status === 404) return null - if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases/latest') + if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases') if (!res.ok) throw new Error(`Provider API ${res.status}`) - const data = (await res.json()) as { - tag_name?: string - draft?: boolean - prerelease?: boolean - html_url?: string - assets?: Array<{ name: string; browser_download_url: string }> - } - if (!data.tag_name) return null + const body = await res.json() + const list: GithubRelease[] = Array.isArray(body) ? body : [] + const candidates = list.filter((r) => r.tag_name && !r.draft) + if (candidates.length === 0) return null + // Highest semver wins; equal or unparseable tags keep the provider's own + // ordering, which is newest-first. + const newest = candidates.reduce((best, r) => + compareSemver(stripV(r.tag_name as string), stripV(best.tag_name as string)) > 0 ? r : best, + ) return { - repoUrl: data.html_url?.replace(/\/releases\/.*$/, '') ?? `${ref.instance.baseUrl}/${ref.owner}/${ref.repo}`, - published: !data.draft, - tag: data.tag_name, - assets: (data.assets ?? []).map((a) => ({ name: a.name, url: a.browser_download_url })), + repoUrl: newest.html_url?.replace(/\/releases\/.*$/, '') ?? `${ref.instance.baseUrl}/${ref.owner}/${ref.repo}`, + published: !newest.draft, + tag: newest.tag_name as string, + assets: (newest.assets ?? []).map((a) => ({ name: a.name, url: a.browser_download_url })), } } +function stripV(tag: string): string { + return tag.replace(/^v/, '') +} + async function fetchGitlab(baseUrl: string, accessToken: string, ref: RepoRef): Promise { const projectId = encodeURIComponent(ref.fullName) - const res = await fetch(`${baseUrl}/api/v4/projects/${projectId}/releases?per_page=1`, { + const res = await fetch(`${baseUrl}/api/v4/projects/${projectId}/releases?per_page=100`, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases') if (!res.ok) throw new Error(`GitLab API ${res.status}`) - const list = (await res.json()) as Array<{ + const body = await res.json() + const list = (Array.isArray(body) ? body : []) as Array<{ tag_name?: string upcoming_release?: boolean assets?: { links?: Array<{ name: string; url: string }> } }> - const latest = list[0] - if (!latest?.tag_name) return null + const tagged = list.filter((r) => r.tag_name) + if (tagged.length === 0) return null + // Same semver ranking as the GitHub-flavored path above. + const latest = tagged.reduce((best, r) => + compareSemver(stripV(r.tag_name as string), stripV(best.tag_name as string)) > 0 ? r : best, + ) return { repoUrl: `${baseUrl}/${ref.fullName}`, published: !latest.upcoming_release, - tag: latest.tag_name, + tag: latest.tag_name as string, assets: (latest.assets?.links ?? []).map((l) => ({ name: l.name, url: l.url })), } } diff --git a/apps/api/tests/lib/release-fetch.test.ts b/apps/api/tests/lib/release-fetch.test.ts new file mode 100644 index 0000000..b09a14f --- /dev/null +++ b/apps/api/tests/lib/release-fetch.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, spyOn, afterEach } from 'bun:test' +import { fetchLatestRelease } from '../../src/lib/release-fetch' +import type { RepoRef } from '../../src/lib/providers' +import type { ProviderInstance } from '../../src/lib/provider-instance' + +function refFor(kind: ProviderInstance['kind'], baseUrl: string): RepoRef { + return { + instance: { + id: kind, + kind, + displayName: kind, + baseUrl, + clientId: '', + clientSecret: '', + logoUrl: null, + enabled: true, + }, + owner: 'alice', + repo: 'my-plugin', + fullName: 'alice/my-plugin', + } +} + +const githubRef = refFor('github', 'https://github.com') + +function ghRelease(tag: string, extra: Record = {}) { + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + html_url: `https://github.com/alice/my-plugin/releases/tag/${tag}`, + assets: [{ name: 'plugin-linux-x64.zip', browser_download_url: `https://example.test/${tag}/linux.zip` }], + ...extra, + } +} + +let spy: ReturnType | null = null + +function mockList(releases: unknown[], status = 200) { + spy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { + lastUrl = String(url) + return new Response(JSON.stringify(releases), { status }) + }) as unknown as typeof fetch) +} + +let lastUrl = '' + +afterEach(() => { + spy?.mockRestore() + spy = null +}) + +describe('fetchLatestRelease (github)', () => { + // Regression: this used to call /releases/latest, which GitHub defines as + // the newest NON-prerelease. A plugin shipping only betas 404'd there and + // looked like it had no releases at all — submit, rehash and the admin + // replay all silently gave up on it. + it('finds the newest release when every release is a prerelease', async () => { + mockList([ghRelease('v1.0.0-beta.5'), ghRelease('v1.0.0-beta.7'), ghRelease('v1.0.0-beta.6')]) + const got = await fetchLatestRelease('token', githubRef) + expect(got?.tag).toBe('v1.0.0-beta.7') + expect(lastUrl).toContain('/releases?per_page=100') + expect(lastUrl).not.toContain('/releases/latest') + }) + + it('ranks by semver, not by the order the provider returns', async () => { + mockList([ghRelease('v0.2.0'), ghRelease('v0.10.0'), ghRelease('v0.9.0')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v0.10.0') + }) + + it('prefers a stable release over its own prerelease', async () => { + mockList([ghRelease('v2.0.0-rc.1'), ghRelease('v2.0.0')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v2.0.0') + }) + + it('picks a newer prerelease over an older stable release', async () => { + mockList([ghRelease('v1.0.0'), ghRelease('v1.1.0-beta.1')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v1.1.0-beta.1') + }) + + it('skips drafts', async () => { + mockList([ghRelease('v1.0.0'), ghRelease('v2.0.0', { draft: true })]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v1.0.0') + }) + + it('returns null when the repo has no releases', async () => { + mockList([]) + expect(await fetchLatestRelease('token', githubRef)).toBeNull() + }) + + it('returns null on 404', async () => { + mockList({ message: 'Not Found' } as unknown as unknown[], 404) + expect(await fetchLatestRelease('token', githubRef)).toBeNull() + }) + + it('carries assets from the winning release', async () => { + mockList([ghRelease('v1.0.0-beta.5'), ghRelease('v1.0.0-beta.7')]) + const got = await fetchLatestRelease('token', githubRef) + expect(got?.assets[0]?.url).toContain('v1.0.0-beta.7') + expect(got?.published).toBe(true) + }) +}) + +describe('fetchLatestRelease (gitlab)', () => { + const gitlabRef = refFor('gitlab', 'https://gitlab.com') + + it('ranks by semver across the listing', async () => { + mockList([ + { tag_name: 'v1.0.0-beta.5', assets: { links: [] } }, + { tag_name: 'v1.0.0-beta.7', assets: { links: [] } }, + ]) + expect((await fetchLatestRelease('token', gitlabRef))?.tag).toBe('v1.0.0-beta.7') + }) + + it('returns null on an empty listing', async () => { + mockList([]) + expect(await fetchLatestRelease('token', gitlabRef)).toBeNull() + }) +}) diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index cf76de2..7eeac91 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release geyankt", "plugin_detail_yank_title": "Release v{version} yanken", "plugin_detail_yanked_badge": "geyankt", + "plugin_prerelease_badge": "Vorabversion", "plugins_list_categories": "Kategorien", "plugins_list_clear_filters": "Filter zurücksetzen", "plugins_list_clear_tag": "Tag entfernen", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index a40f22f..181cece 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release yanked", "plugin_detail_yank_title": "Yank release v{version}", "plugin_detail_yanked_badge": "yanked", + "plugin_prerelease_badge": "pre-release", "plugins_list_categories": "Categories", "plugins_list_clear_filters": "Clear filters", "plugins_list_clear_tag": "Clear tag", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index d3cb94b..8df0fc8 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release retirado", "plugin_detail_yank_title": "Retirar release v{version}", "plugin_detail_yanked_badge": "retirado", + "plugin_prerelease_badge": "preversión", "plugins_list_categories": "Categorías", "plugins_list_clear_filters": "Limpiar filtros", "plugins_list_clear_tag": "Quitar etiqueta", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index a3600bf..0ec552b 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release retirée", "plugin_detail_yank_title": "Retirer la release v{version}", "plugin_detail_yanked_badge": "retirée", + "plugin_prerelease_badge": "préversion", "plugins_list_categories": "Catégories", "plugins_list_clear_filters": "Effacer les filtres", "plugins_list_clear_tag": "Retirer le tag", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index affd9ab..1bb1f58 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release ritirata", "plugin_detail_yank_title": "Ritira release v{version}", "plugin_detail_yanked_badge": "ritirata", + "plugin_prerelease_badge": "preversione", "plugins_list_categories": "Categorie", "plugins_list_clear_filters": "Pulisci filtri", "plugins_list_clear_tag": "Rimuovi tag", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 3d0fb12..3bae6a3 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "已撤回发布版本", "plugin_detail_yank_title": "撤回发布版本 v{version}", "plugin_detail_yanked_badge": "已撤回", + "plugin_prerelease_badge": "预发布", "plugins_list_categories": "分类", "plugins_list_clear_filters": "清除筛选", "plugins_list_clear_tag": "清除 tag", diff --git a/apps/frontend/src/lib/components/PluginCard.svelte b/apps/frontend/src/lib/components/PluginCard.svelte index 3887860..5061579 100644 --- a/apps/frontend/src/lib/components/PluginCard.svelte +++ b/apps/frontend/src/lib/components/PluginCard.svelte @@ -1,8 +1,9 @@