fix(release): simplify in-app highlights

This commit is contained in:
rcourtman
2026-08-07 15:50:39 +01:00
parent f0e2243b44
commit 8fd43b307b
4 changed files with 195 additions and 12 deletions
+11 -9
View File
@@ -68,13 +68,15 @@ Write the release notes in exactly this format:
## v${VERSION}
### Highlights
[2-4 short bullets, plain English, covering only what a typical user would
notice and care about. This section is rendered inside the Pulse UI itself
(the post-update "What's New" banner and the update-banner preview), so each
bullet must be self-contained and jargon-free. IMPORTANT: if this release has
nothing a typical user would notice (only internal fixes or minor patches),
OMIT this entire section — that deliberately keeps the in-app banner silent
for maintenance releases. Keep the heading at level 3 (###).]
[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 (###).]
### New Features
[Genuinely new user-facing capabilities. Be specific about what users can now do.]
@@ -92,8 +94,8 @@ Guidelines:
- Do NOT write an Installation section or anything after Improvements — the
release pipeline appends those.
- Highlights is the ONE exception to "boring": it is shown in-app to users who
just updated, so pick the few changes they would actually notice — but still
facts, no hype.
just updated, so make it the shortest useful explanation of what changed —
still factual, with no hype.
Your reply must be ONLY the release-notes markdown, starting with
"## v${VERSION}" — no preamble, no code fences, no commentary.
@@ -19,6 +19,11 @@ _VALIDATION_STATUS_BLOCK_RE = re.compile(
re.DOTALL,
)
_HIGHLIGHTS_HEADING_RE = re.compile(r"^(#{2,6})[ \t]+Highlights[ \t]*$", re.IGNORECASE)
_HIGHLIGHT_BULLET_RE = re.compile(r"^-[ \t]+(.+)$")
_MAX_HIGHLIGHT_ITEMS = 3
_MAX_HIGHLIGHT_LENGTH = 140
def _normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
@@ -40,6 +45,75 @@ def _find_inline_markdown_markers(text: str) -> list[str]:
return markers
def _highlight_items(text: str) -> list[str] | None:
"""Return the optional in-app Highlights list after validating its shape."""
lines = _normalize_newlines(text).splitlines()
headings: list[tuple[int, int]] = []
for index, line in enumerate(lines):
match = _HIGHLIGHTS_HEADING_RE.fullmatch(line.strip())
if match:
headings.append((index, len(match.group(1))))
if not headings:
return None
if len(headings) > 1:
raise ReleaseBodyIntegrityError(
"release notes must contain at most one Highlights section"
)
start_index, start_level = headings[0]
section_lines: list[str] = []
for line in lines[start_index + 1 :]:
heading = re.fullmatch(r"(#{1,6})[ \t]+\S.*", line.strip())
if heading and len(heading.group(1)) <= start_level:
break
section_lines.append(line)
items: list[str] = []
for line in section_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(
"Highlights must be a flat list of short plain-text bullets"
)
if not items:
raise ReleaseBodyIntegrityError("Highlights must contain at least one bullet")
if len(items) > _MAX_HIGHLIGHT_ITEMS:
raise ReleaseBodyIntegrityError(
f"Highlights may contain at most {_MAX_HIGHLIGHT_ITEMS} bullets"
)
for item in items:
if len(item) > _MAX_HIGHLIGHT_LENGTH:
raise ReleaseBodyIntegrityError(
"each Highlights bullet must be "
f"{_MAX_HIGHLIGHT_LENGTH} characters or fewer"
)
if (
"`" in item
or "*" in item
or "_" in item
or re.search(r"!?\[[^\]]+\]\([^\)]+\)", item)
or re.search(r"<[^>]+>", item)
or re.search(r"\(#[0-9]+\)", item)
):
raise ReleaseBodyIntegrityError(
"Highlights bullets must use plain text without links, code, HTML, "
"or issue references"
)
return items
def validate_release_notes_shape(raw_text: str, version: str) -> None:
"""Fail closed when authored release-note Markdown has lost its structure."""
@@ -71,6 +145,8 @@ def validate_release_notes_shape(raw_text: str, version: str) -> None:
"release notes contain flattened Markdown: " + ", ".join(inline_markers)
)
_highlight_items(text)
def strip_validation_status_block(text: str) -> str:
"""Remove the workflow-owned validation annotation from a release body."""
@@ -29,6 +29,105 @@ def _discover_rc_draft_packet_paths() -> tuple[str, ...]:
class RenderReleaseBodyTest(unittest.TestCase):
def test_highlights_are_a_small_plain_language_overview(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## Highlights
- Alerts now explain what went wrong and what to do next.
- Tables are easier to use on phones and small screens.
- Updates recover cleanly when an earlier installation was interrupted.
## Fixed
- Corrected a release issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_generated_level_three_highlights_and_wrapped_bullets_are_supported(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## v6.2.0
### Highlights
- Alerts now explain what went wrong and what to do
next.
- Tables are easier to use on small screens.
### Bug Fixes
- Corrected a release issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_reject_more_than_three_items(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## Highlights
- One.
- Two.
- Three.
- Four.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"at most 3 bullets",
):
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_reject_long_or_formatted_items(self) -> None:
long_item = "A" * 141
long_notes = f"""# Pulse v6.2.0 Release Notes
## Highlights
- {long_item}
## Fixed
- Corrected a release issue.
"""
formatted_notes = """# Pulse v6.2.0 Release Notes
## Highlights
- Read the [upgrade guide](https://example.com) for details.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"140 characters or fewer",
):
render_release_body.validate_release_notes_shape(long_notes, "6.2.0")
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"must use plain text",
):
render_release_body.validate_release_notes_shape(formatted_notes, "6.2.0")
def test_release_notes_may_omit_highlights(self) -> None:
notes = """# Pulse v6.2.1 Release Notes
## Fixed
- Corrected a maintenance issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.1")
def test_sanitize_release_notes_strips_draft_markers_duplicate_sections_and_draft_links(self) -> None:
raw = """# Pulse v6.0.0-rc.2 Draft Release Notes