From 52000255c6b92885721db3029c0912503b8b5d89 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:10:02 +0300 Subject: [PATCH 1/3] Update generate_changelog.py with improved system prompt and Go version handling --- .github/scripts/generate_changelog.py | 194 ++++++++++++++++---------- 1 file changed, 117 insertions(+), 77 deletions(-) diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py index 13a29dbaa9c..d0c10d5f036 100644 --- a/.github/scripts/generate_changelog.py +++ b/.github/scripts/generate_changelog.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Generate a changelog from merged PR release notes for a given milestone. - + Expects these environment variables: GITHUB_TOKEN - GitHub personal access token or Actions token REPOS - Comma-separated list of repositories in "owner/repo" format @@ -17,28 +17,28 @@ ANTHROPIC_API_KEY - (optional) If set, release notes are polished by Claude before being written to the changelog. """ - + import os import re import requests from datetime import date from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry - + GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] REPOS = [r.strip() for r in os.environ["REPOS"].split(",") if r.strip()] MILESTONE_TITLE = os.environ["MILESTONE"] VERSION = os.environ["VERSION"] - + HEADERS = { "Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json", } - + # Shared timeout for all GitHub API requests (seconds). # Prevents the workflow from hanging indefinitely if the API stalls. API_TIMEOUT = 30 - + # Standard requests retry strategy using urllib3 — handles transient failures # (rate limits, server errors) with exponential back-off and respects Retry-After. GITHUB_RETRY = Retry( @@ -54,27 +54,26 @@ SESSION = requests.Session() SESSION.headers.update(HEADERS) SESSION.mount("https://", GITHUB_ADAPTER) - + SYSTEM_PROMPT = """You are an expert technical writer and copyeditor for Mattermost software release notes. Your task is to transform raw, unstructured release notes from pull requests into a clean, categorized, and grammatically correct changelog entry that matches Mattermost's established changelog format exactly. - + Here are your instructions: - -1. **Section structure:** Use `###` for top-level sections and `####` for subsections. Only include sections that have relevant content — do not output empty sections. Do NOT add horizontal rules or line separators between sections. Do NOT add a blank line between a section/subsection heading and its first bullet point. - + +1. **Section structure:** Use `###` for top-level sections and `####` for subsections. Only include sections that have relevant content — do not output empty sections. Do NOT add horizontal rules or line separators between sections. Do NOT add a blank line between any heading and the content that immediately follows it (whether a paragraph, bullet list, or any other element). + Top-level sections and their subsections, in this order: - + - `### Upgrade Impact` — for changes that affect upgrading, with subsections as applicable: - `#### Database Schema Changes` — schema migrations such as new tables, new columns, changed columns, or new indexes. Example items: "Added a new ``Watermarks`` table.", "Added a new column ``DeleteAt`` to the ``ChannelMembers`` table." - - `#### config.json` — new or changed configuration settings. Use this exact block format for each plan grouping: - + - `#### config.json` — new or changed configuration settings. Use this exact block format for each plan grouping (no blank line between the `#### config.json` heading and the description paragraph, and no blank line between the description paragraph and the first bullet): + New setting options were added to ``config.json``. Below is a list of the additions and their default values on install. The settings can be modified in ``config.json``, or the System Console when available. - - **Changes to Enterprise Advanced plan:** - Under ``ExperimentalSettings`` in ``config.json``, added ``EnableWatermark`` configuration setting to add watermarking toggle in the server. - + Adapt the plan name (e.g. "Changes to All plans:", "Changes to Enterprise plan:", "Changes to Enterprise Advanced plan:") and list each setting change as a bullet under the appropriate plan heading. - `#### Compatibility` — minimum version requirement changes for browsers, OS, or clients. Example: "Updated minimum Edge and Chrome versions to 146+." - - `### Improvements` — for new features and enhancements only. Do NOT place items beginning with "Fixed..." here — those belong in Bug Fixes. Begin this section with the line `See [this blog post](BLOG_POST_URL) on the highlights in our latest release.` (use the exact placeholder `BLOG_POST_URL` — it will be replaced automatically). Then add subsections as applicable: + - `### Improvements` — for new features and enhancements only. Do NOT place items beginning with "Fixed..." here — those belong in Bug Fixes. Begin this section with the line `See [this blog post](BLOG_POST_URL) on the highlights in our latest release.` (use the exact placeholder `BLOG_POST_URL` — it will be replaced automatically), followed by a blank line before the first `####` subsection heading. Then add subsections as applicable: - `#### User Interface` — user interface and UX changes and new visual features. Pre-packaged plugin version updates go at the TOP of this subsection, before other items. Always write "user interface" in full — never abbreviate as "UI". - `#### Plugins/Integrations` — plugin and integration improvements (use as a separate subsection when there are enough items to warrant it) - `#### Administration` — System Console features, logging, support packet changes @@ -87,15 +86,15 @@ - `### Go Version` — Always include this section. The Go version content will be injected automatically — output only this heading with no content beneath it. - `### Open Source Components` — open source component additions or removals. Format each item as: "Added ```` to ." or "Removed ```` from ." Example: "Added ``x/text`` to https://github.com/mattermost/mattermost/." Only include if there are relevant notes. - `### Security` — security-related fixes not already covered under Bug Fixes - + 2. **Sentence patterns:** Follow these conventions consistently: - New features and additions: "Added [feature]..." or "Added support for [feature]..." - Bug fixes: "Fixed an issue where..." or "Fixed an issue with..." — never use "Fixed a bug"; always use "Fixed an issue". - Improvements to existing things: "Improved [thing]..." or "Updated [thing]..." - Removals: "Removed [thing]..." - -3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI". - + +3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI". Always spell out messaging abbreviations: "DM" → "Direct Message", "GM" → "Group Message", "DM/GM" → "Direct/Group Message". + 4. **Code formatting:** Use double backticks for all of the following: - Configuration settings (e.g., ``ServiceSettings.EnableDynamicClientRegistration``) - API endpoints (e.g., ``/api/v4/posts``) @@ -105,18 +104,20 @@ - File names (e.g., ``config.json``) - Feature flags (e.g., ``MM_FEATUREFLAGS_CJKSEARCH``) - Package names in Open Source Components (e.g., ``x/text``) - -5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between a heading and its first bullet. - + +5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between any heading and the content that immediately follows it. + 6. **License requirements:** When a feature requires a specific Mattermost license, note it inline at the end of the bullet point (e.g., "Requires Enterprise Advanced license" or "Requires Enterprise license"). - -7. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Replace any instance of "Fixed a bug" with "Fixed an issue". Aim for clear, concise, and professional language. - -8. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation. - -9. **Focus:** Output only the section content (headings and bullet points). Do not include the release version header line or any introductory or concluding remarks from yourself.""" - - + +7. **One section per release note:** Each raw release note must appear in exactly one section — whichever section best fits its primary purpose. Do not split a single release note across multiple sections or subsections, even if it touches more than one area (e.g. an admin feature that also introduces an API endpoint belongs entirely in Administration, not partially in Administration and partially in API Changes). + +8. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Replace any instance of "Fixed a bug" with "Fixed an issue". Aim for clear, concise, and professional language. + +9. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation. + +10. **Focus:** Output only the section content (headings and bullet points). Do not include the release version header line or any introductory or concluding remarks from yourself.""" + + def get_milestone_number(repo: str, title: str) -> int | None: """Look up the numeric ID for a milestone by its title in the given repo.""" url = f"https://api.github.com/repos/{repo}/milestones" @@ -145,8 +146,8 @@ def get_milestone_number(repo: str, title: str) -> int | None: if recent_titles: print(f" Most recently due milestones: {', '.join(recent_titles)}") return None - - + + def get_merged_prs(repo: str, milestone_number: int) -> list: """Fetch all merged PRs belonging to the given milestone in the given repo.""" prs = [] @@ -179,24 +180,24 @@ def get_merged_prs(repo: str, milestone_number: int) -> list: prs.append(item) page += 1 return prs - - + + def extract_release_notes(body: str) -> list[str] | None: """ Extract release note text from a PR body. - + Looks for a '#### Release Note' section, then pulls the content of any fenced code blocks within it (plain ``` or ```release-note). Returns None if the section is missing or all entries are NONE. """ if not body: return None - + # Normalize line endings (GitHub API may return \r\n on some PR bodies) body = body.replace("\r\n", "\n").replace("\r", "\n") - + notes = [] - + # Primary path: look for a '#### Release Note(s)' section heading section_match = re.search( r"####\s+Release\s+Notes?\s*\n(.*?)(?=\n####|\Z)", @@ -218,7 +219,7 @@ def extract_release_notes(body: str) -> list[str] | None: plain = section.strip() if plain and plain.upper() != "NONE": notes.append(plain) - + # Secondary path: scan the entire body for ```release-note blocks. # Catches PRs that use the block format without a #### Release Note heading. if not notes: @@ -228,10 +229,10 @@ def extract_release_notes(body: str) -> list[str] | None: content = block.strip() if content and content.upper() != "NONE": notes.append(content) - + return notes if notes else None - - + + def polish_with_ai(raw_notes: list[str]) -> str: """ Send raw release notes to Claude for categorization, formatting, and proofreading. @@ -241,40 +242,71 @@ def polish_with_ai(raw_notes: list[str]) -> str: if not api_key: print("ℹ️ ANTHROPIC_API_KEY not set — skipping AI polish, using raw notes") return "\n".join(f"- {note}" for note in raw_notes) - + try: import anthropic except ImportError: print("⚠️ anthropic package not installed — skipping AI polish") return "\n".join(f"- {note}" for note in raw_notes) - + print("✨ Sending notes to Claude for categorization and proofreading...") client = anthropic.Anthropic(api_key=api_key) - + raw_text = "\n\n---\n\n".join(raw_notes) user_message = f"Here are the raw release notes to process:\n\n{raw_text}" - + response = client.messages.create( model="claude-sonnet-4-6", max_tokens=4096, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": user_message}], ) - + return response.content[0].text.strip() - - + + +def normalize_go_version(go_version: str) -> str: + """Normalize a Go version string to v-prefixed form. + + Accepts both "go1.22.5" (Go toolchain convention) and "v1.22.5" + (Mattermost changelog convention) and always returns the latter. + """ + v = go_version.strip() + if v.startswith("go"): + v = v[2:] # strip the "go" prefix + if not v.startswith("v"): + v = "v" + v + return v + + +def extract_previous_go_version(changelog_path: str) -> str | None: + """Scan the changelog file for the most recently documented Go version. + + Used when GO_VERSION is not supplied (i.e. unchanged from the previous + release) so the generated entry still shows a concrete version number + rather than a generic "unchanged" message. + """ + if not os.path.exists(changelog_path): + return None + with open(changelog_path, "r") as f: + content = f.read() + match = re.search(r"is built with Go\s+``(v?[\d.]+)``", content) + if match: + return normalize_go_version(match.group(1)) + return None + + def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") -> None: """Insert a new version entry into the changelog after the static file header block.""" existing = "" if os.path.exists(changelog_path): with open(changelog_path, "r") as f: existing = f.read() - + # The v11 changelog file has a static header block ending with the platform scope note. # New entries are inserted immediately after this block so the header is preserved. HEADER_END_MARKER = "may not represent all affected configurations.\n```" - + if HEADER_END_MARKER in existing: idx = existing.index(HEADER_END_MARKER) + len(HEADER_END_MARKER) new_content = existing[:idx] + "\n\n\n" + entry + existing[idx:] @@ -284,28 +316,28 @@ def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") -> new_content = parts[0] + "\n\n" + entry + ("\n" + parts[2] if len(parts) > 2 else "") else: new_content = entry + "\n" + existing - + with open(changelog_path, "w") as f: f.write(new_content) - - + + def main(): print(f"🔍 Milestone: {MILESTONE_TITLE} | Repos: {', '.join(REPOS)}\n") - + all_notes = [] total_prs = 0 no_notes_prs = [] - + for repo in REPOS: print(f"── {repo}") milestone_number = get_milestone_number(repo, MILESTONE_TITLE) if milestone_number is None: continue - + prs = get_merged_prs(repo, milestone_number) print(f" 📋 Found {len(prs)} merged PR(s)") total_prs += len(prs) - + for pr in sorted(prs, key=lambda p: p["number"]): notes = extract_release_notes(pr.get("body") or "") if notes: @@ -316,14 +348,15 @@ def main(): no_notes_prs.append((repo, pr)) print(f" ⏭️ #{pr['number']}: {pr['title']} (NONE / no notes)") print() - + # Derive short version for heading/anchor: "v11.7.0" → "v11.7", "11.7.0" → "v11.7" version_short = "v" + re.sub(r"\.0$", "", VERSION.lstrip("v")) - + release_type = os.environ.get("RELEASE_TYPE", "feature").strip().lower() release_date = os.environ.get("RELEASE_DATE", "").strip() or date.today().strftime("%Y-%m-%d") go_version = os.environ.get("GO_VERSION", "").strip() - + changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md") + if release_type == "esr": anchor = f"(release-{version_short}-extended-support-release)=" heading = ( @@ -332,17 +365,25 @@ def main(): f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)" ) else: - anchor = f"(release-{version_short})=" - heading = f"## Release {version_short}" - + anchor = f"(release-{version_short}-feature-release)=" + heading = ( + f"## Release {version_short} - " + f"[Feature Release]" + f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)." + ) + entry = f"{anchor}\n{heading}\n\n**Release day: {release_date}**\n\n" - - # Build the Go Version section content + + # Build the Go Version section content. + # Mattermost uses a bare sentence (not a bullet point) in this section. + # When GO_VERSION is not provided (unchanged from previous release), we look up + # the previous version from the changelog so the line still shows a concrete number. if go_version: - go_section = f"### Go Version\n - Updated to ``{go_version}``." + formatted_go = normalize_go_version(go_version) else: - go_section = "### Go Version\n - Go version is the same as in the previous release." - + formatted_go = extract_previous_go_version(changelog_path) or "(see previous release)" + go_section = f"### Go Version\n{version_short} is built with Go ``{formatted_go}``." + if all_notes: polished = polish_with_ai(all_notes) blog_url = os.environ.get("BLOG_POST_URL", "").strip() @@ -375,18 +416,17 @@ def main(): else: entry += go_section + "\n" entry += "\n_No other release notes for this version._\n" - - changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md") + insert_changelog_entry(entry, changelog_path) - + prs_with_notes = total_prs - len(no_notes_prs) print(f"✅ Changelog updated with notes from {prs_with_notes} PR(s) across {len(REPOS)} repo(s)") - + if no_notes_prs: print(f"\n⚠️ {len(no_notes_prs)} PR(s) had no release notes (marked NONE or section missing):") for repo, pr in no_notes_prs: print(f" [{repo}] #{pr['number']}: {pr['title']}") - - + + if __name__ == "__main__": main() From 73423d40be739560cb65aa7c7921b81f965ecf97 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:16:59 +0300 Subject: [PATCH 2/3] Update generate_changelog.py --- .github/scripts/generate_changelog.py | 149 +++++++++++++------------- 1 file changed, 75 insertions(+), 74 deletions(-) diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py index d0c10d5f036..baebbba310b 100644 --- a/.github/scripts/generate_changelog.py +++ b/.github/scripts/generate_changelog.py @@ -1,7 +1,8 @@ + #!/usr/bin/env python3 """ Generate a changelog from merged PR release notes for a given milestone. - + Expects these environment variables: GITHUB_TOKEN - GitHub personal access token or Actions token REPOS - Comma-separated list of repositories in "owner/repo" format @@ -17,28 +18,28 @@ ANTHROPIC_API_KEY - (optional) If set, release notes are polished by Claude before being written to the changelog. """ - + import os import re import requests from datetime import date from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry - + GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] REPOS = [r.strip() for r in os.environ["REPOS"].split(",") if r.strip()] MILESTONE_TITLE = os.environ["MILESTONE"] VERSION = os.environ["VERSION"] - + HEADERS = { "Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json", } - + # Shared timeout for all GitHub API requests (seconds). # Prevents the workflow from hanging indefinitely if the API stalls. API_TIMEOUT = 30 - + # Standard requests retry strategy using urllib3 — handles transient failures # (rate limits, server errors) with exponential back-off and respects Retry-After. GITHUB_RETRY = Retry( @@ -54,23 +55,23 @@ SESSION = requests.Session() SESSION.headers.update(HEADERS) SESSION.mount("https://", GITHUB_ADAPTER) - + SYSTEM_PROMPT = """You are an expert technical writer and copyeditor for Mattermost software release notes. Your task is to transform raw, unstructured release notes from pull requests into a clean, categorized, and grammatically correct changelog entry that matches Mattermost's established changelog format exactly. - + Here are your instructions: - -1. **Section structure:** Use `###` for top-level sections and `####` for subsections. Only include sections that have relevant content — do not output empty sections. Do NOT add horizontal rules or line separators between sections. Do NOT add a blank line between any heading and the content that immediately follows it (whether a paragraph, bullet list, or any other element). - + +1. **Section structure:** Use `###` for top-level sections and `####` for subsections. Only include sections that have relevant content — do not output empty sections. Do NOT add horizontal rules or line separators between sections. Do NOT add a blank line between a section/subsection heading and its first bullet point. + Top-level sections and their subsections, in this order: - + - `### Upgrade Impact` — for changes that affect upgrading, with subsections as applicable: - `#### Database Schema Changes` — schema migrations such as new tables, new columns, changed columns, or new indexes. Example items: "Added a new ``Watermarks`` table.", "Added a new column ``DeleteAt`` to the ``ChannelMembers`` table." - `#### config.json` — new or changed configuration settings. Use this exact block format for each plan grouping (no blank line between the `#### config.json` heading and the description paragraph, and no blank line between the description paragraph and the first bullet): - + New setting options were added to ``config.json``. Below is a list of the additions and their default values on install. The settings can be modified in ``config.json``, or the System Console when available. - **Changes to Enterprise Advanced plan:** - Under ``ExperimentalSettings`` in ``config.json``, added ``EnableWatermark`` configuration setting to add watermarking toggle in the server. - + Adapt the plan name (e.g. "Changes to All plans:", "Changes to Enterprise plan:", "Changes to Enterprise Advanced plan:") and list each setting change as a bullet under the appropriate plan heading. - `#### Compatibility` — minimum version requirement changes for browsers, OS, or clients. Example: "Updated minimum Edge and Chrome versions to 146+." - `### Improvements` — for new features and enhancements only. Do NOT place items beginning with "Fixed..." here — those belong in Bug Fixes. Begin this section with the line `See [this blog post](BLOG_POST_URL) on the highlights in our latest release.` (use the exact placeholder `BLOG_POST_URL` — it will be replaced automatically), followed by a blank line before the first `####` subsection heading. Then add subsections as applicable: @@ -86,15 +87,15 @@ - `### Go Version` — Always include this section. The Go version content will be injected automatically — output only this heading with no content beneath it. - `### Open Source Components` — open source component additions or removals. Format each item as: "Added ```` to ." or "Removed ```` from ." Example: "Added ``x/text`` to https://github.com/mattermost/mattermost/." Only include if there are relevant notes. - `### Security` — security-related fixes not already covered under Bug Fixes - + 2. **Sentence patterns:** Follow these conventions consistently: - New features and additions: "Added [feature]..." or "Added support for [feature]..." - Bug fixes: "Fixed an issue where..." or "Fixed an issue with..." — never use "Fixed a bug"; always use "Fixed an issue". - Improvements to existing things: "Improved [thing]..." or "Updated [thing]..." - Removals: "Removed [thing]..." - + 3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI". Always spell out messaging abbreviations: "DM" → "Direct Message", "GM" → "Group Message", "DM/GM" → "Direct/Group Message". - + 4. **Code formatting:** Use double backticks for all of the following: - Configuration settings (e.g., ``ServiceSettings.EnableDynamicClientRegistration``) - API endpoints (e.g., ``/api/v4/posts``) @@ -104,20 +105,20 @@ - File names (e.g., ``config.json``) - Feature flags (e.g., ``MM_FEATUREFLAGS_CJKSEARCH``) - Package names in Open Source Components (e.g., ``x/text``) - -5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between any heading and the content that immediately follows it. - + +5. **Markdown formatting:** Indent each bullet point with two spaces (e.g., ` - item`). Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections. Do not add a blank line between a section/subsection heading and its first bullet point. + 6. **License requirements:** When a feature requires a specific Mattermost license, note it inline at the end of the bullet point (e.g., "Requires Enterprise Advanced license" or "Requires Enterprise license"). - + 7. **One section per release note:** Each raw release note must appear in exactly one section — whichever section best fits its primary purpose. Do not split a single release note across multiple sections or subsections, even if it touches more than one area (e.g. an admin feature that also introduces an API endpoint belongs entirely in Administration, not partially in Administration and partially in API Changes). - + 8. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Replace any instance of "Fixed a bug" with "Fixed an issue". Aim for clear, concise, and professional language. - + 9. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation. - + 10. **Focus:** Output only the section content (headings and bullet points). Do not include the release version header line or any introductory or concluding remarks from yourself.""" - - + + def get_milestone_number(repo: str, title: str) -> int | None: """Look up the numeric ID for a milestone by its title in the given repo.""" url = f"https://api.github.com/repos/{repo}/milestones" @@ -146,8 +147,8 @@ def get_milestone_number(repo: str, title: str) -> int | None: if recent_titles: print(f" Most recently due milestones: {', '.join(recent_titles)}") return None - - + + def get_merged_prs(repo: str, milestone_number: int) -> list: """Fetch all merged PRs belonging to the given milestone in the given repo.""" prs = [] @@ -180,24 +181,24 @@ def get_merged_prs(repo: str, milestone_number: int) -> list: prs.append(item) page += 1 return prs - - + + def extract_release_notes(body: str) -> list[str] | None: """ Extract release note text from a PR body. - + Looks for a '#### Release Note' section, then pulls the content of any fenced code blocks within it (plain ``` or ```release-note). Returns None if the section is missing or all entries are NONE. """ if not body: return None - + # Normalize line endings (GitHub API may return \r\n on some PR bodies) body = body.replace("\r\n", "\n").replace("\r", "\n") - + notes = [] - + # Primary path: look for a '#### Release Note(s)' section heading section_match = re.search( r"####\s+Release\s+Notes?\s*\n(.*?)(?=\n####|\Z)", @@ -219,7 +220,7 @@ def extract_release_notes(body: str) -> list[str] | None: plain = section.strip() if plain and plain.upper() != "NONE": notes.append(plain) - + # Secondary path: scan the entire body for ```release-note blocks. # Catches PRs that use the block format without a #### Release Note heading. if not notes: @@ -229,10 +230,10 @@ def extract_release_notes(body: str) -> list[str] | None: content = block.strip() if content and content.upper() != "NONE": notes.append(content) - + return notes if notes else None - - + + def polish_with_ai(raw_notes: list[str]) -> str: """ Send raw release notes to Claude for categorization, formatting, and proofreading. @@ -242,32 +243,32 @@ def polish_with_ai(raw_notes: list[str]) -> str: if not api_key: print("ℹ️ ANTHROPIC_API_KEY not set — skipping AI polish, using raw notes") return "\n".join(f"- {note}" for note in raw_notes) - + try: import anthropic except ImportError: print("⚠️ anthropic package not installed — skipping AI polish") return "\n".join(f"- {note}" for note in raw_notes) - + print("✨ Sending notes to Claude for categorization and proofreading...") client = anthropic.Anthropic(api_key=api_key) - + raw_text = "\n\n---\n\n".join(raw_notes) user_message = f"Here are the raw release notes to process:\n\n{raw_text}" - + response = client.messages.create( model="claude-sonnet-4-6", max_tokens=4096, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": user_message}], ) - + return response.content[0].text.strip() - - + + def normalize_go_version(go_version: str) -> str: """Normalize a Go version string to v-prefixed form. - + Accepts both "go1.22.5" (Go toolchain convention) and "v1.22.5" (Mattermost changelog convention) and always returns the latter. """ @@ -277,36 +278,36 @@ def normalize_go_version(go_version: str) -> str: if not v.startswith("v"): v = "v" + v return v - - + + def extract_previous_go_version(changelog_path: str) -> str | None: """Scan the changelog file for the most recently documented Go version. - + Used when GO_VERSION is not supplied (i.e. unchanged from the previous release) so the generated entry still shows a concrete version number rather than a generic "unchanged" message. """ if not os.path.exists(changelog_path): return None - with open(changelog_path, "r") as f: + with open(changelog_path, "r", encoding="utf-8") as f: content = f.read() match = re.search(r"is built with Go\s+``(v?[\d.]+)``", content) if match: return normalize_go_version(match.group(1)) return None - - + + def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") -> None: """Insert a new version entry into the changelog after the static file header block.""" existing = "" if os.path.exists(changelog_path): with open(changelog_path, "r") as f: existing = f.read() - + # The v11 changelog file has a static header block ending with the platform scope note. # New entries are inserted immediately after this block so the header is preserved. HEADER_END_MARKER = "may not represent all affected configurations.\n```" - + if HEADER_END_MARKER in existing: idx = existing.index(HEADER_END_MARKER) + len(HEADER_END_MARKER) new_content = existing[:idx] + "\n\n\n" + entry + existing[idx:] @@ -316,28 +317,28 @@ def insert_changelog_entry(entry: str, changelog_path: str = "CHANGELOG.md") -> new_content = parts[0] + "\n\n" + entry + ("\n" + parts[2] if len(parts) > 2 else "") else: new_content = entry + "\n" + existing - + with open(changelog_path, "w") as f: f.write(new_content) - - + + def main(): print(f"🔍 Milestone: {MILESTONE_TITLE} | Repos: {', '.join(REPOS)}\n") - + all_notes = [] total_prs = 0 no_notes_prs = [] - + for repo in REPOS: print(f"── {repo}") milestone_number = get_milestone_number(repo, MILESTONE_TITLE) if milestone_number is None: continue - + prs = get_merged_prs(repo, milestone_number) print(f" 📋 Found {len(prs)} merged PR(s)") total_prs += len(prs) - + for pr in sorted(prs, key=lambda p: p["number"]): notes = extract_release_notes(pr.get("body") or "") if notes: @@ -348,15 +349,15 @@ def main(): no_notes_prs.append((repo, pr)) print(f" ⏭️ #{pr['number']}: {pr['title']} (NONE / no notes)") print() - + # Derive short version for heading/anchor: "v11.7.0" → "v11.7", "11.7.0" → "v11.7" version_short = "v" + re.sub(r"\.0$", "", VERSION.lstrip("v")) - + release_type = os.environ.get("RELEASE_TYPE", "feature").strip().lower() release_date = os.environ.get("RELEASE_DATE", "").strip() or date.today().strftime("%Y-%m-%d") go_version = os.environ.get("GO_VERSION", "").strip() changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md") - + if release_type == "esr": anchor = f"(release-{version_short}-extended-support-release)=" heading = ( @@ -369,11 +370,11 @@ def main(): heading = ( f"## Release {version_short} - " f"[Feature Release]" - f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)." + f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)" ) - + entry = f"{anchor}\n{heading}\n\n**Release day: {release_date}**\n\n" - + # Build the Go Version section content. # Mattermost uses a bare sentence (not a bullet point) in this section. # When GO_VERSION is not provided (unchanged from previous release), we look up @@ -382,8 +383,8 @@ def main(): formatted_go = normalize_go_version(go_version) else: formatted_go = extract_previous_go_version(changelog_path) or "(see previous release)" - go_section = f"### Go Version\n{version_short} is built with Go ``{formatted_go}``." - + go_section = f"### Go Version\n - {version_short} is built with Go ``{formatted_go}``." + if all_notes: polished = polish_with_ai(all_notes) blog_url = os.environ.get("BLOG_POST_URL", "").strip() @@ -416,17 +417,17 @@ def main(): else: entry += go_section + "\n" entry += "\n_No other release notes for this version._\n" - + insert_changelog_entry(entry, changelog_path) - + prs_with_notes = total_prs - len(no_notes_prs) print(f"✅ Changelog updated with notes from {prs_with_notes} PR(s) across {len(REPOS)} repo(s)") - + if no_notes_prs: print(f"\n⚠️ {len(no_notes_prs)} PR(s) had no release notes (marked NONE or section missing):") for repo, pr in no_notes_prs: print(f" [{repo}] #{pr['number']}: {pr['title']}") - - + + if __name__ == "__main__": main() From c0877e8cbeaba363f7e73cec1d88e751069910f1 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:17:55 +0300 Subject: [PATCH 3/3] Update generate_changelog.py --- .github/scripts/generate_changelog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py index baebbba310b..ce5c05cbb62 100644 --- a/.github/scripts/generate_changelog.py +++ b/.github/scripts/generate_changelog.py @@ -1,4 +1,3 @@ - #!/usr/bin/env python3 """ Generate a changelog from merged PR release notes for a given milestone.