From f929a332bbc5af1be117c96f29e3112b288602b0 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 18 May 2026 15:00:16 +0530 Subject: [PATCH] fix: fix upgrade banner to be triggered after package upload --- ui/src/app/api/config/latest-version/route.ts | 77 +++++++++++++++++++ ui/src/hooks/useLatestReleaseVersion.ts | 22 ++---- 2 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 ui/src/app/api/config/latest-version/route.ts diff --git a/ui/src/app/api/config/latest-version/route.ts b/ui/src/app/api/config/latest-version/route.ts new file mode 100644 index 0000000..0652dd6 --- /dev/null +++ b/ui/src/app/api/config/latest-version/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +const GHCR_IMAGES = ["dograh-hq/dograh-ui", "dograh-hq/dograh-api"] as const; +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; +const REVALIDATE_SECONDS = 60 * 60; + +type Semver = [number, number, number]; + +function parseSemver(tag: string): Semver | null { + const m = tag.match(SEMVER_RE); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function compareSemver(a: Semver, b: Semver): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + +async function fetchLatestTag(image: string): Promise { + const tokenRes = await fetch( + `https://ghcr.io/token?scope=repository:${image}:pull&service=ghcr.io`, + { next: { revalidate: REVALIDATE_SECONDS } }, + ); + if (!tokenRes.ok) return null; + const { token } = (await tokenRes.json()) as { token?: string }; + if (!token) return null; + + const tagsRes = await fetch(`https://ghcr.io/v2/${image}/tags/list`, { + headers: { Authorization: `Bearer ${token}` }, + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!tagsRes.ok) return null; + const { tags } = (await tagsRes.json()) as { tags?: string[] }; + + let latest: { tag: string; parsed: Semver } | null = null; + for (const tag of tags ?? []) { + const parsed = parseSemver(tag); + if (!parsed) continue; + if (!latest || compareSemver(parsed, latest.parsed) > 0) { + latest = { tag, parsed }; + } + } + return latest?.tag ?? null; +} + +export async function GET() { + try { + const results = await Promise.all(GHCR_IMAGES.map(fetchLatestTag)); + + // Only advertise an update once every image has published a tag at that + // version — otherwise we'd nudge users to upgrade before the matching + // container actually exists. + let minLatest: { tag: string; parsed: Semver } | null = null; + for (const tag of results) { + if (!tag) return NextResponse.json({ latest: null }, { status: 200 }); + const parsed = parseSemver(tag); + if (!parsed) return NextResponse.json({ latest: null }, { status: 200 }); + if (!minLatest || compareSemver(parsed, minLatest.parsed) < 0) { + minLatest = { tag, parsed }; + } + } + + return NextResponse.json( + { latest: minLatest?.tag ?? null }, + { + headers: { + "Cache-Control": `public, max-age=${REVALIDATE_SECONDS}, s-maxage=${REVALIDATE_SECONDS}`, + }, + }, + ); + } catch { + return NextResponse.json({ latest: null }, { status: 200 }); + } +} diff --git a/ui/src/hooks/useLatestReleaseVersion.ts b/ui/src/hooks/useLatestReleaseVersion.ts index adabe30..3af534e 100644 --- a/ui/src/hooks/useLatestReleaseVersion.ts +++ b/ui/src/hooks/useLatestReleaseVersion.ts @@ -14,7 +14,7 @@ interface Result { const CACHE_KEY = "dograh-latest-release"; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; -const SEMVER_RE = /^(?:[a-z][a-z0-9-]*-)?v?(\d+)\.(\d+)\.(\d+)$/i; +const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/; function parseSemver(tag: string): [number, number, number] | null { const m = tag.match(SEMVER_RE); @@ -56,11 +56,11 @@ export function useLatestReleaseVersion( } let cancelled = false; - fetch("https://api.github.com/repos/dograh-hq/dograh/releases/latest") + fetch("/api/config/latest-version") .then((res) => (res.ok ? res.json() : null)) .then((data) => { - if (cancelled || !data?.tag_name) return; - const tag = data.tag_name as string; + if (cancelled || !data?.latest) return; + const tag = data.latest as string; try { localStorage.setItem( CACHE_KEY, @@ -72,7 +72,7 @@ export function useLatestReleaseVersion( setLatest(tag); }) .catch(() => { - // silent — don't break the sidebar if GitHub is unreachable + // silent — don't break the sidebar if the lookup fails }); return () => { @@ -80,19 +80,13 @@ export function useLatestReleaseVersion( }; }, [enabled, currentVersion]); - const normalizedCurrent = currentVersion - ? currentVersion.startsWith("v") - ? currentVersion - : `v${currentVersion}` - : null; - - const currentParsed = normalizedCurrent ? parseSemver(normalizedCurrent) : null; + const currentParsed = currentVersion ? parseSemver(currentVersion) : null; const latestParsed = latest ? parseSemver(latest) : null; const isBehind = !!( - normalizedCurrent && + currentVersion && latest && - isOlder(normalizedCurrent, latest) + isOlder(currentVersion, latest) ); const isLatest = !!(