diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index 6de97a5f4..334e6fd37 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -725,6 +725,16 @@ jobs:
--hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \
--hotfix-reason "${{ needs.prepare.outputs.hotfix_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.
+ 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."
+ 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)."
+ fi
+
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
- name: Locate existing release
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index b6c443eb5..2f76da18e 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -208,7 +208,12 @@ update, or rollback transport. The server updater's own downgrade guard and
`POST /api/updates/rollback` backup-restore endpoint are equally server
self-update plumbing: they roll the Pulse server binary and its local
backups, never agent binaries, and agent lifecycle surfaces must not key
-enrollment, update liveness, or fleet-control semantics off them. Workflow starter counts on that endpoint,
+enrollment, update liveness, or fleet-control semantics off them.
+The authenticated `GET /api/updates/release-notes` projection follows the same
+server-only boundary: it reads the exact published notes for the running Pulse
+server release and must not be used as agent version, enrollment, update
+availability, or fleet rollout evidence.
+Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to
diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index 328de800a..32758d5d0 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -2863,6 +2863,11 @@ query...`, and `Reading storage...` before streamed tool arguments are
call the operator's choice `Patrol mode`.
7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts`
8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state
+ The shared app shell may also mount the deployment-installability-owned
+ post-update release highlights card. That card must consume published
+ release notes through the update surface and must not read, reset, or
+ otherwise couple itself to Assistant sessions, Patrol state, model routing,
+ or AI-provider readiness.
That shared drawer ownership also covers passive resource reads while the
shell is mounted but closed. `frontend-modern/src/components/AI/Chat/`
may consume the live websocket snapshot or the existing unified-resource
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 0d0c38df4..f2833a25e 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -1634,6 +1634,12 @@ payload shape change when the portal presents compact client rows.
target, so installer preflight failures point operators at the artifact
they actually need.
85. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
+ `GET /api/updates/release-notes` is the authenticated running-release
+ companion to update checks. It returns the exact published tag body as
+ `version`, `releaseNotes`, `releaseDate`, and `isPrerelease`, rejects
+ source/development builds, and keeps missing releases distinct from
+ upstream failures. `frontend-modern/src/api/updates.ts` must preserve that
+ payload without creating a frontend-authored changelog shape.
86. `pkg/aicontracts/action_broker.go` shared with `ai-runtime`: the public typed action-proposal broker contract is both an AI runtime proposal boundary (the only sanctioned Patrol route to an infrastructure mutation) and a canonical API dependency contract over the shared action lifecycle service.
The updater registry behind `GET /api/updates/plan` is a plan-provider
seam only (`SupportsApply`, `PrepareUpdate`, `GetDeploymentType`); apply
diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index 0891670e6..c9f868fa8 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -875,6 +875,11 @@ the App/AppLayout, routing, and desktop Actions journey tests.
Relay tier remains a tangible standalone paid product.
18. Add contract tests where runtime and pricing need to stay aligned
19. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts`
+ The shared app shell may mount the deployment-installability-owned
+ post-update release highlights card next to existing global banners, but
+ that card must remain independent of plan, entitlement, organization, and
+ hosted bootstrap state. It must not turn release communication into a
+ commercial prompt or add another capability probe to the shell.
That same hosted bootstrap boundary also owns the runtime-capability JSON
shape that the app shell consumes before it decides whether organization
chrome and multi-tenant routes exist. `pkg/licensing/entitlement_payload.go`
diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md
index 34a9e45fd..d5b93e584 100644
--- a/docs/release-control/v6/internal/subsystems/deployment-installability.md
+++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md
@@ -32,6 +32,10 @@ TLS floor in the dynamic config.
1. `internal/updates/`
2. `internal/api/updates.go`
3. `frontend-modern/src/api/updates.ts`
+4. `frontend-modern/src/components/UpdateBanner.tsx`
+5. `frontend-modern/src/components/WhatsNewCard.tsx`
+6. `frontend-modern/src/components/whatsNewModel.ts`
+7. `frontend-modern/src/utils/localStorage.ts`
4. `cmd/pulse-control-plane/main.go`
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
6. `cmd/pulse-control-plane/provider_msp.go`
@@ -72,6 +76,7 @@ TLS floor in the dynamic config.
34. `go.mod`
35. `go.sum`
36. `scripts/build-release.sh`
+37. `scripts/generate-release-notes.sh`
37. `scripts/check-workflow-dispatch-inputs.py`
38. `scripts/clean-mock-alerts.sh`
39. `scripts/com.pulse.hot-dev.plist.template`
@@ -479,7 +484,13 @@ TLS floor in the dynamic config.
must preserve server-derived `owner_user_id` lineage on bootstrap tokens and
enrollment runtime tokens while keeping deploy binding metadata limited to
deploy facts such as cluster, job, target, source agent, and expected node.
-4. Add or change server update transport through `internal/api/updates.go`, `internal/updates/`, and `frontend-modern/src/api/updates.ts`
+4. Add or change server update transport and release-note presentation through
+ `internal/api/updates.go`, `internal/updates/`,
+ `frontend-modern/src/api/updates.ts`,
+ `frontend-modern/src/components/UpdateBanner.tsx`,
+ `frontend-modern/src/components/WhatsNewCard.tsx`,
+ `frontend-modern/src/components/whatsNewModel.ts`, and
+ `frontend-modern/src/utils/localStorage.ts`
Server update planning must attach the canonical upgrade-readiness verdict
to `/api/updates/plan` responses before an operator starts a v6 update, and
`POST /api/updates/apply` must recompute the same verdict and reject
@@ -492,6 +503,14 @@ TLS floor in the dynamic config.
unreadable token state and warning about missing, expired, or soon-expiring
agent reporting scopes without pretending shell-only inspection can prove
live registered-agent continuity.
+ The authenticated running-release notes endpoint must fetch only the exact
+ published tag for the running release, cache both hits and misses, stay
+ 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.
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
First-run browser helpers are part of that dev-runtime proof boundary. They
must preserve the setup-created API token in the shared runtime state, prefer
@@ -977,16 +996,23 @@ host-local redirect contract as runtime token minting and exchange. Proof input
must reject absolute, scheme-relative, backslash-authority, encoded-separator,
and control-character targets before constructing the handoff request.
-The active support prerelease `v6.0.6-rc.1` cut sets the repo-root `VERSION`,
+The active support prerelease `v6.1.0-rc.1` cut sets the repo-root `VERSION`,
repo-root `docker-compose.yml` image default, `scripts/install-docker.sh`
-fallback, and Helm chart release metadata to the same `6.0.6-rc.1` release
+fallback, and Helm chart release metadata to the same `6.1.0-rc.1` release
version. This support prerelease keeps `rollback_version=v6.0.5`, publishes a
versioned public GitHub prerelease plus versioned Docker and Helm artifacts, and
does not move stable/latest install pointers or stable semver aliases. It puts
-the typed Pulse Intelligence lifecycle, monitor-first product workflows,
-native-agent update safety, Windows logged-readiness and recovery proof,
-OIDC callback recovery, and fail-closed security hardening behind RC
-validation before the next stable patch.
+the expanded Pulse Intelligence action and verification lifecycle, the
+operator-facing Actions inbox, monitor-first product workflows, governed host
+and storage operations, native-agent update safety, Windows logged-readiness
+and recovery proof, OIDC callback recovery, and fail-closed security hardening
+behind RC validation before the next stable minor 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.
The initial GA promotion
metadata remains
`promoted_from_tag=v6.0.0-rc.7`, `rollback_version=v5.1.35`,
@@ -1040,8 +1066,8 @@ compose image default, standalone installer fallback constant, and packaged
Helm metadata. A draft release workflow failure caused by stale image or chart
pins is a release-packet blocker until the defaults, tests, and evidence
record are refreshed from the new branch head.
-For the active support prerelease `v6.0.6-rc.1` cut, the repo-root compose
-default and `scripts/install-docker.sh` fallback must both pin `6.0.6-rc.1`
+For the active support prerelease `v6.1.0-rc.1` cut, the repo-root compose
+default and `scripts/install-docker.sh` fallback must both pin `6.1.0-rc.1`
until the next governed stable cut moves them forward. The stable promotion
guard remains in force and must reject leftover `-rc.` defaults when the
governed `VERSION` returns to a stable release.
diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json
index dec9f51e8..042109e04 100644
--- a/docs/release-control/v6/internal/subsystems/registry.json
+++ b/docs/release-control/v6/internal/subsystems/registry.json
@@ -3529,6 +3529,10 @@
"frontend-modern/package-lock.json",
"frontend-modern/package.json",
"frontend-modern/src/api/updates.ts",
+ "frontend-modern/src/components/UpdateBanner.tsx",
+ "frontend-modern/src/components/WhatsNewCard.tsx",
+ "frontend-modern/src/components/whatsNewModel.ts",
+ "frontend-modern/src/utils/localStorage.ts",
"frontend-modern/vite.config.ts",
"go.mod",
"go.sum",
@@ -3552,6 +3556,7 @@
"scripts/dev-deploy-agent.sh",
"scripts/dev-launchd-setup.sh",
"scripts/dev-launchd-wrapper.sh",
+ "scripts/generate-release-notes.sh",
"scripts/hot-dev-bg.sh",
"scripts/hot-dev.sh",
"scripts/install-container-agent.sh",
@@ -3675,7 +3680,12 @@
"docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md",
"docs/RELEASE_NOTES.md",
"docs/UPGRADE_v6.md",
+ "frontend-modern/src/components/UpdateBanner.tsx",
+ "frontend-modern/src/components/WhatsNewCard.tsx",
+ "frontend-modern/src/components/whatsNewModel.ts",
+ "frontend-modern/src/utils/localStorage.ts",
"scripts/check-workflow-dispatch-inputs.py",
+ "scripts/generate-release-notes.sh",
"scripts/release_control/internal/record_rc_to_ga_rehearsal.py",
"scripts/release_control/mobile_release_gate.py",
"scripts/release_control/record_rc_to_ga_rehearsal.py",
@@ -3691,6 +3701,7 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 04dec7d65..a1c1d2ce8 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -1206,6 +1206,11 @@ recovery scope, or a storage/recovery-owned secret source.
aliases.
27. Keep alert-side recovery drill-ins on that same embedded-owner route-state contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through an owning platform/runtime destination using canonical recovery query vocabulary instead of freezing alert-local recovery URLs, reviving the retired Recovery aggregate route, or introducing another provider-shaped recovery handoff vocabulary.
28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
+ The shared route-registration file may also expose the authenticated
+ running-release notes endpoint for deployment/update presentation. That
+ route remains deployment-installability and API-contract owned; storage
+ and recovery must not treat release-note availability or publication time
+ as protection freshness, recovery evidence, or restore state.
29. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface.
The same shared unified-resource boundary also covers canonical
Resource.Uptime fallback on the consumer side. When
diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx
index d53eb5992..976382308 100644
--- a/frontend-modern/src/App.tsx
+++ b/frontend-modern/src/App.tsx
@@ -7,6 +7,7 @@ import { SecurityWarning } from './components/SecurityWarning';
import { Login } from './components/Login';
import { logger } from './utils/logger';
import { UpdateBanner } from './components/UpdateBanner';
+import { WhatsNewCard } from './components/WhatsNewCard';
import { DemoBanner } from './components/DemoBanner';
import { CommercialMigrationBanner } from './components/CommercialMigrationBanner';
import { GitHubStarBanner } from './components/GitHubStarBanner';
@@ -480,6 +481,7 @@ function App() {
+
diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts
index 5636b1fa5..3c850dd16 100644
--- a/frontend-modern/src/__tests__/App.architecture.test.ts
+++ b/frontend-modern/src/__tests__/App.architecture.test.ts
@@ -207,6 +207,8 @@ describe('App architecture', () => {
);
expect(appSource).toContain(' aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
+ expect(appSource).toContain("import { WhatsNewCard } from './components/WhatsNewCard';");
+ expect(appSource).toContain('');
expect(appSource).not.toContain('TrialBanner');
expect(appSource).not.toContain('MonitoredSystemLimitWarningBanner');
expect(appSource).not.toContain('monitoredSystemLimitWarningBanner');
@@ -420,7 +422,8 @@ describe('App architecture', () => {
expect(appRuntimeStateSource).toContain('aiChatStore.setEnabled(');
expect(appRuntimeStateSource).toContain('aiIntelligenceStore.loadPatrolFindings()');
expect(appRuntimeStateSource).toContain('aiIntelligenceStore.loadPendingApprovals()');
- expect(appRuntimeStateSource).toContain('window.setInterval(refreshPatrolOpenWork, 30000)');
+ expect(appRuntimeStateSource).toContain('actionInboxStore.loadPendingActionCount()');
+ expect(appRuntimeStateSource).toContain('window.setInterval(refreshOpenWorkBadges, 30000)');
expect(appRuntimeStateSource).toContain(
"eventBus.on('theme_changed', handleRemoteThemeChange);",
);
diff --git a/frontend-modern/src/api/__tests__/updates.test.ts b/frontend-modern/src/api/__tests__/updates.test.ts
index 57c1c273a..799f0738c 100644
--- a/frontend-modern/src/api/__tests__/updates.test.ts
+++ b/frontend-modern/src/api/__tests__/updates.test.ts
@@ -32,6 +32,19 @@ describe('UpdatesAPI', () => {
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/version');
});
+ it('fetches release notes for the running version', async () => {
+ const response = {
+ version: '6.1.0-rc.1',
+ releaseNotes: '## Highlights\n- Reviewed actions',
+ releaseDate: '2026-07-13T12:00:00Z',
+ isPrerelease: true,
+ };
+ apiFetchJSONMock.mockResolvedValueOnce(response as any);
+
+ await expect(UpdatesAPI.getReleaseNotes()).resolves.toEqual(response);
+ expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/updates/release-notes');
+ });
+
it('encodes optional update-check channel safely', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ available: false } as any);
await UpdatesAPI.checkForUpdates('rc');
@@ -105,7 +118,8 @@ describe('UpdatesAPI', () => {
id: 'agent-token-scopes',
status: 'blocked',
title: 'Agent token scopes',
- summary: 'Registered agents exist, but no loaded API token grants agent reporting scope.',
+ summary:
+ 'Registered agents exist, but no loaded API token grants agent reporting scope.',
},
],
},
diff --git a/frontend-modern/src/api/updates.ts b/frontend-modern/src/api/updates.ts
index 259529fb1..5a547faf0 100644
--- a/frontend-modern/src/api/updates.ts
+++ b/frontend-modern/src/api/updates.ts
@@ -26,6 +26,13 @@ export interface DockerUpdateCommands {
composeUpCommand: string;
}
+export interface ReleaseNotesInfo {
+ version: string;
+ releaseNotes: string;
+ releaseDate: string;
+ isPrerelease: boolean;
+}
+
export interface UpdateStatus {
status: string;
progress: number;
@@ -149,6 +156,10 @@ export class UpdatesAPI {
return apiFetchJSON('/api/version');
}
+ static async getReleaseNotes(): Promise {
+ return apiFetchJSON('/api/updates/release-notes');
+ }
+
static async getUpdatePlan(version: string, channel?: UpdateChannel): Promise {
const validatedVersion = requireNonEmpty(version, 'Version');
const search = new URLSearchParams({ version: validatedVersion });
diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx
index 963d206c3..95ed7a7d1 100644
--- a/frontend-modern/src/components/UpdateBanner.tsx
+++ b/frontend-modern/src/components/UpdateBanner.tsx
@@ -5,7 +5,9 @@ import { UpdatesAPI, type UpdatePlan } from '@/api/updates';
import { UpdateConfirmationModal } from './UpdateConfirmationModal';
import { copyToClipboard } from '@/utils/clipboard';
import { logger } from '@/utils/logger';
-import { buildReleaseNotesUrl } from '@/components/updateVersion';
+import { buildReleaseNotesUrl, normalizeReleaseVersion } from '@/components/updateVersion';
+import { extractHighlights } from '@/components/whatsNewModel';
+import { renderMarkdown } from '@/components/AI/aiChatUtils';
// The Pro binary self-updates from the license server download broker (see
// internal/updates/pro_update.go), so in-app apply keeps the Pro runtime.
@@ -55,6 +57,16 @@ export function UpdateBanner() {
buildReleaseNotesUrl(updateStore.updateInfo()?.latestVersion),
);
+ // Curated `## Highlights` section of the upcoming release, if the release
+ // author wrote one — helps answer "is this update worth taking now?"
+ // without leaving for GitHub. Empty when absent, and the block hides.
+ const highlightsHtml = createMemo(() => {
+ const info = updateStore.updateInfo();
+ if (!info?.available || !info.releaseNotes) return '';
+ const highlights = extractHighlights(info.releaseNotes);
+ return highlights ? renderMarkdown(highlights) : '';
+ });
+
// The compiled Pro binary self-updates from the license server download
// broker, so in-app apply is safe and keeps the Pro runtime; only the
// manual instructions differ (community pull/console steps would install
@@ -268,12 +280,27 @@ export function UpdateBanner() {
{updateStore.updateInfo()?.latestVersion}
+ {/* What's new preview (curated Highlights section of the release notes) */}
+
+
+
+ What's new in v
+ {normalizeReleaseVersion(updateStore.updateInfo()?.latestVersion)}
+
+
+
+
+
{/* Pro edition with in-app apply: updates install the private
Pulse Pro build from the license server */}
- Updates install the private Pulse Pro build from the license server, so
- applying keeps Pro features (Audit, RBAC, Reporting, SSO).
+ Updates install the private Pulse Pro build from the license server, so applying
+ keeps Pro features (Audit, RBAC, Reporting, SSO).
diff --git a/frontend-modern/src/components/WhatsNewCard.tsx b/frontend-modern/src/components/WhatsNewCard.tsx
new file mode 100644
index 000000000..fb00049b1
--- /dev/null
+++ b/frontend-modern/src/components/WhatsNewCard.tsx
@@ -0,0 +1,148 @@
+import { Show, createEffect, createSignal } from 'solid-js';
+import { updateStore } from '@/stores/updates';
+import { UpdatesAPI } from '@/api/updates';
+import { STORAGE_KEYS } from '@/utils/localStorage';
+import { buildReleaseNotesUrl, normalizeReleaseVersion } from '@/components/updateVersion';
+import { extractHighlights, isReleaseVersion } from '@/components/whatsNewModel';
+import { renderMarkdown } from '@/components/AI/aiChatUtils';
+import { logger } from '@/utils/logger';
+
+const readLastSeenVersion = (): string | null => {
+ try {
+ return localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN);
+ } catch {
+ return null;
+ }
+};
+
+const markVersionSeen = (version: string) => {
+ try {
+ localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, version);
+ } catch {
+ // Private mode / storage disabled: the banner simply won't persist state.
+ }
+};
+
+/**
+ * Post-update "What's New" banner. 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 banner stays quiet until the next update.
+ */
+export function WhatsNewCard() {
+ const [visible, setVisible] = createSignal(false);
+ const [version, setVersion] = createSignal('');
+ const [highlightsHtml, setHighlightsHtml] = createSignal('');
+ let checked = false;
+
+ const loadNotes = async (currentVersion: string) => {
+ try {
+ const notes = await UpdatesAPI.getReleaseNotes();
+ // Mid-update the backend can briefly disagree with the UI about the
+ // running version; don't show notes for the wrong release.
+ if (normalizeReleaseVersion(notes.version) !== currentVersion) {
+ return;
+ }
+ const highlights = extractHighlights(notes.releaseNotes);
+ if (!highlights) {
+ markVersionSeen(currentVersion);
+ return;
+ }
+ setHighlightsHtml(renderMarkdown(highlights));
+ setVersion(currentVersion);
+ setVisible(true);
+ } catch (error) {
+ if ((error as { status?: number }).status === 404) {
+ // No published release for this build — stop asking.
+ markVersionSeen(currentVersion);
+ return;
+ }
+ // Transient failure: leave last-seen untouched so the next load retries.
+ logger.warn("Failed to load release notes for What's New banner", error);
+ }
+ };
+
+ createEffect(() => {
+ const info = updateStore.versionInfo();
+ if (!info || checked) return;
+ checked = true;
+
+ if (info.isDevelopment || info.isSourceBuild || !isReleaseVersion(info.version)) {
+ return;
+ }
+
+ const currentVersion = normalizeReleaseVersion(info.version);
+ if (!currentVersion) return;
+
+ const lastSeen = readLastSeenVersion();
+ if (!lastSeen) {
+ // First run (fresh install or first load after this feature shipped):
+ // record the baseline silently instead of greeting users with a banner.
+ markVersionSeen(currentVersion);
+ return;
+ }
+ if (normalizeReleaseVersion(lastSeen) === currentVersion) {
+ return;
+ }
+
+ void loadNotes(currentVersion);
+ });
+
+ const dismiss = () => {
+ markVersionSeen(version());
+ setVisible(false);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx
new file mode 100644
index 000000000..16a57a4cc
--- /dev/null
+++ b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx
@@ -0,0 +1,121 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { STORAGE_KEYS } from '@/utils/localStorage';
+
+const versionInfoMock = vi.hoisted(() => vi.fn());
+const getReleaseNotesMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/stores/updates', () => ({
+ updateStore: {
+ versionInfo: () => versionInfoMock(),
+ },
+}));
+
+vi.mock('@/api/updates', () => ({
+ UpdatesAPI: {
+ getReleaseNotes: () => getReleaseNotesMock(),
+ },
+}));
+
+vi.mock('@/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+describe('WhatsNewCard', () => {
+ beforeEach(() => {
+ versionInfoMock.mockReset();
+ getReleaseNotesMock.mockReset();
+ localStorage.clear();
+ });
+
+ afterEach(cleanup);
+
+ async function renderCard() {
+ const { WhatsNewCard } = await import('../WhatsNewCard');
+ render(() => );
+ }
+
+ it('records the first published version silently as the baseline', async () => {
+ versionInfoMock.mockReturnValue({
+ version: '6.1.0-rc.1',
+ isDevelopment: false,
+ isSourceBuild: false,
+ });
+
+ await renderCard();
+
+ expect(screen.queryByTestId('whats-new-banner')).not.toBeInTheDocument();
+ expect(getReleaseNotesMock).not.toHaveBeenCalled();
+ expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
+ });
+
+ it('shows curated highlights 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',
+ isDevelopment: false,
+ isSourceBuild: false,
+ });
+ getReleaseNotesMock.mockResolvedValue({
+ version: 'v6.1.0-rc.1',
+ releaseNotes: '## Highlights\n- Reviewed Actions inbox\n\n## Changes\n- Internal work',
+ releaseDate: '2026-07-13T12:00:00Z',
+ isPrerelease: true,
+ });
+
+ await renderCard();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('whats-new-banner')).toBeInTheDocument();
+ });
+ expect(
+ screen.getByText("Pulse updated to v6.1.0-rc.1 — here's what's new"),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Reviewed Actions inbox')).toBeInTheDocument();
+ expect(screen.queryByText('Internal work')).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Full release notes →' })).toHaveAttribute(
+ 'href',
+ 'https://github.com/rcourtman/Pulse/releases/tag/v6.1.0-rc.1',
+ );
+ });
+
+ it('persists dismissal for the current release', 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- Reviewed Actions inbox',
+ releaseDate: '2026-07-13T12:00:00Z',
+ isPrerelease: true,
+ });
+
+ await renderCard();
+ await waitFor(() => expect(screen.getByText('Got it')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Got it'));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('whats-new-banner')).not.toBeInTheDocument();
+ });
+ expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
+ });
+
+ it('stays quiet for development builds', async () => {
+ localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
+ versionInfoMock.mockReturnValue({
+ version: '6.1.0-rc.1-dirty',
+ isDevelopment: true,
+ isSourceBuild: false,
+ });
+
+ await renderCard();
+
+ expect(screen.queryByTestId('whats-new-banner')).not.toBeInTheDocument();
+ expect(getReleaseNotesMock).not.toHaveBeenCalled();
+ expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.0.5');
+ });
+});
diff --git a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts
new file mode 100644
index 000000000..cc1fc2fec
--- /dev/null
+++ b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { extractHighlights, isReleaseVersion } from '../whatsNewModel';
+
+describe('extractHighlights', () => {
+ it('extracts the Highlights section from a release body', () => {
+ const body = [
+ 'Intro paragraph.',
+ '',
+ '## Highlights',
+ '- New Docker container update flow',
+ '- Faster dashboard loading',
+ '',
+ '## Full changelog',
+ '- Fix process leak in host agent command executor',
+ ].join('\n');
+
+ expect(extractHighlights(body)).toBe(
+ '- New Docker container update flow\n- Faster dashboard loading',
+ );
+ });
+
+ // Locks the contract with scripts/generate-release-notes.sh, whose LLM
+ // template emits "### Highlights" as the first level-3 section.
+ it('handles the generated release-notes format', () => {
+ const body = [
+ '## v6.0.6',
+ '',
+ '### Highlights',
+ '- Post-update What’s New banner',
+ '- Faster dashboard loading',
+ '',
+ '### New Features',
+ '- Something for the changelog reader',
+ '',
+ '### Bug Fixes',
+ '- Fix a thing (#1234)',
+ '',
+ '---',
+ '',
+ '## Installation',
+ '...',
+ ].join('\n');
+
+ expect(extractHighlights(body)).toBe(
+ '- Post-update What’s New banner\n- Faster dashboard loading',
+ );
+ });
+
+ it('stops at the next heading of the same or higher level', () => {
+ const body = [
+ '## Highlights',
+ '### Monitoring',
+ '- Zappi surplus alerts',
+ '## Other changes',
+ '- internal refactor',
+ ].join('\n');
+
+ expect(extractHighlights(body)).toBe('### Monitoring\n- Zappi surplus alerts');
+ });
+
+ it('matches the heading case-insensitively and at any level', () => {
+ const body = '### HIGHLIGHTS\n- something';
+ expect(extractHighlights(body)).toBe('- something');
+ });
+
+ it('handles CRLF line endings from the GitHub editor', () => {
+ const body = '## Highlights\r\n- one\r\n- two\r\n\r\n## Rest';
+ expect(extractHighlights(body)).toBe('- one\n- two');
+ });
+
+ it('returns null when there is no Highlights section', () => {
+ expect(extractHighlights('## Changelog\n- fix things')).toBeNull();
+ });
+
+ it('returns null when the Highlights section is empty', () => {
+ expect(extractHighlights('## Highlights\n\n## Changelog\n- fix')).toBeNull();
+ });
+
+ it('does not match headings that merely contain the word later', () => {
+ expect(extractHighlights('## Not the Highlights\n- nope')).toBeNull();
+ });
+});
+
+describe('isReleaseVersion', () => {
+ it('accepts published release versions', () => {
+ expect(isReleaseVersion('4.13.0')).toBe(true);
+ expect(isReleaseVersion('v4.13.0')).toBe(true);
+ expect(isReleaseVersion('4.13.0-rc.1')).toBe(true);
+ });
+
+ it('rejects dev and dirty builds', () => {
+ expect(isReleaseVersion('4.13.0-dirty')).toBe(false);
+ expect(isReleaseVersion('v4.13.0-3-g1a2b3c4')).toBe(false);
+ expect(isReleaseVersion('development')).toBe(false);
+ expect(isReleaseVersion('4.13')).toBe(false);
+ expect(isReleaseVersion('4.13.0-preview')).toBe(false);
+ expect(isReleaseVersion('')).toBe(false);
+ });
+});
diff --git a/frontend-modern/src/components/whatsNewModel.ts b/frontend-modern/src/components/whatsNewModel.ts
new file mode 100644
index 000000000..4cbc03970
--- /dev/null
+++ b/frontend-modern/src/components/whatsNewModel.ts
@@ -0,0 +1,44 @@
+// 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.
+
+/**
+ * Extract the contents of the `## Highlights` section from a GitHub release
+ * body. Returns null when the section is missing or empty, which callers
+ * treat as "nothing worth announcing".
+ */
+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) {
+ 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();
+ return content || 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 => {
+ const trimmed = version.trim();
+ return /^v?\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/.test(trimmed);
+};
diff --git a/frontend-modern/src/stores/updates.ts b/frontend-modern/src/stores/updates.ts
index 07fbadc73..add4cf855 100644
--- a/frontend-modern/src/stores/updates.ts
+++ b/frontend-modern/src/stores/updates.ts
@@ -219,8 +219,7 @@ const clearPendingApply = () => {
saveState(state);
};
-const formatVersionLabel = (version: string) =>
- version.startsWith('v') ? version : `v${version}`;
+const formatVersionLabel = (version: string) => (version.startsWith('v') ? version : `v${version}`);
// One-time post-update confirmation: consume the pending-apply marker on the
// first version fetch after an apply. When the running version moved off the
@@ -449,12 +448,15 @@ export const updateStore = {
clearDismissed,
// Manual testing helpers
- simulateUpdate: (version: string = 'v6.0.0') => {
+ simulateUpdate: (
+ version: string = 'v6.0.0',
+ releaseNotes: string = '## Highlights\n- Preview the release highlights shown to users',
+ ) => {
setUpdateInfo({
available: true,
currentVersion: versionInfo()?.version || 'v6.0.0',
latestVersion: version,
- releaseNotes: 'Test update notification',
+ releaseNotes,
releaseDate: new Date().toISOString(),
downloadUrl: '#',
isPrerelease: false,
diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts
index f7b6f4921..1d63a88ec 100644
--- a/frontend-modern/src/utils/localStorage.ts
+++ b/frontend-modern/src/utils/localStorage.ts
@@ -165,6 +165,7 @@ export const STORAGE_KEYS = {
// Updates
UPDATES: 'pulse-updates',
+ WHATS_NEW_LAST_SEEN: 'pulseWhatsNewLastSeen',
// Alert settings
ALERT_HISTORY_TIME_FILTER: 'alertHistoryTimeFilter',
diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go
index 9c7737c61..33027d056 100644
--- a/internal/api/route_inventory_test.go
+++ b/internal/api/route_inventory_test.go
@@ -452,6 +452,7 @@ var allRouteAllowlist = []string{
"/api/updates/apply",
"/api/updates/rollback",
"/api/updates/status",
+ "/api/updates/release-notes",
"/api/updates/stream",
"/api/updates/plan",
"/api/updates/history",
diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go
index 8bb34f403..65d6337c7 100644
--- a/internal/api/router_routes_registration.go
+++ b/internal/api/router_routes_registration.go
@@ -129,6 +129,9 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
r.mux.HandleFunc("/api/updates/stream", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStream)))
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
+ // Release notes for the running version are readable by any authenticated
+ // user (public GitHub data) so the What's New card works for non-admins.
+ r.mux.HandleFunc("/api/updates/release-notes", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, updateHandlers.HandleGetReleaseNotes)))
r.mux.HandleFunc("/api/updates/history", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleListUpdateHistory)))
r.mux.HandleFunc("/api/updates/history/entry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdateHistoryEntry)))
// Config management routes
diff --git a/internal/api/updates.go b/internal/api/updates.go
index 08c641c38..d4d7bbda4 100644
--- a/internal/api/updates.go
+++ b/internal/api/updates.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -22,14 +23,15 @@ const applyUpdateStartAckTimeout = 250 * time.Millisecond
// UpdateHandlers handles update-related API requests
type UpdateHandlers struct {
- manager UpdateManager
- history *updates.UpdateHistory
- registry *updates.UpdaterRegistry
- statusRateLimits map[string]time.Time // IP -> last request time
- statusMu sync.RWMutex
- getConfig func(context.Context) *config.Config
- getHostsSnapshot func(context.Context) []models.Host
- now func() time.Time
+ manager UpdateManager
+ history *updates.UpdateHistory
+ registry *updates.UpdaterRegistry
+ statusRateLimits map[string]time.Time // IP -> last request time
+ statusMu sync.RWMutex
+ getConfig func(context.Context) *config.Config
+ getHostsSnapshot func(context.Context) []models.Host
+ getCurrentVersion func() (*updates.VersionInfo, error)
+ now func() time.Time
}
// UpdateManager defines the interface for update management operations
@@ -67,11 +69,12 @@ func NewUpdateHandlersWithContext(manager UpdateManager, history *updates.Update
}
h := &UpdateHandlers{
- manager: manager,
- history: history,
- registry: registry,
- statusRateLimits: make(map[string]time.Time),
- now: time.Now,
+ manager: manager,
+ history: history,
+ registry: registry,
+ statusRateLimits: make(map[string]time.Time),
+ getCurrentVersion: updates.GetCurrentVersion,
+ now: time.Now,
}
// Start periodic cleanup of rate limit map
@@ -122,6 +125,55 @@ func (h *UpdateHandlers) HandleCheckUpdates(w http.ResponseWriter, r *http.Reque
}
}
+// releaseNotesProvider is implemented by update managers that can fetch
+// release notes for a specific published version.
+type releaseNotesProvider interface {
+ GetReleaseNotes(ctx context.Context, version string) (*updates.ReleaseNotesInfo, error)
+}
+
+// HandleGetReleaseNotes returns the GitHub release notes for the currently
+// running version. Unlike the other update endpoints it is exposed to all
+// authenticated users so the UI can show a post-update "What's New" card.
+func (h *UpdateHandlers) HandleGetReleaseNotes(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ provider, ok := h.manager.(releaseNotesProvider)
+ if !ok {
+ http.Error(w, "Release notes not available", http.StatusNotFound)
+ return
+ }
+
+ versionInfo, err := h.getCurrentVersion()
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get version info for release notes")
+ http.Error(w, "Failed to get version info", http.StatusInternalServerError)
+ return
+ }
+ if versionInfo.IsDevelopment || versionInfo.IsSourceBuild {
+ http.Error(w, "Release notes not available for this build", http.StatusNotFound)
+ return
+ }
+
+ notes, err := provider.GetReleaseNotes(r.Context(), versionInfo.Version)
+ if err != nil {
+ if errors.Is(err, updates.ErrReleaseNotFound) {
+ http.Error(w, "Release not found", http.StatusNotFound)
+ return
+ }
+ log.Warn().Err(err).Str("version", versionInfo.Version).Msg("Failed to fetch release notes")
+ http.Error(w, "Failed to fetch release notes", http.StatusBadGateway)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(notes); err != nil {
+ log.Error().Err(err).Msg("Failed to encode release notes")
+ }
+}
+
// HandleApplyUpdate handles update application requests
func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go
index 280d461b5..4b1375d85 100644
--- a/internal/api/updates_test.go
+++ b/internal/api/updates_test.go
@@ -18,6 +18,7 @@ import (
// MockUpdateManager implements UpdateManager interface for testing
type MockUpdateManager struct {
CheckForUpdatesFunc func(ctx context.Context, channel string) (*updates.UpdateInfo, error)
+ GetReleaseNotesFunc func(ctx context.Context, version string) (*updates.ReleaseNotesInfo, error)
ApplyUpdateFunc func(ctx context.Context, req updates.ApplyUpdateRequest) error
RollbackToBackupFunc func(ctx context.Context, req updates.RollbackRequest) error
GetStatusFunc func() updates.UpdateStatus
@@ -26,6 +27,13 @@ type MockUpdateManager struct {
RemoveSSEClientFunc func(clientID string)
}
+func (m *MockUpdateManager) GetReleaseNotes(ctx context.Context, version string) (*updates.ReleaseNotesInfo, error) {
+ if m.GetReleaseNotesFunc != nil {
+ return m.GetReleaseNotesFunc(ctx, version)
+ }
+ return nil, updates.ErrReleaseNotFound
+}
+
func (m *MockUpdateManager) CheckForUpdatesWithChannel(ctx context.Context, channel string) (*updates.UpdateInfo, error) {
if m.CheckForUpdatesFunc != nil {
return m.CheckForUpdatesFunc(ctx, channel)
@@ -139,6 +147,61 @@ func TestHandleCheckUpdates_InvalidChannel(t *testing.T) {
}
}
+func TestHandleGetReleaseNotes(t *testing.T) {
+ publishedAt := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
+ mockManager := &MockUpdateManager{
+ GetReleaseNotesFunc: func(_ context.Context, version string) (*updates.ReleaseNotesInfo, error) {
+ if version != "6.1.0-rc.1" {
+ t.Fatalf("expected running version 6.1.0-rc.1, got %q", version)
+ }
+ return &updates.ReleaseNotesInfo{
+ Version: version,
+ ReleaseNotes: "## Highlights\n- Reviewed actions",
+ ReleaseDate: publishedAt,
+ IsPrerelease: true,
+ }, nil
+ },
+ }
+
+ h := NewUpdateHandlers(mockManager, nil)
+ h.getCurrentVersion = func() (*updates.VersionInfo, error) {
+ return &updates.VersionInfo{Version: "6.1.0-rc.1"}, nil
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/api/updates/release-notes", nil)
+
+ h.HandleGetReleaseNotes(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var info updates.ReleaseNotesInfo
+ if err := json.NewDecoder(w.Body).Decode(&info); err != nil {
+ t.Fatalf("decode release notes response: %v", err)
+ }
+ if info.Version != "6.1.0-rc.1" || info.ReleaseNotes == "" || !info.IsPrerelease {
+ t.Fatalf("unexpected release notes response: %+v", info)
+ }
+ if !info.ReleaseDate.Equal(publishedAt) {
+ t.Fatalf("expected release date %v, got %v", publishedAt, info.ReleaseDate)
+ }
+}
+
+func TestHandleGetReleaseNotesRejectsDevelopmentBuild(t *testing.T) {
+ h := NewUpdateHandlers(&MockUpdateManager{}, nil)
+ h.getCurrentVersion = func() (*updates.VersionInfo, error) {
+ return &updates.VersionInfo{Version: "6.1.0-rc.1+git.1", IsDevelopment: true}, nil
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/api/updates/release-notes", nil)
+
+ h.HandleGetReleaseNotes(w, r)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected status 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
func TestHandleApplyUpdate_Success(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
diff --git a/internal/updates/manager.go b/internal/updates/manager.go
index 1ea11aeac..6e33ec286 100644
--- a/internal/updates/manager.go
+++ b/internal/updates/manager.go
@@ -186,6 +186,10 @@ type Manager struct {
checkCache map[string]*UpdateInfo // keyed by channel
cacheTime map[string]time.Time // keyed by channel
cacheDuration time.Duration
+ notesCache *ReleaseNotesInfo // release notes for notesCacheTag (nil when notesCacheMiss)
+ notesCacheTag string
+ notesCacheMiss bool
+ notesCacheTime time.Time
progressChan chan UpdateStatus
sseBroadcast *SSEBroadcaster
lifecycleMu sync.RWMutex
diff --git a/internal/updates/release_notes.go b/internal/updates/release_notes.go
new file mode 100644
index 000000000..ab20229dc
--- /dev/null
+++ b/internal/updates/release_notes.go
@@ -0,0 +1,125 @@
+package updates
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
+)
+
+// ErrReleaseNotFound indicates no published GitHub release exists for the
+// requested version tag.
+var ErrReleaseNotFound = errors.New("release not found")
+
+const (
+ releaseNotesCacheDuration = 24 * time.Hour
+ releaseNotesMissCacheDuration = time.Hour
+)
+
+// ReleaseNotesInfo carries the release notes for a specific published version.
+type ReleaseNotesInfo struct {
+ Version string `json:"version"`
+ ReleaseNotes string `json:"releaseNotes"`
+ ReleaseDate time.Time `json:"releaseDate"`
+ IsPrerelease bool `json:"isPrerelease"`
+}
+
+func resolveReleaseByTagURL(tag string) (*url.URL, error) {
+ baseURL, err := securityutil.NormalizeHTTPBaseURL(updateReleaseAPIBaseURL(), "https")
+ if err != nil {
+ return nil, fmt.Errorf("invalid update server base URL: %w", err)
+ }
+
+ target, err := securityutil.ResolveRelativeURL(baseURL, updateReleaseAPIPath()+"/tags/"+url.PathEscape(tag))
+ if err != nil {
+ return nil, fmt.Errorf("build release notes URL: %w", err)
+ }
+
+ return target, nil
+}
+
+// GetReleaseNotes fetches the GitHub release notes for a specific version tag.
+// Results (including "no such release") are cached so repeated UI requests
+// don't burn GitHub API quota.
+func (m *Manager) GetReleaseNotes(ctx context.Context, version string) (*ReleaseNotesInfo, error) {
+ trimmed := strings.TrimSpace(version)
+ if trimmed == "" {
+ return nil, fmt.Errorf("version is required")
+ }
+ tag := "v" + strings.TrimPrefix(trimmed, "v")
+
+ m.statusMu.RLock()
+ if m.notesCacheTag == tag {
+ if m.notesCacheMiss && time.Since(m.notesCacheTime) < releaseNotesMissCacheDuration {
+ m.statusMu.RUnlock()
+ return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, tag)
+ }
+ if m.notesCache != nil && time.Since(m.notesCacheTime) < releaseNotesCacheDuration {
+ cached := m.notesCache
+ m.statusMu.RUnlock()
+ return cached, nil
+ }
+ }
+ m.statusMu.RUnlock()
+
+ target, err := resolveReleaseByTagURL(tag)
+ if err != nil {
+ return nil, err
+ }
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := m.getWithRetry(ctx, client, target, map[string]string{
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": "Pulse-Update-Checker",
+ }, "fetch release notes")
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch release notes: %w", err)
+ }
+ defer resp.Body.Close()
+
+ switch {
+ case resp.StatusCode == http.StatusNotFound:
+ m.cacheReleaseNotes(tag, nil)
+ return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, tag)
+ case resp.StatusCode == http.StatusForbidden:
+ return nil, fmt.Errorf("%w: fetching release notes for %s", errGitHubRateLimited, tag)
+ case resp.StatusCode != http.StatusOK:
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ detail := strings.TrimSpace(string(body))
+ if detail == "" {
+ detail = resp.Status
+ }
+ return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail)
+ }
+
+ var release ReleaseInfo
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseFeedBytes)).Decode(&release); err != nil {
+ return nil, fmt.Errorf("failed to decode release notes: %w", err)
+ }
+
+ info := &ReleaseNotesInfo{
+ Version: strings.TrimPrefix(release.TagName, "v"),
+ ReleaseNotes: release.Body,
+ ReleaseDate: release.PublishedAt,
+ IsPrerelease: release.Prerelease,
+ }
+ m.cacheReleaseNotes(tag, info)
+
+ return info, nil
+}
+
+func (m *Manager) cacheReleaseNotes(tag string, info *ReleaseNotesInfo) {
+ m.statusMu.Lock()
+ m.notesCacheTag = tag
+ m.notesCache = info
+ m.notesCacheMiss = info == nil
+ m.notesCacheTime = time.Now()
+ m.statusMu.Unlock()
+}
diff --git a/internal/updates/release_notes_test.go b/internal/updates/release_notes_test.go
new file mode 100644
index 000000000..2a9b8dd9c
--- /dev/null
+++ b/internal/updates/release_notes_test.go
@@ -0,0 +1,98 @@
+package updates
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+)
+
+func newReleaseNotesServer(t *testing.T, tag string, release *ReleaseInfo, hitCount *int32) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != updateReleaseAPIPath()+"/tags/"+tag {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ if hitCount != nil {
+ atomic.AddInt32(hitCount, 1)
+ }
+ if release == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(release)
+ }))
+}
+
+func TestGetReleaseNotes_FetchesByTagAndCaches(t *testing.T) {
+ var hits int32
+ release := &ReleaseInfo{
+ TagName: "v4.13.0",
+ Name: "v4.13.0",
+ Body: "## Highlights\n- Something shiny",
+ Prerelease: false,
+ PublishedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
+ }
+
+ server := newReleaseNotesServer(t, "v4.13.0", release, &hits)
+ defer server.Close()
+ t.Setenv("PULSE_UPDATE_SERVER", server.URL)
+
+ manager := NewManager(&config.Config{UpdateChannel: "stable"})
+
+ // Version without the "v" prefix must resolve to the v-prefixed tag.
+ info, err := manager.GetReleaseNotes(context.Background(), "4.13.0")
+ if err != nil {
+ t.Fatalf("GetReleaseNotes returned error: %v", err)
+ }
+ if info.Version != "4.13.0" {
+ t.Fatalf("expected version 4.13.0, got %q", info.Version)
+ }
+ if info.ReleaseNotes != release.Body {
+ t.Fatalf("expected release notes %q, got %q", release.Body, info.ReleaseNotes)
+ }
+ if !info.ReleaseDate.Equal(release.PublishedAt) {
+ t.Fatalf("expected release date %v, got %v", release.PublishedAt, info.ReleaseDate)
+ }
+
+ if _, err := manager.GetReleaseNotes(context.Background(), "v4.13.0"); err != nil {
+ t.Fatalf("cached GetReleaseNotes returned error: %v", err)
+ }
+ if got := atomic.LoadInt32(&hits); got != 1 {
+ t.Fatalf("expected 1 GitHub request after caching, got %d", got)
+ }
+}
+
+func TestGetReleaseNotes_NotFoundIsCached(t *testing.T) {
+ var hits int32
+ server := newReleaseNotesServer(t, "v9.9.9", nil, &hits)
+ defer server.Close()
+ t.Setenv("PULSE_UPDATE_SERVER", server.URL)
+
+ manager := NewManager(&config.Config{UpdateChannel: "stable"})
+
+ for i := 0; i < 2; i++ {
+ _, err := manager.GetReleaseNotes(context.Background(), "9.9.9")
+ if !errors.Is(err, ErrReleaseNotFound) {
+ t.Fatalf("attempt %d: expected ErrReleaseNotFound, got %v", i+1, err)
+ }
+ }
+ if got := atomic.LoadInt32(&hits); got != 1 {
+ t.Fatalf("expected 1 GitHub request after negative caching, got %d", got)
+ }
+}
+
+func TestGetReleaseNotes_RequiresVersion(t *testing.T) {
+ manager := NewManager(&config.Config{UpdateChannel: "stable"})
+ if _, err := manager.GetReleaseNotes(context.Background(), " "); err == nil {
+ t.Fatal("expected error for empty version")
+ }
+}
diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh
index 7abdcae98..5f1471e47 100755
--- a/scripts/generate-release-notes.sh
+++ b/scripts/generate-release-notes.sh
@@ -1,7 +1,21 @@
#!/usr/bin/env bash
-# Generate release notes using LLM analysis of actual code diffs (not commit messages)
-# Usage: ./scripts/generate-release-notes.sh [previous-tag]
+# Generate release notes with an agent that explores the actual repo history.
+#
+# Engine: headless Claude Code (`claude -p`, uses your Claude subscription —
+# no API key), with Codex CLI (`codex exec`, OpenAI subscription) as fallback.
+# The agent runs read-only git/gh commands itself instead of being fed
+# pre-chewed diff fragments, so nothing user-visible is missed by grep luck.
+#
+# Usage: ./scripts/generate-release-notes.sh [previous-tag]
+#
+# Contract: the release notes markdown is written to STDOUT (trigger-release.sh
+# captures it); all progress/diagnostics go to STDERR. SAVE_TO_FILE=1 also
+# writes release-notes-v.md.
+#
+# Env overrides:
+# RELEASE_NOTES_ENGINE=claude|codex force an engine (default: claude, codex fallback)
+# RELEASE_NOTES_MODEL= model for the claude engine (default: sonnet)
set -euo pipefail
@@ -9,353 +23,153 @@ VERSION=${1:-}
PREVIOUS_TAG=${2:-}
if [ -z "$VERSION" ]; then
- echo "Usage: $0 [previous-tag]"
- echo "Example: $0 4.29.0 v4.28.0"
+ echo "Usage: $0 [previous-tag]" >&2
+ echo "Example: $0 6.1.0 v6.0.6" >&2
exit 1
fi
-# Find previous tag if not specified
+cd "$(git rev-parse --show-toplevel)"
+
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
- echo "No previous tag found, cannot generate diff-based release notes"
+ echo "No previous tag found, cannot generate diff-based release notes" >&2
exit 1
fi
fi
-echo "Generating release notes for v${VERSION}..."
-echo "Comparing code changes from ${PREVIOUS_TAG} to HEAD..."
-
-# Get diff stats (excluding non-user-facing files)
-DIFF_STAT=$(git diff ${PREVIOUS_TAG}..HEAD --stat \
- -- ':!*.md' ':!*.test.go' ':!*_test.go' ':!*_test.tsx' ':!*_test.ts' \
- ':!.github/*' ':!tests/*' ':!docs/*' ':!*.txt' ':!*.json' ':!go.sum' \
- ':!frontend-modern/src/**/__tests__/*' \
- | tail -20)
-
-# Get list of changed user-facing files
-CHANGED_FILES=$(git diff ${PREVIOUS_TAG}..HEAD --name-only \
- -- ':!*.md' ':!*.test.go' ':!*_test.go' ':!*_test.tsx' ':!*_test.ts' \
- ':!.github/*' ':!tests/*' ':!docs/*' ':!*.txt' ':!go.sum' \
- ':!frontend-modern/src/**/__tests__/*' \
- | head -100)
-
-# Get specific diffs for key user-facing areas (truncated for API limits)
-
-# API routes/handlers - new endpoints
-API_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'internal/api/*.go' ':!*_test.go' \
- | grep -E '^\+.*func.*Handle|^\+.*router\.(GET|POST|PUT|DELETE|PATCH)|^\+.*\.Path\(' \
- | head -30 || echo "")
-
-# Frontend pages and components - new features
-FRONTEND_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'frontend-modern/src/components/*.tsx' 'frontend-modern/src/pages/*.tsx' \
- ':!*_test.tsx' ':!*__tests__*' \
- | grep -E '^\+.*export|^\+.*function.*\(|^\+.*const.*=' \
- | head -40 || echo "")
-
-# Config options - new settings users can configure
-CONFIG_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'internal/config/*.go' ':!*_test.go' \
- | grep -E '^\+.*`json:|^\+.*`yaml:' \
- | head -20 || echo "")
-
-# Notifications/alerts - webhook changes, alert features
-ALERT_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'internal/notifications/*.go' 'internal/alerts/*.go' ':!*_test.go' \
- | grep -E '^\+' \
- | head -30 || echo "")
-
-# Agent changes - host/docker agent features
-AGENT_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'cmd/pulse-agent/*.go' 'internal/agent/*.go' ':!*_test.go' \
- | grep -E '^\+' \
- | head -20 || echo "")
-
-# Install script changes
-INSTALL_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'scripts/install.sh' 'install.sh' \
- | grep -E '^\+' \
- | head -20 || echo "")
-
-# Models/types - new data structures
-MODELS_DIFF=$(git diff ${PREVIOUS_TAG}..HEAD -- 'internal/models/*.go' ':!*_test.go' \
- | grep -E '^\+.*type.*struct|^\+.*`json:' \
- | head -20 || echo "")
-
-# Bug fixes: Find commits referencing issues and verify fix is still in final code
-echo "Checking for verified bug fixes..."
-VERIFIED_BUG_FIXES=""
-
-# Get commits that reference issues (pattern: #1234 or Related to #1234)
-ISSUE_COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --oneline --grep='#[0-9]' 2>/dev/null || echo "")
-
-if [ -n "$ISSUE_COMMITS" ]; then
- while IFS= read -r commit_line; do
- [ -z "$commit_line" ] && continue
-
- # Extract commit hash and message
- COMMIT_HASH=$(echo "$commit_line" | awk '{print $1}')
- COMMIT_MSG=$(echo "$commit_line" | cut -d' ' -f2-)
-
- # Get the files this commit touched
- COMMIT_FILES=$(git diff-tree --no-commit-id --name-only -r "$COMMIT_HASH" 2>/dev/null | head -5)
-
- # Check if any of those files have changes in the final diff
- CHANGE_STILL_EXISTS=false
- for file in $COMMIT_FILES; do
- if git diff ${PREVIOUS_TAG}..HEAD --name-only | grep -q "^${file}$"; then
- # File is still modified in final diff - verify the commit's changes exist
- COMMIT_ADDITIONS=$(git show "$COMMIT_HASH" --pretty="" --unified=0 -- "$file" 2>/dev/null | grep '^+[^+]' | head -3 || echo "")
- if [ -n "$COMMIT_ADDITIONS" ]; then
- # Check if at least one added line still exists in final diff
- FIRST_ADDITION=$(echo "$COMMIT_ADDITIONS" | head -1 | sed 's/^+//' | head -c 40)
- if [ -n "$FIRST_ADDITION" ] && git diff ${PREVIOUS_TAG}..HEAD -- "$file" | grep -qF "$FIRST_ADDITION"; then
- CHANGE_STILL_EXISTS=true
- break
- fi
- fi
- fi
- done
-
- if [ "$CHANGE_STILL_EXISTS" = true ]; then
- VERIFIED_BUG_FIXES="${VERIFIED_BUG_FIXES}${commit_line}
-"
- fi
- done <<< "$ISSUE_COMMITS"
-fi
-
-# Clean up the bug fixes list
-VERIFIED_BUG_FIXES=$(echo "$VERIFIED_BUG_FIXES" | sed '/^$/d' | head -15)
-
-echo "Collected diffs from key areas"
-
-# Auto-load API keys from local secrets if not already set
-PULSE_SECRETS_DIR="${PULSE_SECRETS_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/pulse/secrets}"
-if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ -f "${PULSE_SECRETS_DIR}/anthropic/api_key" ]; then
- ANTHROPIC_API_KEY=$(cat "${PULSE_SECRETS_DIR}/anthropic/api_key")
- export ANTHROPIC_API_KEY
-fi
-
-# Check for LLM API keys
-if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
- LLM_PROVIDER="anthropic"
-elif [ -n "${OPENAI_API_KEY:-}" ]; then
- LLM_PROVIDER="openai"
-else
- echo "No LLM API keys detected – cannot generate diff-based notes."
- echo "Set ANTHROPIC_API_KEY or OPENAI_API_KEY"
+if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
+ echo "Previous tag '${PREVIOUS_TAG}' does not exist" >&2
exit 1
fi
-echo "Using LLM provider: ${LLM_PROVIDER}"
+echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
-# Build the prompt with actual code changes
read -r -d '' PROMPT <&2
- return 1
- }
-
- local response_type
- response_type=$(echo "$response" | jq -r '.type // empty')
- if [ "$response_type" = "error" ]; then
- local message
- message=$(echo "$response" | jq -r '.error.message // "Unknown error"')
- echo "Anthropic API error: $message" >&2
- return 1
- fi
-
- content=$(echo "$response" | jq -r '.content[0].text // empty')
- if [ -z "$content" ] || [ "$content" = "null" ]; then
- echo "Anthropic API returned empty content: $response" >&2
- return 1
- fi
-
- printf '%s' "$content"
+# Strip accidental markdown fences and anything before the "## v" heading.
+clean_notes() {
+ sed -e 's/^```[a-z]*$//' -e 's/^```$//' | awk '/^## v/{found=1} found{print}'
}
-# Helper to call OpenAI
-generate_with_openai() {
- local response content error_msg
- response=$(curl -s https://api.openai.com/v1/chat/completions \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer ${OPENAI_API_KEY}" \
- -d @- <&2
- return 1
- }
-
- error_msg=$(echo "$response" | jq -r '.error.message? // empty')
- if [ -n "$error_msg" ]; then
- echo "OpenAI API error: $error_msg" >&2
- return 1
- fi
-
- content=$(echo "$response" | jq -r '.choices[0].message.content // empty')
- if [ -z "$content" ] || [ "$content" = "null" ]; then
- echo "OpenAI API returned empty content: $response" >&2
- return 1
- fi
-
- printf '%s' "$content"
+# Both engines must run on the logged-in subscription (Claude Max / OpenAI
+# plan), never on metered API keys. Stray env vars silently override
+# subscription auth — scrub them before invoking either CLI.
+scrub_env() {
+ env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u ANTHROPIC_BASE_URL \
+ -u ANTHROPIC_PROFILE -u OPENAI_API_KEY "$@"
+}
+
+generate_with_claude() {
+ command -v claude >/dev/null || return 1
+ echo "Engine: claude (model: ${RELEASE_NOTES_MODEL:-sonnet})" >&2
+ scrub_env claude -p "$PROMPT" \
+ --model "${RELEASE_NOTES_MODEL:-sonnet}" \
+ --allowedTools \
+ "Bash(git log:*)" "Bash(git diff:*)" "Bash(git show:*)" \
+ "Bash(git describe:*)" "Bash(git tag:*)" "Bash(git rev-parse:*)" \
+ "Bash(gh issue view:*)" "Bash(gh pr view:*)" \
+ "Read" "Grep" "Glob" \
+ /dev/null || return 1
+ echo "Engine: codex" >&2
+ local out
+ out=$(mktemp)
+ # Session log goes to stderr; only the agent's final message is kept.
+ if ! scrub_env codex exec --sandbox read-only -o "$out" "$PROMPT" >&2 &2
- RELEASE_NOTES=$(generate_with_openai) || {
- echo "Both LLM providers failed" >&2
+case "${RELEASE_NOTES_ENGINE:-claude}" in
+ codex)
+ RELEASE_NOTES=$(generate_with_codex) || {
+ echo "Codex generation failed" >&2
+ exit 1
+ }
+ ;;
+ *)
+ if ! RELEASE_NOTES=$(generate_with_claude); then
+ echo "Claude generation failed, trying Codex fallback..." >&2
+ RELEASE_NOTES=$(generate_with_codex) || {
+ echo "Both engines failed (need 'claude' or 'codex' CLI, logged in)" >&2
exit 1
}
- else
- echo "Anthropic generation failed and no OpenAI fallback" >&2
- exit 1
fi
- fi
-else
- RELEASE_NOTES=$(generate_with_openai) || {
- echo "OpenAI generation failed" >&2
- exit 1
- }
-fi
+ ;;
+esac
-if [ -z "$RELEASE_NOTES" ] || [ "$RELEASE_NOTES" = "null" ]; then
- echo "Error: Release notes generation returned empty content" >&2
+RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
+
+if [ -z "$RELEASE_NOTES" ]; then
+ echo "Error: release notes generation returned no '## v' section" >&2
exit 1
fi
-# Output release notes
-echo ""
-echo "Generated release notes:"
-echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
-echo "$RELEASE_NOTES"
-echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+# Notes to stdout only — callers capture this.
+printf '%s\n' "$RELEASE_NOTES"
-# Optionally save to file
if [ "${SAVE_TO_FILE:-}" = "1" ]; then
OUTPUT_FILE="release-notes-v${VERSION}.md"
- echo "$RELEASE_NOTES" > "$OUTPUT_FILE"
- echo ""
- echo "Saved to: $OUTPUT_FILE"
+ printf '%s\n' "$RELEASE_NOTES" > "$OUTPUT_FILE"
+ echo "Saved to ${OUTPUT_FILE}" >&2
fi
diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py
index 3208b4145..027236884 100644
--- a/scripts/release_control/subsystem_lookup_test.py
+++ b/scripts/release_control/subsystem_lookup_test.py
@@ -3613,6 +3613,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
@@ -3650,6 +3651,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
@@ -3694,6 +3696,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
@@ -3731,6 +3734,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
@@ -3768,6 +3772,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",
@@ -3805,6 +3810,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
+ "frontend-modern/src/components/__tests__/whatsNewModel.test.ts",
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/mobile_release_gate_test.py",