From a3937448945a376028560966de162f639bbfa471 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 13 Jul 2026 18:30:29 +0100 Subject: [PATCH] Add in-app release highlights --- .github/workflows/create-release.yml | 10 + .../v6/internal/subsystems/agent-lifecycle.md | 7 +- .../v6/internal/subsystems/ai-runtime.md | 5 + .../v6/internal/subsystems/api-contracts.md | 6 + .../v6/internal/subsystems/cloud-paid.md | 5 + .../subsystems/deployment-installability.md | 44 +- .../v6/internal/subsystems/registry.json | 11 + .../internal/subsystems/storage-recovery.md | 5 + frontend-modern/src/App.tsx | 2 + .../src/__tests__/App.architecture.test.ts | 5 +- .../src/api/__tests__/updates.test.ts | 16 +- frontend-modern/src/api/updates.ts | 11 + .../src/components/UpdateBanner.tsx | 33 +- .../src/components/WhatsNewCard.tsx | 148 ++++++ .../__tests__/WhatsNewCard.test.tsx | 121 +++++ .../__tests__/whatsNewModel.test.ts | 99 ++++ .../src/components/whatsNewModel.ts | 44 ++ frontend-modern/src/stores/updates.ts | 10 +- frontend-modern/src/utils/localStorage.ts | 1 + internal/api/route_inventory_test.go | 1 + internal/api/router_routes_registration.go | 3 + internal/api/updates.go | 78 +++- internal/api/updates_test.go | 63 +++ internal/updates/manager.go | 4 + internal/updates/release_notes.go | 125 +++++ internal/updates/release_notes_test.go | 98 ++++ scripts/generate-release-notes.sh | 428 +++++------------- .../release_control/subsystem_lookup_test.py | 6 + 28 files changed, 1050 insertions(+), 339 deletions(-) create mode 100644 frontend-modern/src/components/WhatsNewCard.tsx create mode 100644 frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx create mode 100644 frontend-modern/src/components/__tests__/whatsNewModel.test.ts create mode 100644 frontend-modern/src/components/whatsNewModel.ts create mode 100644 internal/updates/release_notes.go create mode 100644 internal/updates/release_notes_test.go 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 ( + +
+
+
+
+ {/* Sparkle icon */} + + + + + + Pulse updated to v{version()} — here's what's new + + +
+ +
+
+
+
+ + ); +} 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",