#!/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]+(.+)$") _MAX_HIGHLIGHT_ITEMS = 3 _MAX_HIGHLIGHT_LENGTH = 140 def _normalize_newlines(text: str) -> 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 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.""" 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) 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) validate_release_notes_shape(clean_body, version) if clean_body.count("## Installation\n") != 1: raise ReleaseBodyIntegrityError( "published release body must contain exactly one Installation section" ) if clean_body.count("## Promotion Metadata\n") != 1: raise ReleaseBodyIntegrityError( "published release body must contain exactly one Promotion Metadata section" ) 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: raise ReleaseBodyIntegrityError( "Installation must precede Promotion Metadata in the published body" ) authored_prefix = clean_body[:installation_index] 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" ) 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", "## Promotion Metadata"}) text = _drop_draft_packet_links(text) return _collapse_blank_lines(text) def build_installation_section(version: str) -> str: return "\n".join( [ "## Installation", "", "**Docker (recommended):**", "```bash", f"docker pull rcourtman/pulse:{version}", "```", "", "**Docker Compose:**", f"Update your `docker-compose.yml` to use `rcourtman/pulse:{version}`", "", "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.", ] ) 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 parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True) parser.add_argument("--release-notes-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, build_installation_section(args.version), build_promotion_metadata_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())