Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 119 additions & 76 deletions .github/scripts/generate_changelog.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -54,27 +54,25 @@
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.

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
Expand All @@ -87,15 +85,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 ``<package>`` to <repo_url>." or "Removed ``<package>`` from <repo_url>." 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``)
Expand All @@ -105,18 +103,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 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. **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"
Expand Down Expand Up @@ -145,8 +145,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 = []
Expand Down Expand Up @@ -179,24 +179,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)",
Expand All @@ -218,7 +218,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:
Expand All @@ -228,10 +228,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.
Expand All @@ -241,40 +241,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", 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:]
Expand All @@ -284,28 +315,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:
Expand All @@ -316,14 +347,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 = (
Expand All @@ -332,17 +364,29 @@ 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 single 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)
go_section = f"### Go Version\n - {version_short} is built with Go ``{formatted_go}``."
else:
go_section = "### Go Version\n - Go version is the same as in the previous release."

prev_go = extract_previous_go_version(changelog_path)
if prev_go:
go_section = f"### Go Version\n - {version_short} is built with Go ``{prev_go}``."
else:
go_section = f"### Go Version\n - {version_short} uses the same Go version as the previous release."

if all_notes:
polished = polish_with_ai(all_notes)
blog_url = os.environ.get("BLOG_POST_URL", "").strip()
Expand Down Expand Up @@ -375,18 +419,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()
Loading