#!/usr/bin/env python3 """Render a publish-safe GitHub release body from the current RC packet.""" from __future__ import annotations import argparse import re import sys from pathlib import Path class ReleaseBodyIntegrityError(ValueError): """Raised when release-note Markdown is not safe to publish.""" _VALIDATION_STATUS_BLOCK_RE = re.compile( r"(?:^|\n).*?" r"(?:\n{0,2}|$)", re.DOTALL, ) _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: return text.replace("\r\n", "\n").replace("\r", "\n") def _canonical_body(text: str) -> str: return _normalize_newlines(text).rstrip("\n") + "\n" def _find_inline_markdown_markers(text: str) -> list[str]: markers: list[str] = [] for line_number, line in enumerate(_normalize_newlines(text).splitlines(), start=1): heading_markers = re.finditer(r"#{2,6}[ \t]+\S", line) if any(match.start() != 0 for match in heading_markers): markers.append(f"inline heading marker on line {line_number}") fence_markers = re.finditer(r"```(?:[A-Za-z0-9_-]+)?", line) if any(match.start() != 0 for match in fence_markers): markers.append(f"inline code-fence marker on line {line_number}") 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 _ISSUE_REFERENCE_RE.search(item) ): raise ReleaseBodyIntegrityError( "Highlights bullets must use plain text without links, code, HTML, " "or issue references" ) 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 _requires_single_change_list(version: str) -> bool: """Return whether fixes must be folded into the one customer outcome list.""" normalized = version.lower().removeprefix("v") core = _release_core(normalized) if core is None or core < (6, 4, 0): return False if core > (6, 4, 0): return True rc_match = re.fullmatch(r"6\.4\.0-rc\.(\d+)", normalized) return rc_match is None or int(rc_match.group(1)) >= 6 def _requires_plain_release_punctuation(version: str) -> bool: """Apply the punctuation rule after the already-published v6.4 RC packets.""" normalized = version.lower().removeprefix("v") core = _release_core(normalized) if core is None or core < (6, 4, 0): return False if core > (6, 4, 0): return True rc_match = re.fullmatch(r"6\.4\.0-rc\.(\d+)", normalized) return rc_match is None or int(rc_match.group(1)) >= 7 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, version: str) -> None: """Enforce concise public notes without release-control implementation prose.""" lines = _normalize_newlines(text).strip().splitlines() if _requires_plain_release_punctuation(version) and any( character in text for character in (";", "—") ): raise ReleaseBodyIntegrityError( "customer-facing release notes must not contain semicolons or em dashes" ) 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: raise ReleaseBodyIntegrityError( "What's improved must contain at least one bullet" ) 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: if _requires_single_change_list(version): raise ReleaseBodyIntegrityError( "customer-facing release notes must describe features and fixes once " "in What's improved instead of adding a separate Fixes section" ) 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.""" text = _normalize_newlines(raw_text).strip() if not text: raise ReleaseBodyIntegrityError("release notes are empty") lines = text.splitlines() expected_title = re.compile( rf"^# Pulse v{re.escape(version)} (?:Draft )?Release Notes$" ) if not expected_title.fullmatch(lines[0]): raise ReleaseBodyIntegrityError( "the first line must be the standalone release title " f"'# Pulse v{version} Release Notes' (or its Draft form)" ) level_two_headings = [ line for line in lines if re.fullmatch(r"##[ \t]+\S.*", line) ] if not level_two_headings: raise ReleaseBodyIntegrityError( "release notes must contain at least one standalone level-two section" ) inline_markers = _find_inline_markdown_markers(text) if inline_markers: raise ReleaseBodyIntegrityError( "release notes contain flattened Markdown: " + ", ".join(inline_markers) ) _highlight_items(text) if _requires_customer_facing_standard(version): _validate_customer_facing_release_notes(text, version) def strip_validation_status_block(text: str) -> str: """Remove the workflow-owned validation annotation from a release body.""" stripped = _VALIDATION_STATUS_BLOCK_RE.sub("\n", _normalize_newlines(text), count=1) return _canonical_body(stripped.lstrip("\n")) def validate_release_body_shape( body: str, version: str, *, expected_body: str | None = None, ) -> str: """Validate a stored GitHub release body and return its authored body.""" clean_body = strip_validation_status_block(body) if clean_body.count("## Install\n") != 1: raise ReleaseBodyIntegrityError( "published release body must contain exactly one Install section" ) if clean_body.count("## Roll back\n") != 1: raise ReleaseBodyIntegrityError( "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("## Install\n") rollback_index = clean_body.index("## Roll back\n") if installation_index >= rollback_index: raise ReleaseBodyIntegrityError( "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 Install" ) visual_heading = "\n## See the difference\n" if authored_prefix.count(visual_heading) > 1: raise ReleaseBodyIntegrityError( "published release body must contain at most one See the difference section" ) authored_notes = authored_prefix.split(visual_heading, 1)[0] validate_release_notes_shape(authored_notes, version) if expected_body is not None: expected_clean = strip_validation_status_block(expected_body) if clean_body != expected_clean: raise ReleaseBodyIntegrityError( "GitHub's stored release body does not exactly match the expected " "rendered Markdown" ) return clean_body def _replace_draft_heading(text: str, version: str) -> str: lines = text.splitlines() for index, line in enumerate(lines): if line.startswith("# ") and "Draft Release Notes" in line: lines[index] = f"# Pulse v{version} Release Notes" return "\n".join(lines) return text def _drop_draft_disclaimer(text: str) -> str: pattern = re.compile( r"(?:^|\n)_?Draft only\. Do not treat this as published.*?(?:\n{2,}|$)", re.DOTALL, ) return pattern.sub("\n\n", text, count=1) def _drop_level_two_sections(text: str, headings: set[str]) -> str: lines = text.splitlines() kept: list[str] = [] skip = False for line in lines: stripped = line.strip() if stripped in headings: skip = True continue if skip and stripped.startswith("## "): skip = False if not skip: kept.append(line) return "\n".join(kept) def _drop_draft_packet_links(text: str) -> str: return "\n".join(line for line in text.splitlines() if "_DRAFT.md" not in line) def _collapse_blank_lines(text: str) -> str: text = _normalize_newlines(text).strip() text = re.sub(r"\n{3,}", "\n\n", text) return text + "\n" 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", "## Install", "## Roll back", "## Promotion Metadata"}, ) text = _drop_draft_packet_links(text) return _collapse_blank_lines(text) def build_installation_section(version: str) -> str: return "\n".join( [ "## Install", "", "For systemd and Proxmox LXC installs, use **Settings → System → Updates** or:", "", "```bash", f"sudo /bin/update --version v{version}", "```", "", "For Docker:", "", "```bash", f"docker pull rcourtman/pulse:{version}", "```", "", f"For Docker Compose, update the image to `rcourtman/pulse:{version}` and recreate the container.", "", "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_rollback_section(args: argparse.Namespace) -> str: return "\n".join( [ "## Roll back", "", f"The rollback target is `{args.rollback_target}`:", "", "```bash", args.rollback_command, "```", "", "For Docker Compose, set the Pulse image to the rollback target and recreate the container.", ] ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True) parser.add_argument("--release-notes-file") parser.add_argument("--release-visuals-file") parser.add_argument("--validate-notes-file") parser.add_argument("--validate-body-file") parser.add_argument("--expected-body-file") parser.add_argument("--output") parser.add_argument("--promotion-channel") parser.add_argument("--candidate-tag") parser.add_argument("--promoted-prerelease-tag", default="") parser.add_argument("--rollback-target") parser.add_argument("--rollback-command") parser.add_argument("--planned-ga-date", default="") parser.add_argument("--planned-v5-eos-date", default="") parser.add_argument("--hotfix-exception") parser.add_argument("--hotfix-reason", default="") parser.add_argument("--require-windows-signing") parser.add_argument("--unsigned-windows-exception") parser.add_argument("--unsigned-windows-reason", default="") args = parser.parse_args() if args.validate_notes_file or args.validate_body_file: if args.release_notes_file: parser.error( "validation modes cannot be combined with --release-notes-file" ) if args.validate_notes_file and args.validate_body_file: parser.error( "--validate-notes-file cannot be combined with --validate-body-file" ) if args.validate_notes_file and (args.expected_body_file or args.output): parser.error( "--validate-notes-file cannot use --expected-body-file or --output" ) return args required_render_args = { "--release-notes-file": args.release_notes_file, "--output": args.output, "--promotion-channel": args.promotion_channel, "--candidate-tag": args.candidate_tag, "--rollback-target": args.rollback_target, "--rollback-command": args.rollback_command, "--hotfix-exception": args.hotfix_exception, "--require-windows-signing": args.require_windows_signing, "--unsigned-windows-exception": args.unsigned_windows_exception, } missing = [name for name, value in required_render_args.items() if value is None] if missing: parser.error("render mode requires " + ", ".join(missing)) if args.expected_body_file: parser.error("--expected-body-file requires --validate-body-file") return args def main() -> int: args = parse_args() try: if args.validate_notes_file: raw_text = Path(args.validate_notes_file).read_text(encoding="utf-8") validate_release_notes_shape(raw_text, args.version) return 0 if args.validate_body_file: body = Path(args.validate_body_file).read_text(encoding="utf-8") expected_body = None if args.expected_body_file: expected_body = Path(args.expected_body_file).read_text(encoding="utf-8") clean_body = validate_release_body_shape( body, args.version, expected_body=expected_body, ) if args.output: Path(args.output).write_text(clean_body, encoding="utf-8") return 0 raw_text = Path(args.release_notes_file).read_text(encoding="utf-8") validate_release_notes_shape(raw_text, args.version) sanitized = sanitize_release_notes(raw_text, args.version).rstrip("\n") sections = [sanitized] if args.release_visuals_file: release_visuals = Path(args.release_visuals_file).read_text( encoding="utf-8" ).strip() if release_visuals: if not release_visuals.startswith("## See the difference\n"): raise ReleaseBodyIntegrityError( "release visuals must begin with '## See the difference'" ) sections.append(release_visuals) sections.extend( [ build_installation_section(args.version), build_rollback_section(args), ] ) rendered = "\n\n".join(sections) + "\n" validate_release_body_shape(rendered, args.version) Path(args.output).write_text(rendered, encoding="utf-8") return 0 except ReleaseBodyIntegrityError as exc: print(f"release body integrity check failed: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())