diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e49458fd5..9eb31f91e 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -657,6 +657,11 @@ upgrade, update, release, or artifact-selection behavior. while the post-update card may show that same section once per later installed release and must stay silent for a first baseline, malformed or development versions, missing releases, and releases without highlights. + When present, `Highlights` is the complete in-app overview: release + rendering must keep it to at most three short plain-text bullets of no more + than 140 characters each, with links, code, issue references, nested + structure, and implementation-oriented detail reserved for the full release + notes. The same post-update communication boundary owns the one-time schema-v2 telemetry payload notice. It must use a non-blocking shared notice banner, appear only for existing installations on a published build, stay silent @@ -1416,9 +1421,10 @@ experience. Update checks can preview a curated `Highlights` section, and an authenticated running-version endpoint lets the update surface show those same published highlights once after a later upgrade. Missing highlights stay quiet by design, and source or development builds never masquerade as -published releases. Post-update highlights use the shared dialog so multi-item -release content has a readable measure without pushing the dashboard down; -every close path still records the running release as seen. +published releases. Post-update highlights are limited to three short, +plain-text user outcomes and use the shared dialog so the overview stays easy +to scan without pushing the dashboard down; every close path still records the +running release as seen. The initial GA promotion metadata remains `promoted_from_tag=v6.0.0-rc.7`, `rollback_version=v5.1.35`, diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 5f1471e47..0f44b4db0 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -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. diff --git a/scripts/release_control/render_release_body.py b/scripts/release_control/render_release_body.py index 86aaf8046..bc7526aff 100644 --- a/scripts/release_control/render_release_body.py +++ b/scripts/release_control/render_release_body.py @@ -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.""" diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index a7bc5649d..d1d40920e 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -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