mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Add in-app release highlights
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user