From 4cf04056c9f5d3487942bba6d91a7b98243b07b5 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 27 Jun 2026 22:38:55 -0400 Subject: [PATCH] fix: bind the node-update changelog to the advertised release version (#1492) The changelog tab fetched release notes once and held them in component state without tying them to a version, and the endpoint did not report which release the notes belonged to. When a newer release surfaced while the sheet stayed mounted, reopening the changelog could show the previous version's notes, and a GitHub/Docker Hub fallback or independent cache timing could leave the notes out of sync with the advertised latest version. The release-notes endpoint now returns the release version (normalized tag_name). The changelog keys its loaded notes to the advertised latest version, refetching when that version changes, and labels the notes with the version they belong to so the displayed content is always explicit. --- .../__tests__/fleet-update-hardening.test.ts | 32 ++++++++ backend/src/routes/fleet.ts | 3 + .../components/FleetView/NodeUpdatesSheet.tsx | 82 ++++++++++++------- .../__tests__/NodeUpdatesSheet.test.tsx | 55 +++++++++++++ 4 files changed, 142 insertions(+), 30 deletions(-) diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index 0db35d1b..edfb2fec 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -276,3 +276,35 @@ describe('forced-recheck throttle', () => { expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)).toBeUndefined(); }); }); + +describe('GET /api/fleet/update-status/release-notes', () => { + // Each case uses ?recheck=true so getLatestRelease force-invalidates the cache + // and fetches fresh, keeping the assertion independent of prior cache state. + it('binds the returned notes to the release version (normalized tag_name)', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + tag_name: 'v0.93.0', + body: '## Notes for 0.93.0', + html_url: 'https://github.com/studio-saelix/sencho/releases/tag/v0.93.0', + }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + const res = await request(app) + .get('/api/fleet/update-status/release-notes?recheck=true') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBe('0.93.0'); + expect(res.body.releaseNotes).toBe('## Notes for 0.93.0'); + expect(res.body.htmlUrl).toContain('v0.93.0'); + }); + + it('returns null fields when the upstream release lookup fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + const res = await request(app) + .get('/api/fleet/update-status/release-notes?recheck=true') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBeNull(); + expect(res.body.releaseNotes).toBeNull(); + expect(res.body.htmlUrl).toBeNull(); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index d276ce0d..4fecaffb 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -1070,6 +1070,9 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ const forceRefresh = req.query.recheck === 'true'; const release = await getLatestRelease(forceRefresh); res.json({ + // Bind the notes to the release they belong to so the frontend can tell + // when the advertised latest version has moved past the loaded notes. + version: release ? release.tag_name.replace(/^v/, '') : null, releaseNotes: release?.body ?? null, htmlUrl: release?.html_url ?? null, }); diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index e447a5dd..3edbb068 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -44,43 +44,58 @@ export function NodeUpdatesSheet({ const [skipLoading, setSkipLoading] = useState(null); const [releaseNotes, setReleaseNotes] = useState(null); const [releaseHtmlUrl, setReleaseHtmlUrl] = useState(null); + const [releaseVersion, setReleaseVersion] = useState(null); const [loadingRelease, setLoadingRelease] = useState(false); - // Tracks whether a release-notes fetch has settled (success or failure), so a - // null result lands on the empty state instead of re-triggering the effect. - const [releaseLoaded, setReleaseLoaded] = useState(false); + // The advertised latest version the loaded notes were fetched against, or + // `undefined` before the first settle (so a null/unknown advertised version + // still triggers the initial fetch). When it no longer matches the advertised + // latest, the notes are refetched so the changelog never shows a previous + // version's notes; settling on any result (including null) records the version + // so a failed fetch lands on the empty state instead of looping. + const [loadedForVersion, setLoadedForVersion] = useState(undefined); const [hasSeenChangelog, setHasSeenChangelog] = useState(false); + // The version the Fleet currently advertises as latest (gateway-derived, same + // as the footer's "Latest version" label). Notes are keyed to this so they + // stay in sync. + const advertisedLatest = (updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0])?.latestVersion ?? null; + useEffect(() => { if (open) setActiveTab(initialTab); }, [open, initialTab]); - // Always fetch release notes when the sheet opens (changelog shows current - // release regardless of update availability). Pass recheck when the user + // Fetch release notes when the sheet opens (changelog shows the current + // release regardless of update availability), and refetch whenever the + // advertised latest version changes so reopening after a newer release + // surfaces its notes, never a previous version's. Pass recheck when the user // forced a version recheck so the changelog stays in sync. useEffect(() => { - if (open && !releaseLoaded && !loadingRelease) { - setLoadingRelease(true); - const recheck = recheckingUpdates ? '?recheck=true' : ''; - apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true }) - .then(res => res.ok ? res.json() as Promise<{ releaseNotes: string | null; htmlUrl: string | null }> : null) - .then(data => { - if (data) { - setReleaseNotes(data.releaseNotes); - setReleaseHtmlUrl(data.htmlUrl); - } - }) - .catch((err) => { - // Informational panel: a failure falls through to the empty - // state (with an online changelog link) rather than a toast, - // but leave a breadcrumb so the failure is diagnosable. - console.warn('[Fleet] Release-notes fetch failed:', err); - }) - .finally(() => { - setLoadingRelease(false); - setReleaseLoaded(true); - }); - } - }, [open, releaseLoaded, loadingRelease, recheckingUpdates]); + if (!open || loadingRelease) return; + if (loadedForVersion === advertisedLatest) return; + setLoadingRelease(true); + const recheck = recheckingUpdates ? '?recheck=true' : ''; + apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true }) + .then(res => res.ok ? res.json() as Promise<{ version: string | null; releaseNotes: string | null; htmlUrl: string | null }> : null) + .then(data => { + if (data) { + setReleaseNotes(data.releaseNotes); + setReleaseHtmlUrl(data.htmlUrl); + setReleaseVersion(data.version); + } + }) + .catch((err) => { + // Informational panel: a failure falls through to the empty + // state (with an online changelog link) rather than a toast, + // but leave a breadcrumb so the failure is diagnosable. + console.warn('[Fleet] Release-notes fetch failed:', err); + }) + .finally(() => { + setLoadingRelease(false); + // Record the advertised version this fetch settled for, so a null + // result does not loop and a later version change forces a refetch. + setLoadedForVersion(advertisedLatest); + }); + }, [open, advertisedLatest, loadedForVersion, loadingRelease, recheckingUpdates]); // Clear the changelog dot when user opens that tab. useEffect(() => { @@ -100,10 +115,12 @@ export function NodeUpdatesSheet({ const handleRecheck = async () => { setRecheckingUpdates(true); - // Force a fresh release-notes fetch with the rechecked version. + // Force a fresh release-notes fetch with the rechecked version: clearing + // loadedForVersion makes the effect's version guard miss and refetch. setReleaseNotes(null); setReleaseHtmlUrl(null); - setReleaseLoaded(false); + setReleaseVersion(null); + setLoadedForVersion(undefined); try { const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true }); if (res.ok) { @@ -270,6 +287,11 @@ export function NodeUpdatesSheet({ ) : releaseNotes ? (
+ {releaseVersion && ( +
+ Release {formatVersion(releaseVersion)} +
+ )} {releaseNotes}
{releaseHtmlUrl && ( diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index eb0d4943..2b30d2e3 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -92,6 +92,61 @@ describe('NodeUpdatesSheet', () => { await waitFor(() => expect(releaseCalls()).toBe(1)); }); + it('binds the changelog to the release version and shows it', async () => { + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## What changed', htmlUrl: null }), + }); + render(); + await screen.findByRole('heading', { name: 'What changed' }); + // The notes are labelled with the version they belong to. + expect(screen.getByText('Release v1.1.0')).toBeInTheDocument(); + }); + + it('refetches release notes when the advertised latest version changes', async () => { + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({}) }), + ); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + expect(releaseCalls()).toBe(1); + + // A newer release surfaces while the sheet is open: the advertised latest + // moves to 1.2.0 and the endpoint now returns its notes. The changelog must + // refetch and show the new version, not the stale 1.1.0 notes. + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.2.0', releaseNotes: '## v1.2.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({}) }), + ); + const bumped = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + rerender(); + + await screen.findByRole('heading', { name: 'v1.2.0 notes' }); + expect(screen.getByText('Release v1.2.0')).toBeInTheDocument(); + // The stale notes are replaced, not appended alongside the new ones. + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + await waitFor(() => expect(releaseCalls()).toBe(2)); + }); + + it('does not refetch loaded notes on re-render when the version is unchanged', async () => { + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }), + }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + expect(releaseCalls()).toBe(1); + // Re-rendering with the same advertised latest version must not refetch: the + // version-keyed guard short-circuits for an already-loaded changelog. + rerender(); + await waitFor(() => expect(releaseCalls()).toBe(1)); + }); + it('Recheck resets and refetches release notes with the recheck flag', async () => { apiFetchMock.mockImplementation((url: string) => String(url).includes('release-notes')