Enforce customer-facing release notes

This commit is contained in:
Richard Courtman
2026-08-25 17:13:07 +01:00
parent f18f15bf7b
commit 1c76a7cbdc
13 changed files with 532 additions and 160 deletions
+6 -6
View File
@@ -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."
@@ -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
+32
View File
@@ -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.
+9 -1
View File
@@ -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:
+13 -40
View File
@@ -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"
]
}
@@ -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(() => <UpdateBanner />);
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();
});
});
@@ -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}
</p>
{/* What's new preview (curated Highlights section of the release notes) */}
{/* What's new preview (customer-facing improvements or historical Highlights) */}
<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">
@@ -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',
@@ -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<Record<string, string>> = {
'new features': 'Added',
improved: 'Improved',
improvements: 'Improved',
"what's improved": 'Improved',
'whats improved': 'Improved',
changed: 'Changed',
fixed: 'Fixed',
fixes: 'Fixed',
'bug fixes': 'Fixed',
security: 'Security',
'breaking changes': 'Breaking changes',
+32 -33
View File
@@ -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
@@ -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")
+201 -43
View File
@@ -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",
"whats 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"(?<![A-Za-z0-9])#[0-9]+\b"
@@ -32,6 +56,10 @@ _ISSUE_REFERENCE_RE = re.compile(
)
_MAX_HIGHLIGHT_ITEMS = 3
_MAX_HIGHLIGHT_LENGTH = 140
_MAX_CUSTOMER_IMPROVEMENT_ITEMS = 6
_MAX_CUSTOMER_FIX_ITEMS = 12
_MAX_CUSTOMER_ITEM_LENGTH = 260
_MAX_CUSTOMER_SUMMARY_LENGTH = 600
def _normalize_newlines(text: str) -> 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", "whats 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)
@@ -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 = """<!-- VALIDATION_STATUS_START -->
## 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,