Add in-app release highlights

This commit is contained in:
rcourtman
2026-07-13 18:30:29 +01:00
parent 2d954b24ca
commit a393744894
28 changed files with 1050 additions and 339 deletions
+10
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
@@ -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.
@@ -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",
@@ -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
+2
View File
@@ -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() {
<DemoBanner />
<CommercialMigrationBanner />
<UpdateBanner />
<WhatsNewCard />
<GitHubStarBanner />
<GlobalUpdateProgressWatcher />
</Show>
@@ -207,6 +207,8 @@ describe('App architecture', () => {
);
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
expect(appSource).toContain("import { WhatsNewCard } from './components/WhatsNewCard';");
expect(appSource).toContain('<WhatsNewCard />');
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);",
);
@@ -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.',
},
],
},
+11
View File
@@ -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<ReleaseNotesInfo> {
return apiFetchJSON('/api/updates/release-notes');
}
static async getUpdatePlan(version: string, channel?: UpdateChannel): Promise<UpdatePlan> {
const validatedVersion = requireNonEmpty(version, 'Version');
const search = new URLSearchParams({ version: validatedVersion });
@@ -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}
</p>
{/* What's new preview (curated Highlights section of the release notes) */}
<Show when={highlightsHtml()}>
<div class="mt-2 p-3 rounded-md border bg-blue-100 dark:bg-blue-950 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200">
<div class="font-medium mb-1">
What's new in v
{normalizeReleaseVersion(updateStore.updateInfo()?.latestVersion)}
</div>
<div
class="max-h-40 overflow-y-auto [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:mt-0.5 [&_p]:mt-1 [&_a]:underline [&_code]:font-mono [&_code]:text-xs"
// eslint-disable-next-line solid/no-innerhtml -- renderMarkdown sanitizes via DOMPurify
innerHTML={highlightsHtml()}
/>
</div>
</Show>
{/* Pro edition with in-app apply: updates install the private
Pulse Pro build from the license server */}
<Show when={isProEdition() && updatePlan()?.canAutoUpdate}>
<p class="text-xs">
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).
</p>
</Show>
@@ -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 (
<Show when={visible()}>
<div
data-testid="whats-new-banner"
class="bg-emerald-50 dark:bg-emerald-900 border-b border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-100 relative animate-slideDown"
>
<div class="px-4 py-2">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
{/* Sparkle icon */}
<svg
class="w-4 h-4 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15z" />
</svg>
<span class="text-sm font-medium truncate">
Pulse updated to v{version()} here's what's new
</span>
<a
href={buildReleaseNotesUrl(version())}
target="_blank"
rel="noopener noreferrer"
class="text-emerald-600 dark:text-emerald-300 underline text-sm hidden sm:inline hover:text-emerald-700 dark:hover:text-emerald-200 flex-shrink-0"
>
Full release notes
</a>
</div>
<button
onClick={dismiss}
class="px-3 py-1 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded transition-colors flex-shrink-0"
>
Got it
</button>
</div>
<div
class="text-sm mt-1 pl-7 pr-2 max-h-48 overflow-y-auto [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:mt-0.5 [&_p]:mt-1 [&_a]:underline [&_code]:font-mono [&_code]:text-xs"
// eslint-disable-next-line solid/no-innerhtml -- renderMarkdown sanitizes via DOMPurify
innerHTML={highlightsHtml()}
/>
</div>
</div>
</Show>
);
}
@@ -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(() => <WhatsNewCard />);
}
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');
});
});
@@ -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 Whats 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 Whats 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);
});
});
@@ -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<hash> 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);
};
+6 -4
View File
@@ -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,
@@ -165,6 +165,7 @@ export const STORAGE_KEYS = {
// Updates
UPDATES: 'pulse-updates',
WHATS_NEW_LAST_SEEN: 'pulseWhatsNewLastSeen',
// Alert settings
ALERT_HISTORY_TIME_FILTER: 'alertHistoryTimeFilter',
+1
View File
@@ -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",
@@ -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
+65 -13
View File
@@ -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 {
+63
View File
@@ -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 {
+4
View File
@@ -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
+125
View File
@@ -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()
}
+98
View File
@@ -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")
}
}
+121 -307
View File
@@ -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 <version> [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 <version> [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<version>.md.
#
# Env overrides:
# RELEASE_NOTES_ENGINE=claude|codex force an engine (default: claude, codex fallback)
# RELEASE_NOTES_MODEL=<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 <version> [previous-tag]"
echo "Example: $0 4.29.0 v4.28.0"
echo "Usage: $0 <version> [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 <<EOF || true
You are generating release notes for Pulse v${VERSION}.
Pulse is a monitoring dashboard for Proxmox VE, PBS, and Docker containers.
You are generating the release notes for Pulse v${VERSION}.
IMPORTANT: You are analyzing ACTUAL CODE DIFFS, not commit messages. This means you see what is truly different between v${PREVIOUS_TAG} and v${VERSION}. Focus only on user-visible changes.
Pulse is a self-hosted monitoring dashboard for Proxmox VE, PBS, Docker, and
Kubernetes, used mostly by homelab and small-ops users.
Here are the files that changed (excluding tests/docs):
${CHANGED_FILES}
The repo is checked out at the commit that will become v${VERSION}. The
previous release tag is ${PREVIOUS_TAG}. Investigate the changes yourself —
start with \`git log ${PREVIOUS_TAG}..HEAD --oneline\` and
\`git diff ${PREVIOUS_TAG}..HEAD --stat\`, then use targeted \`git diff\` /
\`git show\` and read source files where the diff alone is unclear. For
commits that reference GitHub issues (#1234), you may use \`gh issue view\` to
understand the user-facing symptom. Only describe changes that exist in the
final code state; verify anything you are unsure about before writing it.
Summary of changes:
${DIFF_STAT}
Focus on USER-VISIBLE changes only: features, fixes, and behavior users will
notice. Ignore internal refactors, test changes, CI/tooling, and docs.
NEW API ENDPOINTS/HANDLERS (lines starting with + are additions):
${API_DIFF:-No significant API changes detected}
NEW FRONTEND FEATURES:
${FRONTEND_DIFF:-No significant frontend changes detected}
NEW CONFIG OPTIONS:
${CONFIG_DIFF:-No significant config changes detected}
ALERT/NOTIFICATION CHANGES:
${ALERT_DIFF:-No significant alert changes detected}
AGENT CHANGES:
${AGENT_DIFF:-No significant agent changes detected}
INSTALL SCRIPT CHANGES:
${INSTALL_DIFF:-No significant install changes detected}
NEW DATA MODELS:
${MODELS_DIFF:-No significant model changes detected}
VERIFIED BUG FIXES (commits referencing issues, verified still in final code):
${VERIFIED_BUG_FIXES:-No verified bug fix commits found}
Generate release notes following this format:
Write the release notes in exactly this format:
## v${VERSION}
### New Features
[List genuinely new user-facing features. Be specific about what users can now do.]
### Highlights
[2-4 short bullets, plain English, covering only what a typical user would
notice and care about. This section is rendered inside the Pulse UI itself
(the post-update "What's New" banner and the update-banner preview), so each
bullet must be self-contained and jargon-free. IMPORTANT: if this release has
nothing a typical user would notice (only internal fixes or minor patches),
OMIT this entire section — that deliberately keeps the in-app banner silent
for maintenance releases. Keep the heading at level 3 (###).]
### Bug Fixes
[List fixes for issues users would have encountered. Use the VERIFIED BUG FIXES section above - these reference actual GitHub issues. Include the issue number in format (#1234).]
### New Features
[Genuinely new user-facing capabilities. Be specific about what users can now do.]
### Bug Fixes
[Fixes for problems users would have encountered. Include issue refs like
(#1234) only when the fix verifiably addresses that issue.]
### Improvements
[List enhancements to existing features.]
[Enhancements to existing features.]
---
Guidelines:
- Plain, factual, understated. No marketing language, no emojis.
- Omit any section that has no items.
- Do NOT write an Installation section or anything after Improvements — the
release pipeline appends those.
- Highlights is the ONE exception to "boring": it is shown in-app to users who
just updated, so pick the few changes they would actually notice — but still
facts, no hype.
## Installation
**Quick Install (LXC / Proxmox VE):**
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
\`\`\`
**Docker:**
\`\`\`bash
docker pull rcourtman/pulse:${VERSION}
\`\`\`
**Helm:**
\`\`\`bash
helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart --version ${VERSION}
\`\`\`
See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for details.
Paid Pulse Pro, Relay, and eligible legacy customers: public GitHub release
assets and the public \`rcourtman/pulse\` Docker image are community builds. They do
not include the private Pulse Pro runtime hooks. Use
https://pulserelay.pro/download.html with your activation key to get the
private Pulse Pro Docker image or Linux archive.
GUIDELINES:
- Write plain, factual release notes. No marketing language or excitement.
- Only mention features that exist in the FINAL code state
- Do not mention internal refactors, test changes, or CI/CD improvements
- Do not mention AI features prominently - these are optional features
- Keep it concise and boring - users want facts, not hype
- If a section has no items, omit the section entirely
- No emojis
Your reply must be ONLY the release-notes markdown, starting with
"## v${VERSION}" — no preamble, no code fences, no commentary.
EOF
# Helper to call Anthropic
generate_with_anthropic() {
local response content
response=$(curl -s https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
-H "anthropic-version: 2023-06-01" \
-d @- <<JSON
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 2000,
"system": "You are a technical writer creating factual, understated release notes. Be concise and avoid marketing language.",
"messages": [
{
"role": "user",
"content": $(echo "$PROMPT" | jq -Rs .)
}
]
}
JSON
) || {
echo "Anthropic API request failed" >&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 @- <<JSON
{
"model": "gpt-4o",
"messages": [
{
"role": "system",
"content": "You are a technical writer creating factual, understated release notes. Be concise and avoid marketing language."
},
{
"role": "user",
"content": $(echo "$PROMPT" | jq -Rs .)
}
],
"temperature": 0.3,
"max_tokens": 2000
}
JSON
) || {
echo "OpenAI API request failed" >&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
}
generate_with_codex() {
command -v codex >/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 </dev/null; then
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
# Call LLM API based on provider
RELEASE_NOTES=""
if [ "$LLM_PROVIDER" = "anthropic" ]; then
if ! RELEASE_NOTES=$(generate_with_anthropic); then
if [ -n "${OPENAI_API_KEY:-}" ]; then
echo "Anthropic generation failed, falling back to OpenAI..." >&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
@@ -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",