#!/usr/bin/env python3"""Release management tools for Manim. This script provides commands for preparing and managing Manim releases:- Generate changelogs from GitHub's release notes API- Export documentation changelogs as GitHub-flavored release notes- Update CITATION.cff with new version information- Fetch existing release notes for documentation Usage: # Generate changelog for an upcoming release uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 # Include a PR omitted by GitHub's generated release notes uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 --include-extra 1234 # Also update CITATION.cff at the same time uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 --update-citation # Export an edited changelog for pasting into a GitHub release uv run python scripts/release.py export-changelog 0.20.0 # Update only CITATION.cff uv run python scripts/release.py citation --version 0.20.0 # Fetch existing release changelogs for documentation uv run python scripts/release.py fetch-releases Requirements: - gh CLI installed and authenticated - Python 3.11+""" from __future__ import annotations import jsonimport reimport subprocessimport sysfrom datetime import datetimefrom pathlib import Pathfrom typing import TYPE_CHECKING import click if TYPE_CHECKING: from collections.abc import Sequence # =============================================================================# Constants# ============================================================================= REPO = "manimcommunity/manim" SCRIPT_DIR = Path(__file__).resolve().parentREPO_ROOT = SCRIPT_DIR.parentCHANGELOG_DIR = REPO_ROOT / "docs" / "source" / "changelog"CITATION_TEMPLATE = SCRIPT_DIR / "TEMPLATE.cff"CITATION_FILE = REPO_ROOT / "CITATION.cff" # Minimum version for fetching historical releasesDEFAULT_MIN_VERSION = "0.18.0" # =============================================================================# GitHub CLI Helpers# ============================================================================= def run_gh( args: Sequence[str], *, check: bool = True, suppress_errors: bool = False,) -> subprocess.CompletedProcess[str]: """ Run a gh CLI command. Args: args: Arguments to pass to gh check: If True, raise on non-zero exit suppress_errors: If True, don't print errors to stderr Returns: CompletedProcess with stdout/stderr """ result = subprocess.run( ["gh", *args], capture_output=True, text=True, ) if ( result.returncode != 0 and not suppress_errors and "not found" not in result.stderr.lower() ): click.echo(f"gh error: {result.stderr}", err=True) if check and result.returncode != 0: raise click.ClickException(f"gh command failed: gh {' '.join(args)}") return result def get_release_tags() -> list[str]: """Get all published release tags from GitHub, newest first.""" result = run_gh( ["release", "list", "--repo", REPO, "--limit", "100", "--json", "tagName"], check=False, ) if result.returncode != 0 or not result.stdout.strip(): return [] try: data = json.loads(result.stdout) return [item["tagName"] for item in data] except (json.JSONDecodeError, KeyError): return [] def get_release_body(tag: str) -> str | None: """Get the release body for a published release.""" result = run_gh( ["release", "view", tag, "--repo", REPO, "--json", "body", "--jq", ".body"], check=False, suppress_errors=True, ) if result.returncode != 0: return None return result.stdout.strip() or None def get_release_date(tag: str) -> str | None: """Get the release date formatted as 'Month DD, YYYY'.""" result = run_gh( [ "release", "view", tag, "--repo", REPO, "--json", "createdAt", "--jq", ".createdAt", ], check=False, ) if result.returncode != 0: return None date_str = result.stdout.strip().strip('"') try: dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) return dt.strftime("%B %d, %Y") except ValueError: return None def get_pull_request_release_note(number: int) -> str: """Return a GitHub-style release-note bullet for a pull request.""" result = run_gh( [ "pr", "view", str(number), "--repo", REPO, "--json", "title,author,url", ] ) try: data = json.loads(result.stdout) title = data["title"] author = data["author"]["login"] url = data["url"] except (json.JSONDecodeError, KeyError, TypeError) as error: raise click.ClickException(f"Could not read pull request #{number}") from error return f"* {title} by @{author} in {url}" def generate_release_notes(head_tag: str, base_tag: str) -> str: """ Generate release notes using GitHub's API. This respects .github/release.yml for PR categorization. """ result = run_gh( [ "api", f"repos/{REPO}/releases/generate-notes", "--field", f"tag_name={head_tag}", "--field", f"previous_tag_name={base_tag}", "--jq", ".body", ] ) body = result.stdout.strip() if not body: raise click.ClickException("GitHub API returned empty release notes") return body # =============================================================================# Version Utilities# ============================================================================= def normalize_tag(tag: str) -> str: """Ensure tag has 'v' prefix.""" return tag if tag.startswith("v") else f"v{tag}" def version_from_tag(tag: str) -> str: """Extract version from tag (e.g., 'v0.18.0' -> '0.18.0').""" return tag[1:] if tag.startswith("v") else tag def parse_version(version: str) -> tuple[int, ...]: """Parse version string into comparable tuple.""" # Handle post-releases like '0.18.0.post0' version = version.replace(".post", "-post") parts = [] for part in version.replace("-", ".").split("."): try: parts.append(int(part)) except ValueError: continue # Pad to at least 3 components while len(parts) < 3: parts.append(0) return tuple(parts) def version_gte(version: str, min_version: str) -> bool: """Check if version >= min_version.""" return parse_version(version) >= parse_version(min_version) # =============================================================================# Markdown Conversion# ============================================================================= _PR_REFERENCE = re.compile( r"(?:\{pr\}`(?P<myst>\d+)`|" r"https://github\.com/ManimCommunity/manim/pull/(?P<url>\d+))")_TOP_LEVEL_BULLET = re.compile(r"^[*-]\s")_SECOND_LEVEL_HEADING = re.compile(r"^##(?!#)\s+(.+?)\s*$")_ANY_HEADING = re.compile(r"^#{1,6}\s")_CHANGELOG_SECTION_SCOPES = { "What's Changed": "changes", "New Contributors": "contributors",} def include_extra_pull_requests(body: str, numbers: Sequence[int]) -> str: """Add explicitly requested PRs to the generated "Other Changes" category.""" existing_numbers = { int(match.group("url")) for match in _PR_REFERENCE.finditer(body) if match.group("url") is not None } entries = [ get_pull_request_release_note(number) for number in numbers if number not in existing_numbers ] if not entries: return body lines = body.splitlines() category_heading = "### Other Changes" try: category_start = lines.index(category_heading) except ValueError: insertion_point = next( ( index for index, line in enumerate(lines) if line == "## New Contributors" or line.startswith("**Full Changelog**:") ), len(lines), ) lines[insertion_point:insertion_point] = [category_heading, *entries] else: insertion_point = category_start + 1 while insertion_point < len(lines) and not lines[insertion_point].startswith( "#" ): insertion_point += 1 while ( insertion_point > category_start + 1 and not lines[insertion_point - 1].strip() ): insertion_point -= 1 lines[insertion_point:insertion_point] = entries return "\n".join(lines) def _find_pull_request_blocks( lines: list[str],) -> list[tuple[int, int, tuple[str, str]]]: """Find PR list items and their continuation lines in a changelog. The returned key includes the enclosing second-level section so that a PR's release note and its potential "New Contributors" entry are kept separate. Third-level category headings are deliberately not part of the key: the newly generated changelog remains authoritative for PR categorization. """ blocks = [] section_scope: str | None = None line_number = 0 while line_number < len(lines): line = lines[line_number] heading = _SECOND_LEVEL_HEADING.match(line.rstrip("\r\n")) if heading: section_scope = _CHANGELOG_SECTION_SCOPES.get(heading.group(1)) line_number += 1 continue if section_scope is None or not _TOP_LEVEL_BULLET.match(line): line_number += 1 continue pr_reference = _PR_REFERENCE.search(line) if pr_reference is None: line_number += 1 continue pr_number = pr_reference.group("myst") or pr_reference.group("url") block_end = line_number + 1 while block_end < len(lines): next_line = lines[block_end] if ( _TOP_LEVEL_BULLET.match(next_line) or _ANY_HEADING.match(next_line) or next_line.startswith("**Full Changelog**:") ): break block_end += 1 while block_end > line_number + 1 and not lines[block_end - 1].strip(): block_end -= 1 blocks.append( (line_number, block_end, (section_scope, pr_number)), ) line_number = block_end return blocks def merge_changelog(generated: str, existing: str) -> str: """Merge existing PR note edits into a newly generated changelog. The generated changelog controls which PRs are present, their ordering, and their category headings. For every PR that is present in both versions, its existing list item (including indented descriptions) is retained. Thus, deleted entries are restored and label changes move entries to their newly generated categories without discarding copy edits. """ generated_lines = generated.splitlines(keepends=True) existing_lines = existing.splitlines(keepends=True) existing_blocks = { key: "".join(existing_lines[start:end]) for start, end, key in _find_pull_request_blocks(existing_lines) } merged = [] previous_end = 0 for start, end, key in _find_pull_request_blocks(generated_lines): merged.extend(generated_lines[previous_end:start]) existing_block = existing_blocks.get(key) if existing_block is None: merged.extend(generated_lines[start:end]) else: merged.append(existing_block) previous_end = end merged.extend(generated_lines[previous_end:]) return "".join(merged) def convert_to_myst(body: str) -> str: """ Convert GitHub markdown to MyST format. - PR URLs -> {pr}`NUMBER` - Issue URLs -> {issue}`NUMBER` - @mentions -> {user}`USERNAME` - Strips HTML comments - Ensures proper list spacing """ lines = body.split("\n") result = [] prev_bullet = False for line in lines: # Skip HTML comments if line.strip().startswith("<!--") and line.strip().endswith("-->"): continue # Convert URLs to extlinks line = re.sub( r"https://github\.com/ManimCommunity/manim/pull/(\d+)", r"{pr}`\1`", line, ) line = re.sub( r"https://github\.com/ManimCommunity/manim/issues/(\d+)", r"{issue}`\1`", line, ) line = re.sub(r"@([a-zA-Z0-9_-]+)", r"{user}`\1`", line) if line.startswith("**Full Changelog**:"): _, url = line.split(":", 1) url = url.strip().replace("vmain", "main") line = f"**Full Changelog**: [Compare view]({url})" # Handle list spacing is_bullet = line.strip().startswith("*") and not line.strip().startswith("**") if prev_bullet and not is_bullet and line.strip(): result.append("") result.append(line) prev_bullet = is_bullet return "\n".join(result) def format_changelog( version: str, body: str, date: str | None = None, title: str | None = None,) -> str: """Create changelog file content with MyST frontmatter.""" title = title or f"v{version}" body = convert_to_myst(body) date_section = f"Date\n: {date}\n" if date else "" return f"""---short-title: {title}description: Changelog for {title}--- # {title} {date_section}{body}""" def convert_to_github(body: str) -> str: """Convert the MyST roles used by changelogs to GitHub Markdown.""" body = re.sub( r"\{pr\}`(\d+)`", rf"https://github.com/{REPO}/pull/\1", body, ) body = re.sub( r"\{issue\}`(\d+)`", rf"https://github.com/{REPO}/issues/\1", body, ) body = re.sub(r"\{user\}`([a-zA-Z0-9_-]+)`", r"@\1", body) body = re.sub(r"\{class\}`\.?(.*?)`", r"`\1`", body) return body def format_github_release_notes(version: str, content: str) -> str: """Create a GitHub release body from a documentation changelog.""" content = re.sub(r"\A---\r?\n.*?\r?\n---\r?\n", "", content, flags=re.DOTALL) lines = content.splitlines() while lines and not lines[0].strip(): lines.pop(0) if lines and lines[0].startswith("# "): lines.pop(0) while lines and not lines[0].strip(): lines.pop(0) if len(lines) >= 2 and lines[0] == "Date" and lines[1].startswith(": "): del lines[:2] while lines and not lines[0].strip(): lines.pop(0) body = convert_to_github("\n".join(lines)).rstrip() changelog_url = ( f"https://docs.manim.community/en/stable/changelog/{version}-changelog.html" ) return f"See [our rendered changelog]({changelog_url}).\n\n{body}\n" # =============================================================================Lines 1–500 of 790. Load more
#!/usr/bin/env python3 """ Release management tools for Manim. This script provides commands for preparing and managing Manim releases: - Generate changelogs from GitHub's release notes API - Export documentation changelogs as GitHub-flavored release notes - Update CITATION.cff with new version information - Fetch existing release notes for documentation Usage: # Generate changelog for an upcoming release uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 # Include a PR omitted by GitHub's generated release notes uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 --include-extra 1234 # Also update CITATION.cff at the same time uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 --update-citation # Export an edited changelog for pasting into a GitHub release uv run python scripts/release.py export-changelog 0.20.0 # Update only CITATION.cff uv run python scripts/release.py citation --version 0.20.0 # Fetch existing release changelogs for documentation uv run python scripts/release.py fetch-releases Requirements: - gh CLI installed and authenticated - Python 3.11+ """ from __future__ import annotations import json import re import subprocess import sys from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING import click if TYPE_CHECKING: from collections.abc import Sequence # ============================================================================= # Constants # ============================================================================= REPO = "manimcommunity/manim" SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent CHANGELOG_DIR = REPO_ROOT / "docs" / "source" / "changelog" CITATION_TEMPLATE = SCRIPT_DIR / "TEMPLATE.cff" CITATION_FILE = REPO_ROOT / "CITATION.cff" # Minimum version for fetching historical releases DEFAULT_MIN_VERSION = "0.18.0" # ============================================================================= # GitHub CLI Helpers # ============================================================================= def run_gh( args: Sequence[str], *, check: bool = True, suppress_errors: bool = False, ) -> subprocess.CompletedProcess[str]: """ Run a gh CLI command. Args: args: Arguments to pass to gh check: If True, raise on non-zero exit suppress_errors: If True, don't print errors to stderr Returns: CompletedProcess with stdout/stderr """ result = subprocess.run( ["gh", *args], capture_output=True, text=True, ) if ( result.returncode != 0 and not suppress_errors and "not found" not in result.stderr.lower() ): click.echo(f"gh error: {result.stderr}", err=True) if check and result.returncode != 0: raise click.ClickException(f"gh command failed: gh {' '.join(args)}") return result def get_release_tags() -> list[str]: """Get all published release tags from GitHub, newest first.""" result = run_gh( ["release", "list", "--repo", REPO, "--limit", "100", "--json", "tagName"], check=False, ) if result.returncode != 0 or not result.stdout.strip(): return [] try: data = json.loads(result.stdout) return [item["tagName"] for item in data] except (json.JSONDecodeError, KeyError): return [] def get_release_body(tag: str) -> str | None: """Get the release body for a published release.""" result = run_gh( ["release", "view", tag, "--repo", REPO, "--json", "body", "--jq", ".body"], check=False, suppress_errors=True, ) if result.returncode != 0: return None return result.stdout.strip() or None def get_release_date(tag: str) -> str | None: """Get the release date formatted as 'Month DD, YYYY'.""" result = run_gh( [ "release", "view", tag, "--repo", REPO, "--json", "createdAt", "--jq", ".createdAt", ], check=False, ) if result.returncode != 0: return None date_str = result.stdout.strip().strip('"') try: dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) return dt.strftime("%B %d, %Y") except ValueError: return None def get_pull_request_release_note(number: int) -> str: """Return a GitHub-style release-note bullet for a pull request.""" result = run_gh( [ "pr", "view", str(number), "--repo", REPO, "--json", "title,author,url", ] ) try: data = json.loads(result.stdout) title = data["title"] author = data["author"]["login"] url = data["url"] except (json.JSONDecodeError, KeyError, TypeError) as error: raise click.ClickException(f"Could not read pull request #{number}") from error return f"* {title} by @{author} in {url}" def generate_release_notes(head_tag: str, base_tag: str) -> str: """ Generate release notes using GitHub's API. This respects .github/release.yml for PR categorization. """ result = run_gh( [ "api", f"repos/{REPO}/releases/generate-notes", "--field", f"tag_name={head_tag}", "--field", f"previous_tag_name={base_tag}", "--jq", ".body", ] ) body = result.stdout.strip() if not body: raise click.ClickException("GitHub API returned empty release notes") return body # ============================================================================= # Version Utilities # ============================================================================= def normalize_tag(tag: str) -> str: """Ensure tag has 'v' prefix.""" return tag if tag.startswith("v") else f"v{tag}" def version_from_tag(tag: str) -> str: """Extract version from tag (e.g., 'v0.18.0' -> '0.18.0').""" return tag[1:] if tag.startswith("v") else tag def parse_version(version: str) -> tuple[int, ...]: """Parse version string into comparable tuple.""" # Handle post-releases like '0.18.0.post0' version = version.replace(".post", "-post") parts = [] for part in version.replace("-", ".").split("."): try: parts.append(int(part)) except ValueError: continue # Pad to at least 3 components while len(parts) < 3: parts.append(0) return tuple(parts) def version_gte(version: str, min_version: str) -> bool: """Check if version >= min_version.""" return parse_version(version) >= parse_version(min_version) # ============================================================================= # Markdown Conversion # ============================================================================= _PR_REFERENCE = re.compile( r"(?:\{pr\}`(?P<myst>\d+)`|" r"https://github\.com/ManimCommunity/manim/pull/(?P<url>\d+))" ) _TOP_LEVEL_BULLET = re.compile(r"^[*-]\s") _SECOND_LEVEL_HEADING = re.compile(r"^##(?!#)\s+(.+?)\s*$") _ANY_HEADING = re.compile(r"^#{1,6}\s") _CHANGELOG_SECTION_SCOPES = { "What's Changed": "changes", "New Contributors": "contributors", } def include_extra_pull_requests(body: str, numbers: Sequence[int]) -> str: """Add explicitly requested PRs to the generated "Other Changes" category.""" existing_numbers = { int(match.group("url")) for match in _PR_REFERENCE.finditer(body) if match.group("url") is not None } entries = [ get_pull_request_release_note(number) for number in numbers if number not in existing_numbers ] if not entries: return body lines = body.splitlines() category_heading = "### Other Changes" try: category_start = lines.index(category_heading) except ValueError: insertion_point = next( ( index for index, line in enumerate(lines) if line == "## New Contributors" or line.startswith("**Full Changelog**:") ), len(lines), ) lines[insertion_point:insertion_point] = [category_heading, *entries] else: insertion_point = category_start + 1 while insertion_point < len(lines) and not lines[insertion_point].startswith( "#" ): insertion_point += 1 while ( insertion_point > category_start + 1 and not lines[insertion_point - 1].strip() ): insertion_point -= 1 lines[insertion_point:insertion_point] = entries return "\n".join(lines) def _find_pull_request_blocks( lines: list[str], ) -> list[tuple[int, int, tuple[str, str]]]: """Find PR list items and their continuation lines in a changelog. The returned key includes the enclosing second-level section so that a PR's release note and its potential "New Contributors" entry are kept separate. Third-level category headings are deliberately not part of the key: the newly generated changelog remains authoritative for PR categorization. """ blocks = [] section_scope: str | None = None line_number = 0 while line_number < len(lines): line = lines[line_number] heading = _SECOND_LEVEL_HEADING.match(line.rstrip("\r\n")) if heading: section_scope = _CHANGELOG_SECTION_SCOPES.get(heading.group(1)) line_number += 1 continue if section_scope is None or not _TOP_LEVEL_BULLET.match(line): line_number += 1 continue pr_reference = _PR_REFERENCE.search(line) if pr_reference is None: line_number += 1 continue pr_number = pr_reference.group("myst") or pr_reference.group("url") block_end = line_number + 1 while block_end < len(lines): next_line = lines[block_end] if ( _TOP_LEVEL_BULLET.match(next_line) or _ANY_HEADING.match(next_line) or next_line.startswith("**Full Changelog**:") ): break block_end += 1 while block_end > line_number + 1 and not lines[block_end - 1].strip(): block_end -= 1 blocks.append( (line_number, block_end, (section_scope, pr_number)), ) line_number = block_end return blocks def merge_changelog(generated: str, existing: str) -> str: """Merge existing PR note edits into a newly generated changelog. The generated changelog controls which PRs are present, their ordering, and their category headings. For every PR that is present in both versions, its existing list item (including indented descriptions) is retained. Thus, deleted entries are restored and label changes move entries to their newly generated categories without discarding copy edits. """ generated_lines = generated.splitlines(keepends=True) existing_lines = existing.splitlines(keepends=True) existing_blocks = { key: "".join(existing_lines[start:end]) for start, end, key in _find_pull_request_blocks(existing_lines) } merged = [] previous_end = 0 for start, end, key in _find_pull_request_blocks(generated_lines): merged.extend(generated_lines[previous_end:start]) existing_block = existing_blocks.get(key) if existing_block is None: merged.extend(generated_lines[start:end]) else: merged.append(existing_block) previous_end = end merged.extend(generated_lines[previous_end:]) return "".join(merged) def convert_to_myst(body: str) -> str: """ Convert GitHub markdown to MyST format. - PR URLs -> {pr}`NUMBER` - Issue URLs -> {issue}`NUMBER` - @mentions -> {user}`USERNAME` - Strips HTML comments - Ensures proper list spacing """ lines = body.split("\n") result = [] prev_bullet = False for line in lines: # Skip HTML comments if line.strip().startswith("<!--") and line.strip().endswith("-->"): continue # Convert URLs to extlinks line = re.sub( r"https://github\.com/ManimCommunity/manim/pull/(\d+)", r"{pr}`\1`", line, ) line = re.sub( r"https://github\.com/ManimCommunity/manim/issues/(\d+)", r"{issue}`\1`", line, ) line = re.sub(r"@([a-zA-Z0-9_-]+)", r"{user}`\1`", line) if line.startswith("**Full Changelog**:"): _, url = line.split(":", 1) url = url.strip().replace("vmain", "main") line = f"**Full Changelog**: [Compare view]({url})" # Handle list spacing is_bullet = line.strip().startswith("*") and not line.strip().startswith("**") if prev_bullet and not is_bullet and line.strip(): result.append("") result.append(line) prev_bullet = is_bullet return "\n".join(result) def format_changelog( version: str, body: str, date: str | None = None, title: str | None = None, ) -> str: """Create changelog file content with MyST frontmatter.""" title = title or f"v{version}" body = convert_to_myst(body) date_section = f"Date\n: {date}\n" if date else "" return f"""--- short-title: {title} description: Changelog for {title} --- # {title} {date_section} {body} """ def convert_to_github(body: str) -> str: """Convert the MyST roles used by changelogs to GitHub Markdown.""" body = re.sub( r"\{pr\}`(\d+)`", rf"https://github.com/{REPO}/pull/\1", body, ) body = re.sub( r"\{issue\}`(\d+)`", rf"https://github.com/{REPO}/issues/\1", body, ) body = re.sub(r"\{user\}`([a-zA-Z0-9_-]+)`", r"@\1", body) body = re.sub(r"\{class\}`\.?(.*?)`", r"`\1`", body) return body def format_github_release_notes(version: str, content: str) -> str: """Create a GitHub release body from a documentation changelog.""" content = re.sub(r"\A---\r?\n.*?\r?\n---\r?\n", "", content, flags=re.DOTALL) lines = content.splitlines() while lines and not lines[0].strip(): lines.pop(0) if lines and lines[0].startswith("# "): lines.pop(0) while lines and not lines[0].strip(): lines.pop(0) if len(lines) >= 2 and lines[0] == "Date" and lines[1].startswith(": "): del lines[:2] while lines and not lines[0].strip(): lines.pop(0) body = convert_to_github("\n".join(lines)).rstrip() changelog_url = ( f"https://docs.manim.community/en/stable/changelog/{version}-changelog.html" ) return f"See [our rendered changelog]({changelog_url}).\n\n{body}\n" # =============================================================================