diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0f9921f99..2897cee00 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 89820bdee..474c62758 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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`, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 79b01c0f3..a08e401f7 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/frontend-modern/src/components/WhatsNewCard.tsx b/frontend-modern/src/components/WhatsNewCard.tsx index 23c746593..b04e16165 100644 --- a/frontend-modern/src/components/WhatsNewCard.tsx +++ b/frontend-modern/src/components/WhatsNewCard.tsx @@ -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 -

Pulse updated successfully

+

What changed in this release

diff --git a/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx index 177d4959b..a7081ce25 100644 --- a/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx +++ b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx @@ -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({ diff --git a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts index 8bf467099..839e1b2c7 100644 --- a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts +++ b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts @@ -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); diff --git a/frontend-modern/src/components/whatsNewModel.ts b/frontend-modern/src/components/whatsNewModel.ts index 4cbc03970..630905f0c 100644 --- a/frontend-modern/src/components/whatsNewModel.ts +++ b/frontend-modern/src/components/whatsNewModel.ts @@ -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> = { + 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 suffix; they never correspond to a // published release, so the banner should stay quiet for them. export const isReleaseVersion = (version: string): boolean => { diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 0f44b4db0..833159f9c 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -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.