mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(release): simplify in-app highlights
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user