Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9bb74c4
Create generate-changelog.yml
amyblais Mar 18, 2026
c561a08
Create generate_changelog.py
amyblais Mar 18, 2026
a79aeb5
Update generate-changelog.yml
amyblais Mar 18, 2026
132cd17
Update generate_changelog.py
amyblais Mar 18, 2026
37ea628
Create generate_changelog.py
amyblais Mar 18, 2026
e9d880f
Delete scripts/generate_changelog.py
amyblais Mar 18, 2026
6bb6cfc
Update generate-changelog.yml
amyblais Mar 18, 2026
497e0c6
Update generate_changelog.py
amyblais Mar 18, 2026
a3cba5d
Update generate_changelog.py
amyblais Mar 18, 2026
a18a89e
Update generate_changelog.py
amyblais Mar 18, 2026
5a358ff
Update .github/scripts/generate_changelog.py
amyblais Mar 18, 2026
57a178f
Update .github/scripts/generate_changelog.py
amyblais Mar 18, 2026
6e2cc91
Update generate_changelog.py
amyblais Mar 18, 2026
6081acc
fix: prevent script injection in generate-changelog workflow
esarafianou Mar 20, 2026
ee807c7
fix: pin actions and pip dependencies to exact versions
esarafianou Mar 20, 2026
a85861b
fix: remove duplicate get_milestone_number definition
esarafianou Mar 20, 2026
52b9adf
chore: remove unused sys import
esarafianou Mar 20, 2026
9967e0c
fix: add timeout to get_merged_prs HTTP request
esarafianou Mar 20, 2026
3410a36
refactor: extract HTTP timeout into a shared constant
esarafianou Mar 20, 2026
e5512dd
fix: add concurrency group to prevent parallel runs for same version
esarafianou Mar 20, 2026
8c7df92
Update generate_changelog.py
amyblais Mar 20, 2026
e41443e
Update generate-changelog.yml
amyblais Mar 20, 2026
173317b
Update generate_changelog.py
amyblais Mar 20, 2026
5d470b8
Merge branch 'master' into amyblais-changelogautomation
amyblais Mar 31, 2026
798249b
remove Jira links
amyblais Apr 2, 2026
be999d1
Merge branch 'master' into amyblais-changelogautomation
amyblais Apr 2, 2026
314eb5d
Update generate-changelog.yml
amyblais Apr 2, 2026
80482d4
Update generate_changelog.py
amyblais Apr 2, 2026
612a959
Merge branch 'master' into amyblais-changelogautomation
amyblais Apr 7, 2026
8c9e4e0
Merge branch 'master' into amyblais-changelogautomation
amyblais Apr 8, 2026
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
268 changes: 268 additions & 0 deletions .github/scripts/generate_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
#!/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
(e.g. "mattermost/mattermost,mattermost/enterprise")
MILESTONE - Milestone title (e.g. "v10.6")
VERSION - Version label for the changelog entry (e.g. "10.6")
ANTHROPIC_API_KEY - (optional) If set, release notes are polished by Claude
before being written to the changelog.
"""

import os
import re
import sys
import requests
from datetime import date

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",
}

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.

Top-level sections and their subsections:

- `### Upgrade Impact` — for changes that affect upgrading, with subsections as applicable:
- `#### Database Schema Changes` — new tables, columns, indexes, or migrations
- `#### config.json` — new or changed configuration settings; group by plan (e.g. "Changes to Enterprise plans")
- `#### Compatibility` — browser, OS, or minimum version requirement changes
- `### Improvements` — for new features and enhancements. 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, new visual features, pre-packaged plugin version updates
- `#### Administration` — System Console features, mmctl additions, logging, support packet changes
- `#### Performance` — performance improvements
- `### Bug Fixes` — corrections to defects
- `### API Changes` — API additions, changes, or deprecations
- `### Audit Log Event Changes` — new or changed audit log events
- `### Go Version` — Go version updates
- `### 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..."
- Improvements to existing things: "Improved [thing]..." or "Updated [thing]..."
- Removals: "Removed [thing]..."

3. **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``)

4. **Markdown formatting:** Use `- ` bullet points for individual items within sections. Ensure correct and clean Markdown syntax throughout.

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").

7. **Proofreading:** Correct any typos, grammatical errors, awkward phrasing, or inconsistencies. 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."""


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"
params = {"state": "all", "per_page": 100}
resp = requests.get(url, headers=HEADERS, params=params)
resp.raise_for_status()
milestones = resp.json()
for m in milestones:
if m["title"] == title:
return m["number"]
print(f" ⚠️ Milestone '{title}' not found in {repo} — skipping")
available = [m["title"] for m in milestones]
if available:
print(f" Available milestones: {', '.join(available[:10])}")
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 = []
page = 1
while True:
url = f"https://api.github.com/repos/{repo}/issues"
params = {
"milestone": milestone_number,
"state": "closed",
"per_page": 100,
"page": page,
}
resp = requests.get(url, headers=HEADERS, params=params)
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"):
prs.append(item)
Comment thread
amyblais marked this conversation as resolved.
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

# Capture everything after '#### Release Note' up to the next #### header or EOF
section_match = re.search(
r"####\s+Release\s+Note\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)

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.
Falls back to a simple bullet list if ANTHROPIC_API_KEY is not set.
"""
api_key = os.environ.get("ANTHROPIC_API_KEY")
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 prepend_to_changelog(entry: str, changelog_path: str = "CHANGELOG.md") -> None:
"""Prepend a new version entry to the changelog file."""
existing = ""
if os.path.exists(changelog_path):
with open(changelog_path, "r") as f:
existing = f.read()

# If the file starts with a top-level heading, insert the new entry below it
if existing.startswith("# "):
parts = existing.split("\n", 2)
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:
for note in notes:
all_notes.append(note)
print(f" ✅ #{pr['number']}: {pr['title']}")
else:
no_notes_prs.append((repo, pr))
print(f" ⏭️ #{pr['number']}: {pr['title']} (NONE / no notes)")
print()

today = date.today().strftime("%Y-%m-%d")
entry = f"## {VERSION} - {today}\n\n"

if all_notes:
polished = polish_with_ai(all_notes)
blog_url = os.environ.get("BLOG_POST_URL", "")
if blog_url:
polished = polished.replace("BLOG_POST_URL", blog_url)
entry += polished + "\n"
else:
entry += "_No release notes for this version._\n"

changelog_path = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
prepend_to_changelog(entry, changelog_path)

prs_with_notes = total_prs - len(no_notes_prs)
print(f"✅ CHANGELOG.md 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()
82 changes: 82 additions & 0 deletions .github/workflows/generate-changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Generate Changelog

on:
workflow_dispatch:
inputs:
milestone:
description: 'Milestone title in the server repo (e.g. v10.6)'
required: true
type: string
version:
description: 'Version label for changelog entry (e.g. 10.6)'
required: true
type: string
server_repos:
description: 'Comma-separated repos to pull PRs from'
required: true
default: 'mattermost/mattermost,mattermost/enterprise'
type: string
Comment thread
amyblais marked this conversation as resolved.
blog_post_url:
description: 'Blog post URL for the Improvements section (e.g. https://mattermost.com/blog/mattermost-v11-6-is-now-available/)'
required: false
type: string

jobs:
generate-changelog:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
# A GitHub App token is required so the workflow can read PRs from the
# server repo AND push/create PRs in this docs repo.
# The default GITHUB_TOKEN only works within the repo the workflow runs in.
- name: Get GitHub App token
uses: mattermost/github-app-installation-token-action@4d4c8c09a49df5b54e1eafd372d1a1e44fdcab13
id: appToken
with:
appId: "${{ vars.UNIFIED_CI_APP_ID }}"
installationId: "${{ vars.UNIFIED_CI_INSTALLATION_ID }}"
privateKey: ${{ secrets.UNIFIED_CI_PRIVATE_KEY }}

- name: Checkout docs repo
uses: actions/checkout@v4
with:
token: ${{ steps.appToken.outputs.token }}

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: pip install requests anthropic

- name: Generate changelog
env:
GITHUB_TOKEN: ${{ steps.appToken.outputs.token }} # App token has read access to the server repo
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # Add this secret under Settings → Secrets and variables → Actions to enable AI categorization and proofreading
MILESTONE: ${{ inputs.milestone }}
VERSION: ${{ inputs.version }}
REPOS: ${{ inputs.server_repos }} # Comma-separated repos to pull PRs from
BLOG_POST_URL: ${{ inputs.blog_post_url }}
CHANGELOG_PATH: source/product-overview/mattermost-v11-changelog.md
run: python .github/scripts/generate_changelog.py
Comment thread
amyblais marked this conversation as resolved.

- name: Create Pull Request
env:
GH_TOKEN: ${{ steps.appToken.outputs.token }}
run: |
git config user.name "${{ vars.UNIFIED_CI_USERNAME }}"
git config user.email "${{ vars.UNIFIED_CI_EMAIL }}"
git checkout -b "changelog/${{ inputs.version }}"
git add source/product-overview/mattermost-v11-changelog.md
git diff --staged --quiet && echo "No changes to commit" && exit 0
git commit -m "docs: add changelog for ${{ inputs.version }}"
git push origin "changelog/${{ inputs.version }}" --force
gh pr create \
--title "Changelog: ${{ inputs.version }}" \
--body "Auto-generated changelog for milestone \`${{ inputs.milestone }}\` from \`${{ inputs.server_repos }}\`." \
--base main \
--head "changelog/${{ inputs.version }}"
Loading