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,