Skip to content
Merged
Show file tree
Hide file tree
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
163 changes: 116 additions & 47 deletions .github/scripts/generate_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
(e.g. "mattermost/mattermost,mattermost/enterprise")
MILESTONE - Milestone title (e.g. "v11.7.0")
VERSION - Version label for the changelog entry (e.g. "v11.7.0")
RELEASE_TYPE - (optional) "feature" (default) or "esr" (Extended Support Release).
RELEASE_DATE - (optional) Release day date (e.g. "2026-05-15"). Defaults to today.
GO_VERSION - (optional) Go version used in this release (e.g. "go1.22.5").
If not provided, changelog notes it is unchanged from previous release.
BLOG_POST_URL - (optional) Blog post URL for the Improvements section.
Auto-constructed from VERSION if not provided.
ANTHROPIC_API_KEY - (optional) If set, release notes are polished by Claude
Expand All @@ -17,9 +20,10 @@

import os
import re
import sys
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()]
Expand All @@ -35,11 +39,27 @@
# 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(
total=5,
connect=5,
read=5,
backoff_factor=1,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]),
respect_retry_after_header=True,
)
GITHUB_ADAPTER = HTTPAdapter(max_retries=GITHUB_RETRY)
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.
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:

Expand All @@ -55,7 +75,7 @@
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:
- `#### User Interface` — UI/UX changes and new visual features. Pre-packaged plugin version updates go at the TOP of this subsection, before other items.
- `#### 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
- `#### mmctl` — mmctl command additions or changes (use as a separate subsection when there are enough items to warrant it)
Expand All @@ -64,34 +84,37 @@
- `### API Changes` — API additions, changes, or deprecations
- `### WebSocket Event Changes` — new or changed WebSocket events, if applicable
- `### Audit Log Event Changes` — new or changed audit log events
- `### Go Version` — Go version updates
- `### Open Source Components` — open source component additions or removals, if applicable
- `### 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..."
- 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. **Code formatting:** Use double backticks for all of the following:
3. **Terminology:** Always write "user interface" in full — never use the abbreviation "UI".

4. **Code formatting:** Use double backticks for all of the following:
- Configuration settings (e.g., ``ServiceSettings.EnableDynamicClientRegistration``)
- API endpoints (e.g., ``/api/v4/posts``)
- Command names (e.g., ``mmctl license get``)
- Environment variables (e.g., ``MM_LOG_PATH``)
- Database table and column names (e.g., ``channelmembers.autotranslation``)
- File names (e.g., ``config.json``)
- Feature flags (e.g., ``MM_FEATUREFLAGS_CJKSEARCH``)
- Package names in Open Source Components (e.g., ``x/text``)

4. **Markdown formatting:** Use `- ` bullet points for individual items within sections. Ensure correct and clean Markdown syntax throughout. Do not insert horizontal rules (`---`) or any other separators between sections.
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. **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").
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").

6. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. Aim for clear, concise, and professional language.
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.

7. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation.
8. **Tone:** Maintain a neutral, informative, and professional tone consistent with technical documentation.

8. **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."""
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."""


def get_milestone_number(repo: str, title: str) -> int | None:
Expand All @@ -107,7 +130,7 @@ def get_milestone_number(repo: str, title: str) -> int | None:
"sort": "due_on", # sort by due date
"direction": "desc", # most recently due first, so active milestones are found quickly
}
resp = requests.get(url, headers=HEADERS, params=params, timeout=API_TIMEOUT)
resp = SESSION.get(url, params=params, timeout=API_TIMEOUT)
resp.raise_for_status()
milestones = resp.json()
if not milestones:
Expand Down Expand Up @@ -136,15 +159,24 @@ def get_merged_prs(repo: str, milestone_number: int) -> list:
"per_page": 100,
"page": page,
}
resp = requests.get(url, headers=HEADERS, params=params, timeout=API_TIMEOUT)
resp = SESSION.get(url, params=params, timeout=API_TIMEOUT)
resp.raise_for_status()
items = resp.json()
if not items:
break
for item in items:
# Issues and PRs share the same endpoint; filter to merged PRs only
if "pull_request" in item and item["pull_request"].get("merged_at"):
if "pull_request" not in item:
continue # plain issue, not a PR
if item["pull_request"].get("merged_at"):
prs.append(item)
else:
# merged_at can be null for very recently merged PRs due to an API
# propagation delay. Verify directly against the Pulls API.
pr_url = f"https://api.github.com/repos/{repo}/pulls/{item['number']}"
pr_resp = SESSION.get(pr_url, timeout=API_TIMEOUT)
pr_resp.raise_for_status()
if pr_resp.json().get("merged"):
prs.append(item)
page += 1
return prs

Expand All @@ -163,34 +195,39 @@ def extract_release_notes(body: str) -> list[str] | None:
# Normalize line endings (GitHub API may return \r\n on some PR bodies)
body = body.replace("\r\n", "\n").replace("\r", "\n")

# Capture everything after '#### Release Note(s)' up to the next #### header or EOF
notes = []

# Primary path: look for a '#### Release Note(s)' section heading
section_match = re.search(
r"####\s+Release\s+Notes?\s*\n(.*?)(?=\n####|\Z)",
body,
re.DOTALL | re.IGNORECASE,
)
if not section_match:
return None

section = section_match.group(1)

# Strip HTML comments (the instructional block in the template)
section = re.sub(r"<!--.*?-->", "", section, flags=re.DOTALL)

# Extract fenced code blocks (supports both ``` and ```release-note)
code_blocks = re.findall(r"```(?:release-note)?\s*\n(.*?)\n?```", section, re.DOTALL)

notes = []
for block in code_blocks:
content = block.strip()
if content and content.upper() != "NONE":
notes.append(content)

# Fallback to plain text only when there are no code blocks at all in the section
if not notes and "```" not in section:
plain = section.strip()
if plain and plain.upper() != "NONE":
notes.append(plain)
if section_match:
section = section_match.group(1)
# Strip HTML comments (the instructional block in the template)
section = re.sub(r"<!--.*?-->", "", section, flags=re.DOTALL)
# Extract fenced code blocks (supports both ``` and ```release-note)
code_blocks = re.findall(r"```(?:release-note)?\s*\n(.*?)\n?```", section, re.DOTALL)
for block in code_blocks:
content = block.strip()
if content and content.upper() != "NONE":
notes.append(content)
# Fallback to plain text only when there are no code blocks at all in the section
if not notes and "```" not in section:
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:
body_no_comments = re.sub(r"<!--.*?-->", "", body, flags=re.DOTALL)
raw_blocks = re.findall(r"```release-note\s*\n(.*?)\n?```", body_no_comments, re.DOTALL)
for block in raw_blocks:
content = block.strip()
if content and content.upper() != "NONE":
notes.append(content)

return notes if notes else None

Expand Down Expand Up @@ -283,17 +320,29 @@ def main():
# 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()

if release_type == "esr":
anchor = f"(release-{version_short}-extended-support-release)="
heading = (
f"## Release {version_short} - "
f"[Extended Support Release]"
f"(https://docs.mattermost.com/product-overview/release-policy.html#release-types)"
)
else:
anchor = f"(release-{version_short})="
heading = f"## Release {version_short}"

# e.g. (release-v11.7-extended-support-release)=
anchor = f"(release-{version_short}-extended-support-release)="
heading = (
f"## Release {version_short} - "
f"[Extended Support 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
if go_version:
go_section = f"### Go Version\n - Updated to ``{go_version}``."
else:
go_section = "### Go Version\n - Go version is the same as in the previous release."

if all_notes:
polished = polish_with_ai(all_notes)
blog_url = os.environ.get("BLOG_POST_URL", "").strip()
Expand All @@ -303,9 +352,29 @@ def main():
blog_url = f"https://mattermost.com/blog/mattermost-v{version_slug}-is-now-available/"
print(f"ℹ️ No blog post URL provided — using auto-constructed URL: {blog_url}")
polished = polished.replace("BLOG_POST_URL", blog_url)
# Inject the Go Version section: replace the placeholder heading the AI outputs,
# or insert it before ### Open Source Components / ### Security to preserve
# section order, or append at the end if neither anchor exists.
if re.search(r"(?m)^### Go Version\b", polished):
polished = re.sub(
r"(?ms)^### Go Version\b.*?(?=^### \S|\Z)",
go_section + "\n\n",
polished,
count=1,
)
else:
anchor = re.search(
r"(?m)^### (?:Open Source Components|Security)\b", polished
)
if anchor:
idx = anchor.start()
polished = polished[:idx].rstrip() + "\n\n" + go_section + "\n\n" + polished[idx:]
else:
polished = polished.rstrip() + "\n\n" + go_section + "\n"
entry += polished + "\n"
else:
entry += "_No release notes for this version._\n"
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)
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/generate-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@ on:
required: true
default: 'mattermost/mattermost,mattermost/enterprise'
type: string
release_type:
description: 'Release type: "feature" (default) or "esr" (Extended Support Release)'
required: false
default: 'feature'
type: choice
options:
- feature
- esr
release_date:
description: 'Release day date (e.g. 2026-05-15) — defaults to today if omitted'
required: false
type: string
go_version:
description: 'Go version used in this release (e.g. go1.22.5) — if omitted, noted as unchanged from previous release'
required: false
type: string
blog_post_url:
description: 'Blog post URL for the Improvements section — if omitted, auto-constructed from version (e.g. https://mattermost.com/blog/mattermost-v11-6-0-is-now-available/)'
required: false
Expand Down Expand Up @@ -79,7 +91,9 @@ jobs:
MILESTONE: ${{ inputs.milestone }}
VERSION: ${{ inputs.version }}
REPOS: ${{ inputs.server_repos }}
RELEASE_TYPE: ${{ inputs.release_type }}
Comment thread
amyblais marked this conversation as resolved.
RELEASE_DATE: ${{ inputs.release_date }}
GO_VERSION: ${{ inputs.go_version }}
Comment thread
amyblais marked this conversation as resolved.
BLOG_POST_URL: ${{ inputs.blog_post_url }}
CHANGELOG_PATH: source/product-overview/mattermost-v11-changelog.md
run: python .github/scripts/generate_changelog.py
Expand Down
Loading