fix(release): show categorized changelog after updates

This commit is contained in:
rcourtman
2026-08-11 15:58:46 +01:00
parent 3826316eec
commit eddd8a1988
8 changed files with 286 additions and 104 deletions
+10 -6
View File
@@ -897,14 +897,18 @@ jobs:
--unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \
--unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}"
# The in-app "What's New" banner only fires when the release body has
# a Highlights section (see frontend-modern/src/components/whatsNewModel.ts).
# Surface which way this release will behave so silence is a choice,
# not an accident.
# Highlights provide the compact pre-update preview. The post-update
# dialog uses categorized changelog entries instead of that summary.
if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then
echo "::notice::Release notes include a Highlights section — the in-app What's New banner will show it after users update."
echo "::notice::Release notes include Highlights — the update banner can preview them before users update."
else
echo "::notice::Release notes have no Highlights section — the in-app What's New banner stays silent for this release (expected for maintenance releases)."
echo "::notice::Release notes have no Highlights — the update banner will not show a summary preview."
fi
if grep -qiE '^#{1,6}[[:space:]]+(added|new features|improved|improvements|changed|fixed|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$' "$RENDERED_NOTES_FILE"; then
echo "::notice::Release notes include categorized changes — the post-update changelog dialog will show them."
else
echo "::notice::Release notes have no categorized changes — the post-update changelog dialog stays silent."
fi
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
@@ -689,14 +689,16 @@ upgrade, update, release, or artifact-selection behavior.
unavailable for source/development builds, and return the canonical release
body without inventing a second changelog source. The update banner may
preview only the curated `Highlights` section from update-check metadata,
while the post-update card may show that same section once per later
installed release and must stay silent for a first baseline, malformed or
development versions, missing releases, and releases without highlights.
When present, `Highlights` is the complete in-app overview: release
rendering must keep it to at most three short plain-text bullets of no more
than 140 characters each, with links, code, issue references, nested
structure, and implementation-oriented detail reserved for the full release
notes.
while the post-update card must render the release's categorized user-facing
change sections (`Added`, `Improved`/`Changed`, `Fixed`, `Security`,
`Breaking changes`, `Deprecated`, or `Removed`) as a changelog once per
later installed release. It must not reuse the Highlights summary as the
post-update content, and must stay silent for a first baseline, malformed or
development versions, missing releases, and releases without categorized
changes. `Highlights` remains a pre-update overview only: release rendering
keeps it to at most three short plain-text bullets of no more than 140
characters each, with links, code, issue references, and nested structure
reserved for the categorized or full release notes.
The same post-update communication boundary owns the one-time schema-v2
telemetry payload notice. It must use a non-blocking shared notice banner,
appear only for existing installations on a published build, stay silent
@@ -1591,14 +1593,15 @@ serves their route, scope, payload, pairing, and push requirements, including
the relay-mobile Patrol attention scopes corrected after rc.4. No companion
build upload or public mobile-store rollout is part of this server release.
The same release boundary now provides one canonical in-app release-note
experience. Update checks can preview a curated `Highlights` section, and an
authenticated running-version endpoint lets the update surface show those
same published highlights once after a later upgrade. Missing highlights stay
quiet by design, and source or development builds never masquerade as
published releases. Post-update highlights are limited to three short,
plain-text user outcomes and use the shared dialog so the overview stays easy
to scan without pushing the dashboard down; every close path still records the
running release as seen.
experience. Update checks can preview a curated `Highlights` section before an
update, while the authenticated running-version endpoint lets the post-update
surface extract the published `Added`, `Improved`/`Changed`, `Fixed`,
`Security`, `Breaking changes`, `Deprecated`, and `Removed` sections into a
categorized changelog after an upgrade. Summary-only or uncategorized releases
stay quiet in the post-update dialog, and source or development builds never
masquerade as published releases. The shared scrollable dialog keeps the
categories readable without pushing the dashboard down; every close path still
records the running release as seen.
The initial GA promotion
metadata remains
`promoted_from_tag=v6.0.0-rc.7`, `rollback_version=v5.1.35`,
+19 -29
View File
@@ -1,34 +1,23 @@
{
"version": 1,
"base_sha": "c45a950a74c11d299b1be6277a8b552e87f9c5d9",
"verified_at": "2026-08-11T10:30:03Z",
"base_sha": "3826316eec488cef2561eb64397f225979a0adc1",
"verified_at": "2026-08-11T14:57:15Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/resourceOperatorState.ts",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
"frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx",
"frontend-modern/src/utils/resourceMonitoringPolicy.ts"
"frontend-modern/src/components/WhatsNewCard.tsx",
"frontend-modern/src/components/whatsNewModel.ts"
],
"content_sha256": {
"frontend-modern/src/api/resourceOperatorState.ts": "2b27d3dcc3415cc2a57b9bd201d06f04150ede3b29d91816ce3715a065eb8459",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "9b757063cbd6c0b97ab0057754d45ad2e2067ad58f4ec0b388c7ae28e51d8239",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "3c5008b4db7027a7a08c8dff796a7546b5b343a522bf7439361467c0031318f3",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f0339dc3c3fdd2e08f2c5fea75a62a0a5ecdfab833970653bf9f74982c4ba891",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "83081abb871d76e5531bf66ddcddb47171c05c86c1d91249a0e9c6ef5dad73dd",
"frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx": "287ff35169c4082f9cb8cac3e36bcbaaab9e343801c497e89da8602d9af276d2",
"frontend-modern/src/utils/resourceMonitoringPolicy.ts": "ba5133bbdeb57a236845f3ee946f0f0c13518e293810eec1ae7e4fab2ab3e2d5"
"frontend-modern/src/components/WhatsNewCard.tsx": "f7f905d0e08b56c98b908653ec663332dc9d140d4e82b6d20c9428be6bbd5b15",
"frontend-modern/src/components/whatsNewModel.ts": "d6aaf56335e9cb1505e5f574c51f2d396636db850f2162fbc56cf61330df5cc5"
},
"routes": [
"/alerts",
"/proxmox/overview"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 900
},
{
"width": 390,
@@ -36,17 +25,18 @@
}
],
"states": [
"Active powered-off Proxmox guest alert with the Monitoring policy menu open and provider ownership copy visible.",
"Normal monitoring restored, powered-off detector alert reactivated, then Expected offline applied from that alert and the alert removed immediately.",
"Proxmox guest drawer with Monitoring and Lifecycle controls showing Expected offline and provider-owned retirement guidance.",
"Retired lifecycle selected with monitoring disabled and the retained-inventory, history, attention, and automation notice visible.",
"Mobile Alerts overview with the policy menu open, all four choices reachable, and main content clientWidth and scrollWidth both 362 pixels."
"Post-update Pulse v6.2.2 changelog open with Added, Improved, and Fixed sections while Highlights and Release Qualification are absent.",
"Desktop dialog fully visible with all categorized entries fitting without page clipping or horizontal overflow.",
"Mobile dialog open at the top with internal vertical scrolling and no horizontal overflow.",
"Mobile dialog scrolled to the final Fixed entry with the close action still reachable.",
"Dialog dismissed with Escape, reopened, and dismissed by clicking the backdrop."
],
"interactions": [
"Opened the Monitoring menu from a live powered-off alert and confirmed Proxmox remains the inventory owner.",
"Applied Expected offline from the alert, observed the success notification, and confirmed the alert disappeared immediately while the active count decreased.",
"Opened dev-portal-01 in the Proxmox guest drawer, changed Monitoring between Normal and Expected offline, and saved through the canonical resource policy.",
"Changed Lifecycle to Retired, confirmed the retirement notice and disabled monitoring control, then restored Active while preserving Expected offline.",
"Repeated the Alerts policy-menu inspection at 390 by 844 pixels and confirmed there was no horizontal content overflow."
"Triggered the post-update dialog from a simulated completed v6.2.2 update and inspected the rendered changelog content.",
"Resized from 1440 by 900 pixels to 390 by 844 pixels and confirmed responsive containment.",
"Scrolled the changelog body to the bottom and confirmed the final entry remained readable.",
"Dismissed with Escape and confirmed page scrolling was restored.",
"Reopened the dialog and dismissed it by clicking outside the panel.",
"Inspected browser warning and error logs and found none."
]
}
+12 -12
View File
@@ -10,7 +10,7 @@ import { Dialog } from '@/components/shared/Dialog';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
import { InlineNotice } from '@/components/shared/InlineNotice';
import { buildReleaseNotesUrl, normalizeReleaseVersion } from '@/components/updateVersion';
import { extractHighlights, isReleaseVersion } from '@/components/whatsNewModel';
import { extractChangelog, isReleaseVersion } from '@/components/whatsNewModel';
import { renderMarkdown } from '@/components/AI/aiChatUtils';
import { t } from '@/i18n';
import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
@@ -56,9 +56,9 @@ const markTelemetryPayloadNoticeSeen = () => {
/**
* Post-update "What's New" dialog. Shows once after the running version
* changes, and only when that release has a curated `## Highlights` section
* in its GitHub release notes. Dismissing (or a highlights-free release)
* records the version so the dialog stays quiet until the next update.
* changes, and only when that release has categorized user-facing changelog
* entries in its GitHub release notes. Dismissing (or a release without those
* entries) records the version so the dialog stays quiet until the next update.
*
* This release communication boundary also owns the one-time, non-blocking
* telemetry schema v2 notice. Existing installations see it once; fresh
@@ -69,7 +69,7 @@ export function WhatsNewCard() {
const [visible, setVisible] = createSignal(false);
const [telemetryNoticeVisible, setTelemetryNoticeVisible] = createSignal(false);
const [version, setVersion] = createSignal('');
const [highlightsHtml, setHighlightsHtml] = createSignal('');
const [changelogHtml, setChangelogHtml] = createSignal('');
const hadPriorReleaseBaseline = readLastSeenVersion() !== null;
let checked = false;
let telemetryNoticeChecked = false;
@@ -82,12 +82,12 @@ export function WhatsNewCard() {
if (normalizeReleaseVersion(notes.version) !== currentVersion) {
return;
}
const highlights = extractHighlights(notes.releaseNotes);
if (!highlights) {
const changelog = extractChangelog(notes.releaseNotes);
if (!changelog) {
markVersionSeen(currentVersion);
return;
}
setHighlightsHtml(renderMarkdown(highlights));
setChangelogHtml(renderMarkdown(changelog));
setVersion(currentVersion);
setVisible(true);
} catch (error) {
@@ -244,9 +244,9 @@ export function WhatsNewCard() {
id="whats-new-title"
class="text-lg font-semibold text-base-content truncate"
>
What's new in v{version()}
Pulse v{version()} changelog
</h2>
<p class="text-xs text-muted">Pulse updated successfully</p>
<p class="text-xs text-muted">What changed in this release</p>
</div>
</div>
<ActionIconButton
@@ -263,9 +263,9 @@ export function WhatsNewCard() {
</div>
<div
class="px-6 py-4 max-h-[60vh] overflow-y-auto text-sm text-base-content [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-2 [&_p]:mt-2 [&_a]:underline [&_code]:font-mono [&_code]:text-xs"
class="px-6 py-4 max-h-[60vh] overflow-y-auto text-sm text-base-content [&_h3]:mt-5 [&_h3:first-child]:mt-0 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-2 [&_p]:mt-2 [&_a]:underline [&_code]:font-mono [&_code]:text-xs"
// eslint-disable-next-line solid/no-innerhtml -- renderMarkdown sanitizes via DOMPurify
innerHTML={highlightsHtml()}
innerHTML={changelogHtml()}
/>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-between gap-3">
@@ -126,7 +126,7 @@ describe('WhatsNewCard', () => {
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
});
it('shows curated highlights after the running release changes', async () => {
it('shows the categorized changelog after the running release changes', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
@@ -135,7 +135,19 @@ describe('WhatsNewCard', () => {
});
getReleaseNotesMock.mockResolvedValue({
version: 'v6.1.0-rc.1',
releaseNotes: '## Highlights\n- Reviewed Actions inbox\n\n## Changes\n- Internal work',
releaseNotes: [
'## Highlights',
'- General agent improvements',
'',
'## Added',
'- Actions now has a dedicated inbox for approvals.',
'',
'## Fixed',
'- Acknowledged alerts now stay dismissed after refresh.',
'',
'## Release Qualification',
'- Internal work',
].join('\n'),
releaseDate: '2026-07-13T12:00:00Z',
isPrerelease: true,
});
@@ -146,8 +158,16 @@ describe('WhatsNewCard', () => {
expect(screen.getByTestId('whats-new-modal')).toBeInTheDocument();
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText("What's new in v6.1.0-rc.1")).toBeInTheDocument();
expect(screen.getByText('Reviewed Actions inbox')).toBeInTheDocument();
expect(screen.getByText('Pulse v6.1.0-rc.1 changelog')).toBeInTheDocument();
expect(screen.getByText('Added')).toBeInTheDocument();
expect(
screen.getByText('Actions now has a dedicated inbox for approvals.'),
).toBeInTheDocument();
expect(screen.getByText('Fixed')).toBeInTheDocument();
expect(
screen.getByText('Acknowledged alerts now stay dismissed after refresh.'),
).toBeInTheDocument();
expect(screen.queryByText('General agent improvements')).not.toBeInTheDocument();
expect(screen.queryByText('Internal work')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Full release notes →' })).toHaveAttribute(
'href',
@@ -164,7 +184,7 @@ describe('WhatsNewCard', () => {
});
getReleaseNotesMock.mockResolvedValue({
version: '6.1.0-rc.1',
releaseNotes: '## Highlights\n- Reviewed Actions inbox',
releaseNotes: '## Added\n- Actions now has a dedicated inbox for approvals.',
releaseDate: '2026-07-13T12:00:00Z',
isPrerelease: true,
});
@@ -180,6 +200,28 @@ describe('WhatsNewCard', () => {
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
});
it('stays quiet when a release has only a highlights summary', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
isSourceBuild: false,
});
getReleaseNotesMock.mockResolvedValue({
version: '6.1.0-rc.1',
releaseNotes: '## Highlights\n- General improvements',
releaseDate: '2026-07-13T12:00:00Z',
isPrerelease: true,
});
await renderCard();
await waitFor(() => {
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
});
expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument();
});
it('stays quiet for development builds', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
versionInfoMock.mockReturnValue({
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractHighlights, isReleaseVersion } from '../whatsNewModel';
import { extractChangelog, extractHighlights, isReleaseVersion } from '../whatsNewModel';
import whatsNewCardSource from '../WhatsNewCard.tsx?raw';
describe('extractHighlights', () => {
@@ -82,6 +82,93 @@ describe('extractHighlights', () => {
});
});
describe('extractChangelog', () => {
it('builds a categorized changelog and excludes the highlights summary', () => {
const body = [
'# Pulse v6.2.1 Release Notes',
'',
'## Highlights',
'- Agent improvements and fixes.',
'',
'## Added',
'- Plans & Billing is now available before Pro activation.',
'',
'## Improved',
'- Update results show when the last check ran.',
'',
'## Fixed',
'- Agent downloads now work when the server redirects the request (#1696).',
'',
'## Upgrade Notes',
'Use the normal update flow.',
].join('\n');
expect(extractChangelog(body)).toBe(
[
'### Added',
'',
'- Plans & Billing is now available before Pro activation.',
'',
'### Improved',
'',
'- Update results show when the last check ran.',
'',
'### Fixed',
'',
'- Agent downloads now work when the server redirects the request (#1696).',
].join('\n'),
);
});
it('normalizes generated section names into stable changelog labels', () => {
const body = [
'## v6.2.2',
'### New Features',
'- Add certificate expiry monitoring.',
'### Improvements',
'- Make storage filters easier to scan.',
'### Bug Fixes',
'- Keep acknowledged alerts dismissed after refresh.',
].join('\n');
expect(extractChangelog(body)).toBe(
[
'### Added',
'',
'- Add certificate expiry monitoring.',
'',
'### Improved',
'',
'- Make storage filters easier to scan.',
'',
'### Fixed',
'',
'- Keep acknowledged alerts dismissed after refresh.',
].join('\n'),
);
});
it('preserves nested details inside a recognized category', () => {
const body = [
'## Fixed',
'### Proxmox',
'- Storage rows no longer disappear after refresh.',
'## Installation',
'- Not part of the changelog.',
].join('\n');
expect(extractChangelog(body)).toBe(
'### Fixed\n\n### Proxmox\n- Storage rows no longer disappear after refresh.',
);
});
it('returns null for summaries and release metadata without change categories', () => {
expect(
extractChangelog('## Highlights\n- Faster updates.\n\n## Installation\n- Pull the image.'),
).toBeNull();
});
});
describe('isReleaseVersion', () => {
it('accepts published release versions', () => {
expect(isReleaseVersion('4.13.0')).toBe(true);
+69 -24
View File
@@ -1,9 +1,29 @@
// Model logic for the post-update "What's New" banner.
//
// The banner only ever shows the release's "Highlights" section — a curated,
// user-facing summary — never the full changelog. Releases without a
// Highlights section stay silent, so patch releases full of internal fixes
// don't nag anyone.
// Model logic for the release preview and post-update changelog.
type Heading = {
index: number;
level: number;
title: string;
};
const headingMatch = (line: string) => line.trim().match(/^(#{1,6})\s+(.*)$/);
const headingsIn = (lines: string[]): Heading[] =>
lines.flatMap((line, index) => {
const match = headingMatch(line);
return match ? [{ index, level: match[1].length, title: match[2].trim() }] : [];
});
const sectionBody = (lines: string[], headings: Heading[], headingIndex: number): string => {
const heading = headings[headingIndex];
const nextHeading = headings
.slice(headingIndex + 1)
.find((candidate) => candidate.level <= heading.level);
return lines
.slice(heading.index + 1, nextHeading?.index ?? lines.length)
.join('\n')
.trim();
};
/**
* Extract the contents of the `## Highlights` section from a GitHub release
@@ -12,30 +32,55 @@
*/
export const extractHighlights = (markdown: string): string | null => {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const headingMatch = (line: string) => line.trim().match(/^(#{1,6})\s+(.*)$/);
const startIdx = lines.findIndex((line) => {
const match = headingMatch(line);
return !!match && /^highlights\b/i.test(match[2].trim());
});
if (startIdx === -1) {
const headings = headingsIn(lines);
const headingIndex = headings.findIndex((heading) => /^highlights\b/i.test(heading.title));
if (headingIndex === -1) {
return null;
}
const startLevel = headingMatch(lines[startIdx])![1].length;
const section: string[] = [];
for (let i = startIdx + 1; i < lines.length; i++) {
const match = headingMatch(lines[i]);
if (match && match[1].length <= startLevel) {
break;
}
section.push(lines[i]);
}
const content = section.join('\n').trim();
const content = sectionBody(lines, headings, headingIndex);
return content || null;
};
const CHANGELOG_SECTION_LABELS: Readonly<Record<string, string>> = {
added: 'Added',
'new features': 'Added',
improved: 'Improved',
improvements: 'Improved',
changed: 'Changed',
fixed: 'Fixed',
'bug fixes': 'Fixed',
security: 'Security',
'breaking changes': 'Breaking changes',
deprecated: 'Deprecated',
removed: 'Removed',
};
/**
* Build the post-update changelog from the user-facing change categories in a
* published release body. Highlights are intentionally excluded: they remain
* a compact pre-update preview, while this view tells users what actually
* changed under recognizable changelog headings.
*/
export const extractChangelog = (markdown: string): string | null => {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const headings = headingsIn(lines);
const sections: string[] = [];
headings.forEach((heading, headingIndex) => {
const normalizedTitle = heading.title.replace(/\s+/g, ' ').toLowerCase();
const label = CHANGELOG_SECTION_LABELS[normalizedTitle];
if (!label) return;
const content = sectionBody(lines, headings, headingIndex);
if (content) {
sections.push(`### ${label}\n\n${content}`);
}
});
return sections.length > 0 ? sections.join('\n\n') : null;
};
// Dev builds carry -dirty or a -g<hash> suffix; they never correspond to a
// published release, so the banner should stay quiet for them.
export const isReleaseVersion = (version: string): boolean => {
+22 -11
View File
@@ -78,24 +78,35 @@ internal fixes or minor patches), OMIT this entire section — that deliberately
keeps the in-app banner silent for maintenance releases. Keep the heading at
level 3 (###).]
### New Features
[Genuinely new user-facing capabilities. Be specific about what users can now do.]
### Added
[Genuinely new user-facing capabilities. Name the page, workflow, integration,
or platform where users will find each one, then say what they can now do.]
### Bug Fixes
[Fixes for problems users would have encountered. Include issue refs like
(#1234) only when the fix verifiably addresses that issue.]
### Improved
[Meaningful changes to existing behavior. Name the affected experience and the
observable improvement; do not summarize several unrelated changes together.]
### Improvements
[Enhancements to existing features.]
### Fixed
[Problems users would have encountered. State the visible symptom that no
longer happens, not the internal cause. Include issue refs like (#1234) only
when the fix verifiably addresses that issue.]
Guidelines:
- Plain, factual, understated. No marketing language, no emojis.
- Omit any section that has no items.
- Do NOT write an Installation section or anything after Improvements — the
- Every Added, Improved, and Fixed bullet must stand on its own as a concrete
changelog entry. A reader should understand where they would notice the
change and what is different without knowing Pulse's implementation.
- Avoid internal release and architecture vocabulary such as canonical,
governed, schema, provider transport, preflight, convergence, or runtime
boundary unless that exact term is visible to the user in the product.
- Do not use vague entries such as "improved agent handling" or "various UI
fixes". Split unrelated changes and name the behavior that changed.
- Do NOT write an Installation section or anything after Fixed — the
release pipeline appends those.
- Highlights is the ONE exception to "boring": it is shown in-app to users who
just updated, so make it the shortest useful explanation of what changed —
still factual, with no hype.
- Highlights is the ONE exception to "boring": it is shown in-app before users
update, so make it the shortest useful preview of what changed — still
factual, with no hype.
Your reply must be ONLY the release-notes markdown, starting with
"## v${VERSION}" — no preamble, no code fences, no commentary.