mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(release): show categorized changelog after updates
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user