fix: render node-update changelog only for the advertised release version (#1493)

The changelog recorded every settled release-notes fetch as loaded for the
advertised version and rendered whatever notes came back, without checking that
the returned version matched. Because the version lookup and the release-notes
lookup use independent caches (and the version can fall back to Docker Hub while
notes are GitHub-only), a drifted response could show one version's notes under
a different advertised update.

The changelog now binds strictly: notes render only when the endpoint confirms
they belong to the advertised latest version; a mismatch falls through to the
empty state with the online changelog link. The settled-version latch is kept so
a mismatch does not loop the fetch.
This commit is contained in:
Anso
2026-06-27 23:29:33 -04:00
committed by GitHub
parent 4cf04056c9
commit dd49ef65aa
2 changed files with 76 additions and 5 deletions
@@ -78,9 +78,16 @@ export function NodeUpdatesSheet({
.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);
// Bind strictly to the advertised update: only render notes the
// endpoint confirms belong to the advertised version. The version
// lookup and the release-notes lookup use independent caches (and
// version can fall back to Docker Hub while notes are GitHub-only),
// so a drifted response must fall through to the empty state with
// the online changelog link rather than show another version's notes.
const matches = data.version !== null && data.version === advertisedLatest;
setReleaseNotes(matches ? data.releaseNotes : null);
setReleaseHtmlUrl(matches ? data.htmlUrl : null);
setReleaseVersion(matches ? data.version : null);
}
})
.catch((err) => {
@@ -54,6 +54,7 @@ describe('NodeUpdatesSheet', () => {
apiFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
version: '1.1.0',
releaseNotes: '## [0.92.0](https://example.com/compare) (2026-06-19)\n\n### Added\n\n* add a toggle ([#1363](https://example.com/issues/1363))\n',
htmlUrl: 'https://example.com/releases/v0.92.0',
}),
@@ -88,7 +89,7 @@ describe('NodeUpdatesSheet', () => {
await screen.findByText('No release notes to show');
expect(releaseCalls()).toBe(1);
rerender(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
// The releaseLoaded latch keeps a null result from re-triggering the fetch.
// The loadedForVersion latch keeps a null result from re-triggering the fetch.
await waitFor(() => expect(releaseCalls()).toBe(1));
});
@@ -147,10 +148,73 @@ describe('NodeUpdatesSheet', () => {
await waitFor(() => expect(releaseCalls()).toBe(1));
});
it('does not render notes whose version differs from the advertised update', async () => {
// Realistic cache offset: the advertised latest is 1.2.0 but the release-notes
// endpoint still returns 1.1.0 (the version lookup and notes lookup use
// independent caches; version can come from Docker Hub while notes are
// GitHub-only). The 1.1.0 notes must never render as the 1.2.0 changelog.
apiFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: 'https://example.com/v1.1.0' }),
});
const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length;
const advertised120 = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' }));
render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog', updateStatuses: advertised120 })} />);
// The mismatched notes fall through to the empty state with the online link.
expect(await screen.findByText('No release notes to show')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument();
expect(screen.queryByText('Release v1.1.0')).not.toBeInTheDocument();
// The whole mismatched payload is discarded, including its GitHub link.
expect(screen.queryByRole('link', { name: /View on GitHub/ })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /View on Sencho/ })).toHaveAttribute('href', 'https://sencho.io/changelog');
// The settled mismatch does not loop the fetch.
await waitFor(() => expect(releaseCalls()).toBe(1));
});
it('recovers via Recheck when the endpoint catches up to the advertised version', async () => {
// Start mismatched (advertised 1.2.0, endpoint still 1.1.0): empty state.
const advertised120 = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' }));
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 () => ({ rechecked: true }) }),
);
render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog', updateStatuses: advertised120 })} />);
expect(await screen.findByText('No release notes to show')).toBeInTheDocument();
// The recheck surfaces the matching 1.2.0 notes; clicking Recheck must
// recover the previously-mismatched changelog rather than stay empty.
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 () => ({ rechecked: true }) }),
);
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }));
expect(await screen.findByRole('heading', { name: 'v1.2.0 notes' })).toBeInTheDocument();
expect(screen.getByText('Release v1.2.0')).toBeInTheDocument();
});
it('shows the empty state without looping when the advertised version is unknown', async () => {
// Local node has no resolved latestVersion: advertisedLatest is null, so a
// non-null endpoint version cannot match and the notes must not render. The
// settle must still record null and not loop the fetch.
const noLatest = STATUSES.map(s => ({ ...s, latestVersion: null }));
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;
render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog', updateStatuses: noLatest })} />);
expect(await screen.findByText('No release notes to show')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument();
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')
? Promise.resolve({ ok: true, json: async () => ({ releaseNotes: '## v1', htmlUrl: null }) })
? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1', htmlUrl: null }) })
: Promise.resolve({ ok: true, json: async () => ({ rechecked: true }) }),
);
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true, initialTab: 'changelog' })} />);