diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index bc20caaa0..6b0e5bbd9 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -729,15 +729,15 @@ jobs: --unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \ --unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}" - # Highlights provide the compact pre-update preview. The post-update - # dialog uses categorized changelog entries instead of that summary. - if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then - echo "::notice::Release notes include Highlights — the update banner can preview them before users update." + # Customer-facing improvements provide the compact pre-update preview. + # Historical Highlights sections remain supported for older packets. + if grep -qiE "^#{1,6}[[:space:]]+(highlights|what.?s improved)\\b" "$RENDERED_NOTES_FILE"; then + echo "::notice::Release notes include customer-facing improvements — the update banner can preview them before users update." else - echo "::notice::Release notes have no Highlights — the update banner will not show a summary preview." + echo "::notice::Release notes have no customer-facing improvements — the update banner will not show a summary preview." fi - if grep -qiE '^#{1,6}[[:space:]]+(added|new features|improved|improvements|changed|fixed|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$' "$RENDERED_NOTES_FILE"; then + if grep -qiE "^#{1,6}[[:space:]]+(what.?s improved|added|new features|improved|improvements|changed|fixed|fixes|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$" "$RENDERED_NOTES_FILE"; then echo "::notice::Release notes include categorized changes — the post-update changelog dialog will show them." else echo "::notice::Release notes have no categorized changes — the post-update changelog dialog stays silent." diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d1185d97b..6f7fe1af7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1069,6 +1069,18 @@ upgrade, update, release, or artifact-selection behavior. must render the public release body through a canonical sanitizer instead of publishing `Draft Release Notes` framing, `_DRAFT.md` packet links, or duplicate appended `Installation` / `Promotion Metadata` sections verbatim. + From the release after `v6.4.0-rc.1` onward, that renderer also owns a + customer-facing communication contract. Public notes lead with one short + outcome paragraph, use a scannable `What's improved` section with no more + than six concrete items, keep fixes symptom-led, and reserve `Before you + upgrade` or `Known issues` for information users must act on or understand. + Qualification counts, readiness assertions, release gates, workflow + narration, artifact identity, and promotion metadata stay in governed + workflow summaries and evidence records rather than the public changelog. + The renderer appends only concise `Install` and exact `Roll back` sections; + `docs/releases/RELEASE_NOTES_TEMPLATE.md` and + `scripts/generate-release-notes.sh` are the canonical manual and generated + authoring entry points for this shape. `docs/UPGRADE_v6.md` and its shipped frontend copy `frontend-modern/public/docs/UPGRADE_v6.md` must also stay byte-aligned with the current RC support packet so upgrade guidance does not keep pointing diff --git a/docs/releases/RELEASE_NOTES_TEMPLATE.md b/docs/releases/RELEASE_NOTES_TEMPLATE.md new file mode 100644 index 000000000..33eef5338 --- /dev/null +++ b/docs/releases/RELEASE_NOTES_TEMPLATE.md @@ -0,0 +1,32 @@ +# Pulse vX.Y.Z Release Notes + +One short paragraph explaining the customer outcome of the release. Lead with +what feels better or works now, not how it was implemented. + +## What's improved + +- **Short outcome** — Explain where users notice it and why it matters. +- **Short outcome** — Keep each item concrete and independently useful. +- **Short outcome** — Prefer observable behavior over component names. +- **Short outcome** — Use plain language and avoid implementation detail. + +Use four to six meaningful improvements for a normal RC or minor release. A +narrow patch may use fewer rather than padding the notes with internal work. + +## Fixes + +- State a visible problem that no longer happens. +- Name the affected page, workflow, integration, or platform when useful. + +Omit this section only when there are genuinely no user-facing fixes. + +## Before you upgrade + +Include only compatibility, migration, signing, companion-app, known-risk, or +required operator information. Omit this section when users do not need to do +or understand anything before upgrading. + +The release pipeline appends the `Install` and `Roll back` sections from +governed version metadata. Do not add release qualification, validation, +workflow, gate, governance, or promotion-metadata sections to the customer +notes. diff --git a/docs/releases/V6_PRERELEASE_RUNBOOK.md b/docs/releases/V6_PRERELEASE_RUNBOOK.md index f7f88c2b3..560ee5ebb 100644 --- a/docs/releases/V6_PRERELEASE_RUNBOOK.md +++ b/docs/releases/V6_PRERELEASE_RUNBOOK.md @@ -118,6 +118,12 @@ git push origin pulse/v6-release ./scripts/trigger-release.sh "$RC_VERSION" "$RELEASE_NOTES_FILE" ``` + - Start new notes from `docs/releases/RELEASE_NOTES_TEMPLATE.md` or use the + canonical generator. Lead with one short customer-outcome paragraph, + keep `What's improved` to at most six scannable items, list concrete + fixes, and include only user-relevant risk under `Before you upgrade`. + Do not put qualification, validation, workflow, gate, governance, or + promotion metadata into the public changelog. - Keep the current release-notes, changelog, and operator-support packet in sync. Do not update only one of them and treat the packet as ready. - Do not paste multiline release notes into the GitHub workflow form as the @@ -141,7 +147,9 @@ git push origin pulse/v6-release - Confirm assets exist and checksums match. - Confirm GitHub marks the release as a prerelease. - Confirm the rendered release body retains its standalone headings, lists, - code fences, Installation section, and Promotion Metadata section. + code fences, concise `Install` section, and exact `Roll back` section. + Machine promotion metadata belongs in the governed workflow record, not + in the customer-facing GitHub release body. - Smoke install on a test host/container. 5. Retry an unpublished draft only through the same helper: diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index f1c19ac10..345f8d23f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,62 +1,35 @@ { "version": 1, - "base_sha": "6e5571997e8b94b0a698c4c5c7486548c5ef7d42", - "verified_at": "2026-08-25T14:27:03Z", + "base_sha": "f18f15bf7b1fbdf1078bf412754c059694c8b996", + "verified_at": "2026-08-25T15:58:23Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/stores/websocket-global.ts", - "frontend-modern/src/stores/websocket.ts", - "frontend-modern/src/utils/resourceStateAdapters.ts" + "frontend-modern/src/components/UpdateBanner.tsx", + "frontend-modern/src/components/whatsNewModel.ts" ], "content_sha256": { - "frontend-modern/src/hooks/useUnifiedResources.ts": "7cad439395da8c5ccb281abe4b38313d63d45f87d38add8574120be5d1ab76a8", - "frontend-modern/src/stores/websocket-global.ts": "644e5bccf8612259188d8cf3fd8b89e7b30fcc326bc717b69745f0b0c5ae5d6c", - "frontend-modern/src/stores/websocket.ts": "e59b92a87db8f373129e7321e0bc06222ad59c57d25703aaf8f8ffc1d07b8fad", - "frontend-modern/src/utils/resourceStateAdapters.ts": "099acb8c55827396ef0722b6b5afdd8d742e3811434ca498de4604ad05edb8fa" + "frontend-modern/src/components/UpdateBanner.tsx": "455a8546b5b98c4541993ea7f894ad352c87a29392ede0da2a562e05f85b09de", + "frontend-modern/src/components/whatsNewModel.ts": "d81c8ae4fcc59cff06a63b4a7407a97848acd934f6b6e04535059dc89f652471" }, - "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/proxmox/backups", - "/proxmox/replication", - "/proxmox/ceph", - "/docker", - "/kubernetes", - "/truenas", - "/vmware", - "/standalone", - "/alerts", - "/ai", - "/actions", - "/settings" - ], + "routes": ["/release-notes-verification.html"], "viewports": [ { "width": 1440, "height": 900 }, - { - "width": 1280, - "height": 720 - }, { "width": 390, "height": 844 } ], "states": [ - "live realtime metric ticks flowing through the patched-keys fast merge on the 50-node/1500-resource mock estate (pulse-dev rig build of this exact tree on :7696)", - "per-cell DOM-vs-store consistency for workloads-table CPU cells across cohort updates (11/12 rows within 1% at two samples 30s apart; 12th is a group header without a CPU cell)", - "warm tab re-entry catch-up through the changed-key history (Alerts, Proxmox, Storage/Overview sub-tab cycles)", - "30s idle observation on Proxmox Overview at desktop and 390px/4x-throttle mobile", - "nodes / workloads / storage tables rendered and live under realtime deltas; zero console errors across all probes on both viewport classes" + "expanded update banner with a v6.4.0-rc.2 customer-facing What's improved section", + "desktop banner rendered both improvement bullets and excluded the Fixes section from the preview", + "narrow banner retained the full preview at 390px with document scroll width equal to client width" ], "interactions": [ - "login", - "desktop platform-tab rail navigation across all platforms and utility tabs", - "mobile bottom-bar navigation including platform-switcher and More sheets", - "Proxmox sub-tab cycle (Storage, Backups, Replication, Ceph, Overview) with repeated warm switches", - "repeated warm Alerts entry (5-rep A/B harness)" + "opened the isolated current-tree frontend fixture against the local mock backend", + "expanded the update banner with Show more", + "inspected rendered text, layout geometry, and horizontal overflow at desktop and narrow widths" ] } diff --git a/frontend-modern/src/components/UpdateBanner.test.tsx b/frontend-modern/src/components/UpdateBanner.test.tsx index 9261cc51e..1761c36e6 100644 --- a/frontend-modern/src/components/UpdateBanner.test.tsx +++ b/frontend-modern/src/components/UpdateBanner.test.tsx @@ -16,6 +16,7 @@ const { mock } = vi.hoisted(() => ({ composeUpCommand: string; } | undefined, + releaseNotes: '', plan: { canAutoUpdate: true, requiresRoot: false, @@ -37,7 +38,7 @@ vi.mock('@/stores/updates', () => ({ available: true, currentVersion: '6.0.0', latestVersion: '6.1.0', - releaseNotes: '', + releaseNotes: mock.releaseNotes, releaseDate: '', downloadUrl: 'https://github.com/rcourtman/Pulse/releases/download/v6.1.0/pulse-v6.1.0-linux-amd64.tar.gz', @@ -71,6 +72,7 @@ afterEach(() => { mock.runtimeBuild = 'community'; mock.deploymentType = 'systemd'; mock.dockerUpdate = undefined; + mock.releaseNotes = ''; mock.plan = { canAutoUpdate: true, requiresRoot: false, @@ -200,3 +202,30 @@ describe('UpdateBanner Proxmox LXC update safety', () => { expect(screen.getByText(/Safe upgrade:/)).toBeInTheDocument(); }); }); + +describe('UpdateBanner release-note preview', () => { + it("shows customer-facing What's improved bullets in the expanded banner", async () => { + mock.releaseNotes = `# Pulse v6.4.0-rc.2 Release Notes + +Infrastructure views stay fast and predictable as your environment grows. + +## What's improved + +- **Faster infrastructure views** — Tables stay responsive as estates grow. +- **Lighter realtime updates** — Pages do less work when resources change. + +## Fixes + +- Saved API keys are no longer returned to the browser. +`; + + render(() => ); + await screen.findByRole('button', { name: 'Apply Update' }); + fireEvent.click(screen.getByTitle('Show more')); + + expect(screen.getByText("What's new in v6.1.0")).toBeInTheDocument(); + expect(screen.getByText(/Faster infrastructure views/)).toBeInTheDocument(); + expect(screen.getByText(/Lighter realtime updates/)).toBeInTheDocument(); + expect(screen.queryByText(/Saved API keys/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index 17a1257ff..923483e26 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -59,9 +59,9 @@ 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. + // Curated customer-facing improvements from the upcoming release help + // answer "is this update worth taking now?" without leaving for GitHub. + // Historical `Highlights` notes remain supported. Empty notes stay hidden. const highlightsHtml = createMemo(() => { const info = updateStore.updateInfo(); if (!info?.available || !info.releaseNotes) return ''; @@ -284,7 +284,7 @@ export function UpdateBanner() { {updateStore.updateInfo()?.latestVersion}

- {/* What's new preview (curated Highlights section of the release notes) */} + {/* What's new preview (customer-facing improvements or historical Highlights) */}
diff --git a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts index 839e1b2c7..233d734f5 100644 --- a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts +++ b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts @@ -73,6 +73,42 @@ describe('extractHighlights', () => { expect(extractHighlights('## Changelog\n- fix things')).toBeNull(); }); + it("uses the customer-facing What's improved section when Highlights is absent", () => { + const body = [ + '# Pulse v6.4.0 Release Notes', + '', + 'Pulse is faster in larger environments.', + '', + "## What's improved", + '', + '- **Faster large environments** — Tables stay responsive as estates grow.', + '- **Lighter realtime updates** — Pages do less work when resources change.', + '', + '## Fixes', + '', + '- Saved API keys are no longer returned to the browser.', + ].join('\n'); + + expect(extractHighlights(body)).toBe( + [ + '- **Faster large environments** — Tables stay responsive as estates grow.', + '- **Lighter realtime updates** — Pages do less work when resources change.', + ].join('\n'), + ); + }); + + it('prefers historical Highlights when both preview headings exist', () => { + const body = [ + '## Highlights', + '- Short preview.', + '', + "## What's improved", + '- **Longer detail** — Full customer-facing explanation.', + ].join('\n'); + + expect(extractHighlights(body)).toBe('- Short preview.'); + }); + it('returns null when the Highlights section is empty', () => { expect(extractHighlights('## Highlights\n\n## Changelog\n- fix')).toBeNull(); }); @@ -148,6 +184,27 @@ describe('extractChangelog', () => { ); }); + it("includes What's improved in the post-update changelog", () => { + const body = [ + "## What's improved", + '- **Faster large environments** — Tables stay responsive as estates grow.', + '## Fixes', + '- Storage rows remain visible after refresh.', + ].join('\n'); + + expect(extractChangelog(body)).toBe( + [ + '### Improved', + '', + '- **Faster large environments** — Tables stay responsive as estates grow.', + '', + '### Fixed', + '', + '- Storage rows remain visible after refresh.', + ].join('\n'), + ); + }); + it('preserves nested details inside a recognized category', () => { const body = [ '## Fixed', diff --git a/frontend-modern/src/components/whatsNewModel.ts b/frontend-modern/src/components/whatsNewModel.ts index 630905f0c..4b1e166ac 100644 --- a/frontend-modern/src/components/whatsNewModel.ts +++ b/frontend-modern/src/components/whatsNewModel.ts @@ -26,14 +26,18 @@ const sectionBody = (lines: string[], headings: Heading[], headingIndex: number) }; /** - * 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". + * Extract the compact pre-update summary from a GitHub release body. Current + * customer-facing notes use `What's improved`; historical releases may still + * carry `Highlights`. */ export const extractHighlights = (markdown: string): string | null => { const lines = markdown.replace(/\r\n/g, '\n').split('\n'); const headings = headingsIn(lines); - const headingIndex = headings.findIndex((heading) => /^highlights\b/i.test(heading.title)); + const highlightsIndex = headings.findIndex((heading) => /^highlights\b/i.test(heading.title)); + const improvementsIndex = headings.findIndex((heading) => + /^what(?:'|’)s improved\b/i.test(heading.title), + ); + const headingIndex = highlightsIndex === -1 ? improvementsIndex : highlightsIndex; if (headingIndex === -1) { return null; } @@ -47,8 +51,11 @@ const CHANGELOG_SECTION_LABELS: Readonly> = { 'new features': 'Added', improved: 'Improved', improvements: 'Improved', + "what's improved": 'Improved', + 'what’s improved': 'Improved', changed: 'Changed', fixed: 'Fixed', + fixes: 'Fixed', 'bug fixes': 'Fixed', security: 'Security', 'breaking changes': 'Breaking changes', diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 833159f9c..a528fccef 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -65,56 +65,55 @@ notice. Ignore internal refactors, test changes, CI/tooling, and docs. Write the release notes in exactly this format: -## v${VERSION} +# Pulse v${VERSION} Release Notes -### Highlights -[2-3 short bullets covering only what a typical user would notice and care -about. This is the entire overview shown inside Pulse after an update. Use one -plain-text sentence per bullet, no more than 140 characters. Say what improved -and why it matters in everyday words; avoid component names, acronyms, -implementation details, links, issue numbers, and Markdown formatting. -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 (###).] +[One short paragraph explaining the customer outcome of this release. Lead +with what feels better or works now, not how it was implemented.] -### Added -[Genuinely new user-facing capabilities. Name the page, workflow, integration, -or platform where users will find each one, then say what they can now do.] +## What's improved -### Improved -[Meaningful changes to existing behavior. Name the affected experience and the -observable improvement; do not summarize several unrelated changes together.] +- **[Short outcome]** — [Where users notice it and why it matters.] -### Fixed -[Problems users would have encountered. State the visible symptom that no -longer happens, not the internal cause. Include issue refs like (#1234) only -when the fix verifiably addresses that issue.] +[Use 4-6 meaningful bullets for a normal RC or minor release. A narrow patch +may use fewer. Keep every bullet concrete and independently useful.] + +## Fixes + +- [A visible problem that no longer happens.] + +[Omit this section only when there are genuinely no user-facing fixes.] + +## Before you upgrade + +[Only user-relevant compatibility, migration, signing, companion-app, or known +risk information. Omit this section when there is nothing users need to do or +understand before upgrading.] Guidelines: - Plain, factual, understated. No marketing language, no emojis. - Omit any section that has no items. -- Every Added, Improved, and Fixed bullet must stand on its own as a concrete - changelog entry. A reader should understand where they would notice the - change and what is different without knowing Pulse's implementation. +- Every bullet must stand on its own. A reader should understand where they + would notice the change and what is different without knowing Pulse's + implementation. - Avoid internal release and architecture vocabulary such as canonical, governed, schema, provider transport, preflight, convergence, or runtime boundary unless that exact term is visible to the user in the product. - Do not use vague entries such as "improved agent handling" or "various UI fixes". Split unrelated changes and name the behavior that changed. -- Do NOT write an Installation section or anything after Fixed — the - release pipeline appends those. -- Highlights is the ONE exception to "boring": it is shown in-app before users - update, so make it the shortest useful preview of what changed — still - factual, with no hype. +- Do NOT write Install, Roll back, Promotion Metadata, Release Qualification, + validation, gate, workflow, or governance sections. The release pipeline + appends concise install and rollback instructions, while machine promotion + records stay outside the customer changelog. Your reply must be ONLY the release-notes markdown, starting with -"## v${VERSION}" — no preamble, no code fences, no commentary. +"# Pulse v${VERSION} Release Notes" — no preamble, no code fences, no +commentary. EOF -# Strip accidental markdown fences and anything before the "## v" heading. +# Strip accidental markdown fences and anything before the release title. clean_notes() { - sed -e 's/^```[a-z]*$//' -e 's/^```$//' | awk '/^## v/{found=1} found{print}' + sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \ + awk -v title="# Pulse v${VERSION} Release Notes" '$0 == title {found=1} found{print}' } # Both engines must run on the logged-in subscription (Claude Max / OpenAI @@ -174,7 +173,7 @@ esac RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes) if [ -z "$RELEASE_NOTES" ]; then - echo "Error: release notes generation returned no '## v' section" >&2 + echo "Error: release notes generation returned no canonical release title" >&2 exit 1 fi diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 9afd2a662..beb4e22f7 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1250,6 +1250,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): recorder = read("scripts/release_control/record_rc_to_ga_rehearsal.py") internal_recorder = read("scripts/release_control/internal/record_rc_to_ga_rehearsal.py") renderer = read("scripts/release_control/render_release_body.py") + generator = read("scripts/generate-release-notes.sh") + release_notes_template = read("docs/releases/RELEASE_NOTES_TEMPLATE.md") resolver = read("scripts/release_control/resolve_release_promotion.py") self.assertIn("GitHub Actions run URL", template) self.assertIn("Exact GA date to publish with GA", template) @@ -1297,7 +1299,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("2-core hosted runners", workflow) self.assertIn("resolve_release_promotion.py", release_workflow) self.assertIn("render_release_body.py", release_workflow) - self.assertIn("build_promotion_metadata_section", renderer) + self.assertIn("build_rollback_section", renderer) + self.assertIn("# Pulse v${VERSION} Release Notes", generator) + self.assertIn("## What's improved", generator) + self.assertIn("## What's improved", release_notes_template) + self.assertIn("promotion-metadata", release_notes_template) self.assertIn("default_output_path", internal_recorder) self.assertIn("output path already exists", internal_recorder) self.assertIn("default_output_path", recorder) @@ -1375,7 +1381,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}', content) self.assertIn("resolve_release_promotion.py", content) self.assertIn("render_release_body.py", content) - self.assertIn("build_promotion_metadata_section", renderer) + self.assertIn("build_rollback_section", renderer) self.assertIn("uses: ./.github/workflows/publish-docker.yml", content) self.assertIn("release-convergence.yml/dispatches", content) self.assertIn("Release Activation Commit Verdict", content) @@ -1394,9 +1400,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("recorded rollback target plus exact", source_of_truth) self.assertIn("hours of prerelease soak", resolver) self.assertIn("minimum is 72 hours unless hotfix_exception is true", resolver) - self.assertIn("build_promotion_metadata_section", renderer) - self.assertIn("Planned GA date", renderer) - self.assertIn("Planned v5 end-of-support date", renderer) + self.assertIn("build_rollback_section", renderer) + self.assertIn("promotion metadata out of customer notes", renderer) self.assertIn("historical_asset_backfill_only:", content) self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content) self.assertIn("draft: true", content) @@ -1623,8 +1628,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("./scripts/install.sh --version", helper) self.assertIn("v6 GA date to publish with GA", helper) self.assertIn("--arg ga_date \"$GA_DATE\"", helper) - self.assertIn("Planned GA date", renderer) - self.assertIn("Planned v5 end-of-support date", renderer) + self.assertIn("ga_date", resolver) + self.assertIn("v5_eos_date", resolver) self.assertIn("Stable v6.0.0 requires v5_eos_date in YYYY-MM-DD form", resolver) self.assertIn("release_notes must include the Pulse v5 maintenance-only support notice", resolver) dry_run_workflow = read(".github/workflows/release-dry-run.yml") diff --git a/scripts/release_control/render_release_body.py b/scripts/release_control/render_release_body.py index db6944904..20dd1a028 100644 --- a/scripts/release_control/render_release_body.py +++ b/scripts/release_control/render_release_body.py @@ -21,6 +21,30 @@ _VALIDATION_STATUS_BLOCK_RE = re.compile( _HIGHLIGHTS_HEADING_RE = re.compile(r"^(#{2,6})[ \t]+Highlights[ \t]*$", re.IGNORECASE) _HIGHLIGHT_BULLET_RE = re.compile(r"^-[ \t]+(.+)$") +_CUSTOMER_SECTION_HEADINGS = { + "what's improved", + "what’s improved", + "fixes", + "before you upgrade", + "known issues", +} +_INTERNAL_RELEASE_LANGUAGE_RE = re.compile( + r"\b(?:" + r"readiness assertions?" + r"|release gates?" + r"|candidate cutoff" + r"|exact-sha" + r"|immutable (?:release )?candidate" + r"|promotion channel" + r"|completion state" + r"|lane follow-?ups?" + r"|artifact identity" + r"|self-contained checks" + r")\b", + re.IGNORECASE, +) +_CUSTOMER_FORMAT_MINIMUM = (6, 4, 0) +_CUSTOMER_FORMAT_EXEMPTIONS = {"6.4.0-rc.1"} _ISSUE_REFERENCE_RE = re.compile( r"(?:" r"(? str: @@ -123,6 +151,136 @@ def _highlight_items(text: str) -> list[str] | None: return items +def _release_core(version: str) -> tuple[int, int, int] | None: + match = re.fullmatch( + r"v?(\d+)\.(\d+)\.(\d+)(?:-(?:rc|alpha|beta)\.\d+)?", + version, + re.IGNORECASE, + ) + if not match: + return None + return tuple(int(match.group(index)) for index in range(1, 4)) + + +def _requires_customer_facing_standard(version: str) -> bool: + normalized = version.lower().removeprefix("v") + if normalized in _CUSTOMER_FORMAT_EXEMPTIONS: + return False + core = _release_core(normalized) + return core is not None and core >= _CUSTOMER_FORMAT_MINIMUM + + +def _section_lines(text: str, heading_index: int) -> list[str]: + lines = _normalize_newlines(text).splitlines() + section: list[str] = [] + for line in lines[heading_index + 1 :]: + if re.fullmatch(r"##[ \t]+\S.*", line): + break + section.append(line) + return section + + +def _flat_bullet_items(lines: list[str], section_name: str) -> list[str]: + items: list[str] = [] + for line in lines: + if not line.strip(): + continue + bullet = _HIGHLIGHT_BULLET_RE.fullmatch(line) + if bullet: + items.append(bullet.group(1).strip()) + continue + if line[:1].isspace() and items and not line.lstrip().startswith(("- ", "* ", "+ ")): + items[-1] = f"{items[-1]} {line.strip()}" + continue + raise ReleaseBodyIntegrityError( + f"{section_name} must be a flat Markdown bullet list" + ) + return items + + +def _validate_customer_facing_release_notes(text: str) -> None: + """Enforce concise public notes without release-control implementation prose.""" + + lines = _normalize_newlines(text).strip().splitlines() + first_section_index = next( + (index for index, line in enumerate(lines) if re.fullmatch(r"##[ \t]+\S.*", line)), + None, + ) + if first_section_index is None: + raise ReleaseBodyIntegrityError("customer-facing release notes need sections") + + summary = " ".join(line.strip() for line in lines[1:first_section_index] if line.strip()) + if not summary or summary.startswith(('-', '*', '+')): + raise ReleaseBodyIntegrityError( + "customer-facing release notes need one plain-language summary paragraph" + ) + if len(summary) > _MAX_CUSTOMER_SUMMARY_LENGTH: + raise ReleaseBodyIntegrityError( + f"the release summary must be {_MAX_CUSTOMER_SUMMARY_LENGTH} characters or fewer" + ) + + headings: dict[str, int] = {} + for index, line in enumerate(lines): + heading = re.fullmatch(r"##[ \t]+(.+?)\s*", line) + if not heading: + continue + normalized = re.sub(r"\s+", " ", heading.group(1)).lower() + if normalized not in _CUSTOMER_SECTION_HEADINGS: + raise ReleaseBodyIntegrityError( + "customer-facing release notes may only use What's improved, " + "Fixes, Before you upgrade, and Known issues sections" + ) + if normalized in headings: + raise ReleaseBodyIntegrityError( + f"customer-facing release notes contain duplicate {heading.group(1)} sections" + ) + headings[normalized] = index + + improvements_key = next( + (key for key in ("what's improved", "what’s improved") if key in headings), + None, + ) + if improvements_key is None: + raise ReleaseBodyIntegrityError( + "customer-facing release notes must contain a What's improved section" + ) + + improvements = _flat_bullet_items( + _section_lines(text, headings[improvements_key]), + "What's improved", + ) + if not improvements or len(improvements) > _MAX_CUSTOMER_IMPROVEMENT_ITEMS: + raise ReleaseBodyIntegrityError( + "What's improved must contain between 1 and " + f"{_MAX_CUSTOMER_IMPROVEMENT_ITEMS} bullets" + ) + for item in improvements: + if len(item) > _MAX_CUSTOMER_ITEM_LENGTH: + raise ReleaseBodyIntegrityError( + f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer" + ) + if not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item): + raise ReleaseBodyIntegrityError( + "What's improved bullets must start with a short bold outcome followed by a dash" + ) + + if "fixes" in headings: + fixes = _flat_bullet_items(_section_lines(text, headings["fixes"]), "Fixes") + if not fixes or len(fixes) > _MAX_CUSTOMER_FIX_ITEMS: + raise ReleaseBodyIntegrityError( + f"Fixes must contain between 1 and {_MAX_CUSTOMER_FIX_ITEMS} bullets" + ) + if any(len(item) > _MAX_CUSTOMER_ITEM_LENGTH for item in fixes): + raise ReleaseBodyIntegrityError( + f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer" + ) + + if _INTERNAL_RELEASE_LANGUAGE_RE.search(text): + raise ReleaseBodyIntegrityError( + "customer-facing release notes contain internal release-control language" + ) + + def validate_release_notes_shape(raw_text: str, version: str) -> None: """Fail closed when authored release-note Markdown has lost its structure.""" @@ -155,6 +313,8 @@ def validate_release_notes_shape(raw_text: str, version: str) -> None: ) _highlight_items(text) + if _requires_customer_facing_standard(version): + _validate_customer_facing_release_notes(text) def strip_validation_status_block(text: str) -> str: @@ -173,34 +333,43 @@ def validate_release_body_shape( """Validate a stored GitHub release body and return its authored body.""" clean_body = strip_validation_status_block(body) - validate_release_notes_shape(clean_body, version) - if clean_body.count("## Installation\n") != 1: + if clean_body.count("## Install\n") != 1: raise ReleaseBodyIntegrityError( - "published release body must contain exactly one Installation section" + "published release body must contain exactly one Install section" ) - if clean_body.count("## Promotion Metadata\n") != 1: + if clean_body.count("## Roll back\n") != 1: raise ReleaseBodyIntegrityError( - "published release body must contain exactly one Promotion Metadata section" + "published release body must contain exactly one Roll back section" + ) + if "## Promotion Metadata\n" in clean_body: + raise ReleaseBodyIntegrityError( + "published release body must keep promotion metadata out of customer notes" ) if "Draft Release Notes" in clean_body or "_DRAFT.md" in clean_body: raise ReleaseBodyIntegrityError( "published release body still contains draft-only framing" ) - installation_index = clean_body.index("## Installation\n") - promotion_index = clean_body.index("## Promotion Metadata\n") - if installation_index >= promotion_index: + installation_index = clean_body.index("## Install\n") + rollback_index = clean_body.index("## Roll back\n") + if installation_index >= rollback_index: raise ReleaseBodyIntegrityError( - "Installation must precede Promotion Metadata in the published body" + "Install must precede Roll back in the published body" ) authored_prefix = clean_body[:installation_index] + inline_markers = _find_inline_markdown_markers(authored_prefix) + if inline_markers: + raise ReleaseBodyIntegrityError( + "release notes contain flattened Markdown: " + ", ".join(inline_markers) + ) authored_sections = re.findall(r"(?m)^##[ \t]+\S.*$", authored_prefix) if not authored_sections: raise ReleaseBodyIntegrityError( - "published release body has no authored section before Installation" + "published release body has no authored section before Install" ) + validate_release_notes_shape(authored_prefix, version) if expected_body is not None: expected_clean = strip_validation_status_block(expected_body) @@ -262,7 +431,10 @@ def sanitize_release_notes(raw_text: str, version: str) -> str: text = _normalize_newlines(raw_text) text = _replace_draft_heading(text, version) text = _drop_draft_disclaimer(text) - text = _drop_level_two_sections(text, {"## Installation", "## Promotion Metadata"}) + text = _drop_level_two_sections( + text, + {"## Installation", "## Install", "## Roll back", "## Promotion Metadata"}, + ) text = _drop_draft_packet_links(text) return _collapse_blank_lines(text) @@ -270,47 +442,33 @@ def sanitize_release_notes(raw_text: str, version: str) -> str: def build_installation_section(version: str) -> str: return "\n".join( [ - "## Installation", + "## Install", "", - "**Docker (recommended):**", "```bash", f"docker pull rcourtman/pulse:{version}", "```", "", - "**Docker Compose:**", - f"Update your `docker-compose.yml` to use `rcourtman/pulse:{version}`", + f"For Docker Compose, update the image to `rcourtman/pulse:{version}` and recreate the container.", "", - "See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions.", - "", - f"Review the [Code signing policy](https://github.com/rcourtman/Pulse/blob/v{version}/docs/CODE_SIGNING_POLICY.md) for release provenance, approval roles, and signing scope.", - "", - "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/LXC archive.", + "Pulse Pro and Relay customers should continue using the " + "[private download page](https://pulserelay.pro/download.html) and private " + "runtime image for paid features.", ] ) -def build_promotion_metadata_section(args: argparse.Namespace) -> str: - lines = [ - "## Promotion Metadata", - "", - f"- Promotion channel: {args.promotion_channel}", - f"- Candidate stable tag: {args.candidate_tag}", - f"- Promoted prerelease tag: {args.promoted_prerelease_tag or 'n/a'}", - f"- Rollback target: {args.rollback_target}", - f"- Rollback command: `{args.rollback_command}`", - ] - if args.planned_ga_date: - lines.append(f"- Planned GA date: {args.planned_ga_date}") - if args.planned_v5_eos_date: - lines.append(f"- Planned v5 end-of-support date: {args.planned_v5_eos_date}") - lines.append(f"- Hotfix exception: {args.hotfix_exception}") - if args.hotfix_reason: - lines.append(f"- Hotfix reason: {args.hotfix_reason}") - lines.append(f"- Windows Authenticode required: {args.require_windows_signing}") - lines.append(f"- Unsigned Windows exception: {args.unsigned_windows_exception}") - if args.unsigned_windows_reason: - lines.append(f"- Unsigned Windows reason: {args.unsigned_windows_reason}") - return "\n".join(lines) +def build_rollback_section(args: argparse.Namespace) -> str: + return "\n".join( + [ + "## Roll back", + "", + f"The rollback target is `{args.rollback_target}`:", + "", + "```bash", + args.rollback_command, + "```", + ] + ) def parse_args() -> argparse.Namespace: @@ -397,7 +555,7 @@ def main() -> int: sections = [ sanitized, build_installation_section(args.version), - build_promotion_metadata_section(args), + build_rollback_section(args), ] rendered = "\n\n".join(sections) + "\n" validate_release_body_shape(rendered, args.version) diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index b29d2e680..9ccc5cb49 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -189,6 +189,106 @@ class RenderReleaseBodyTest(unittest.TestCase): render_release_body.validate_release_notes_shape(notes, "6.2.1") + def test_future_release_notes_require_customer_facing_structure(self) -> None: + notes = """# Pulse v6.4.0-rc.2 Release Notes + +Pulse is faster and more predictable in larger environments. + +## What's improved + +- **Faster infrastructure views** — Tables stay responsive as estates grow. +- **Lighter realtime updates** — Pages do less work when resources change. + +## Fixes + +- Saved API keys are no longer returned to the browser. + +## Before you upgrade + +No manual migration is required. +""" + + render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.2") + + def test_customer_facing_standard_exempts_only_the_already_cut_rc1(self) -> None: + self.assertFalse( + render_release_body._requires_customer_facing_standard("6.4.0-rc.1") + ) + self.assertTrue( + render_release_body._requires_customer_facing_standard("6.4.0-beta.1") + ) + self.assertTrue( + render_release_body._requires_customer_facing_standard("v6.4.0-rc.2") + ) + self.assertTrue( + render_release_body._requires_customer_facing_standard("6.4.0") + ) + + def test_future_release_notes_reject_internal_release_control_sections(self) -> None: + notes = """# Pulse v6.4.0-rc.2 Release Notes + +Pulse is faster and more predictable in larger environments. + +## What's improved + +- **Faster infrastructure views** — Tables stay responsive as estates grow. + +## Release Qualification + +- All readiness assertions and release gates passed. +""" + + with self.assertRaisesRegex( + render_release_body.ReleaseBodyIntegrityError, + "may only use", + ): + render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.2") + + def test_future_release_notes_reject_internal_release_control_language(self) -> None: + notes = """# Pulse v6.4.0 Release Notes + +Pulse is faster and more predictable in larger environments. + +## What's improved + +- **Safer releases** — Every immutable candidate now crosses an exact-SHA gate. +""" + + with self.assertRaisesRegex( + render_release_body.ReleaseBodyIntegrityError, + "internal release-control language", + ): + render_release_body.validate_release_notes_shape(notes, "6.4.0") + + def test_future_release_notes_require_scannable_improvement_bullets(self) -> None: + notes = """# Pulse v6.4.0 Release Notes + +Pulse is faster and more predictable in larger environments. + +## What's improved + +- Tables stay responsive as estates grow. +""" + + with self.assertRaisesRegex( + render_release_body.ReleaseBodyIntegrityError, + "short bold outcome", + ): + render_release_body.validate_release_notes_shape(notes, "6.4.0") + + def test_canonical_template_keeps_machine_process_out_of_customer_notes(self) -> None: + template = ( + _REPO_ROOT / "docs/releases/RELEASE_NOTES_TEMPLATE.md" + ).read_text(encoding="utf-8") + + self.assertIn("## What's improved", template) + self.assertIn("## Fixes", template) + self.assertIn("## Before you upgrade", template) + self.assertIn("four to six meaningful improvements", template) + self.assertIn("pipeline appends the `Install` and `Roll back` sections", template) + self.assertNotIn("## Release Qualification", template) + self.assertNotIn("## Promotion Metadata", template) + def test_v621_packet_documents_cached_update_verdict_age(self) -> None: release_notes = ( _REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.2.1.md" @@ -233,7 +333,7 @@ Old metadata section. self.assertNotIn("## Installation", sanitized) self.assertNotIn("## Promotion Metadata", sanitized) - def test_main_renders_single_installation_and_promotion_metadata_sections(self) -> None: + def test_main_renders_concise_install_and_rollback_sections(self) -> None: with tempfile.TemporaryDirectory() as tmp: notes_file = Path(tmp) / "notes.md" output_file = Path(tmp) / "body.md" @@ -276,26 +376,18 @@ Old metadata section. sections = [ sanitized, render_release_body.build_installation_section(namespace.version), - render_release_body.build_promotion_metadata_section(namespace), + render_release_body.build_rollback_section(namespace), ] Path(namespace.output).write_text("\n\n".join(sections) + "\n", encoding="utf-8") body = output_file.read_text(encoding="utf-8") - self.assertEqual(body.count("## Installation"), 1) - self.assertEqual(body.count("## Promotion Metadata"), 1) + self.assertEqual(body.count("## Install"), 1) + self.assertEqual(body.count("## Roll back"), 1) + self.assertNotIn("## Promotion Metadata", body) self.assertIn("docker pull rcourtman/pulse:6.0.0-rc.2", body) - self.assertIn( - "[Code signing policy](https://github.com/rcourtman/Pulse/blob/v6.0.0-rc.2/docs/CODE_SIGNING_POLICY.md)", - body, - ) - self.assertIn( - "public GitHub release assets and the public `rcourtman/pulse` Docker image are community builds", - body, - ) self.assertIn("https://pulserelay.pro/download.html", body) - self.assertIn("- Rollback target: v5.1.28", body) - self.assertIn("- Windows Authenticode required: false", body) - self.assertIn("- Unsigned Windows exception: false", body) + self.assertIn("The rollback target is `v5.1.28`", body) + self.assertIn("./scripts/install.sh --version v5.1.28", body) render_release_body.validate_release_body_shape(body, "6.0.0-rc.2") def test_flattened_release_notes_fail_closed(self) -> None: @@ -326,13 +418,13 @@ Intro. - Patrol findings stay governed. -## Installation +## Install Install details. -## Promotion Metadata +## Roll back -- Promotion channel: rc +Rollback details. """ validation_block = """ ## Release Asset Validation: PASSED @@ -365,13 +457,13 @@ Assets passed. Intro.## Highlights- Patrol findings stay governed. -## Installation +## Install Install details. -## Promotion Metadata +## Roll back -- Promotion channel: rc +Rollback details. """ with self.assertRaisesRegex( render_release_body.ReleaseBodyIntegrityError,