-
Notifications
You must be signed in to change notification settings - Fork 609
Create workflow for automated changelogs #8824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 c561a08
Create generate_changelog.py
amyblais a79aeb5
Update generate-changelog.yml
amyblais 132cd17
Update generate_changelog.py
amyblais 37ea628
Create generate_changelog.py
amyblais e9d880f
Delete scripts/generate_changelog.py
amyblais 6bb6cfc
Update generate-changelog.yml
amyblais 497e0c6
Update generate_changelog.py
amyblais a3cba5d
Update generate_changelog.py
amyblais a18a89e
Update generate_changelog.py
amyblais 5a358ff
Update .github/scripts/generate_changelog.py
amyblais 57a178f
Update .github/scripts/generate_changelog.py
amyblais 6e2cc91
Update generate_changelog.py
amyblais 6081acc
fix: prevent script injection in generate-changelog workflow
esarafianou ee807c7
fix: pin actions and pip dependencies to exact versions
esarafianou a85861b
fix: remove duplicate get_milestone_number definition
esarafianou 52b9adf
chore: remove unused sys import
esarafianou 9967e0c
fix: add timeout to get_merged_prs HTTP request
esarafianou 3410a36
refactor: extract HTTP timeout into a shared constant
esarafianou e5512dd
fix: add concurrency group to prevent parallel runs for same version
esarafianou 8c7df92
Update generate_changelog.py
amyblais e41443e
Update generate-changelog.yml
amyblais 173317b
Update generate_changelog.py
amyblais 5d470b8
Merge branch 'master' into amyblais-changelogautomation
amyblais 798249b
remove Jira links
amyblais be999d1
Merge branch 'master' into amyblais-changelogautomation
amyblais 314eb5d
Update generate-changelog.yml
amyblais 80482d4
Update generate_changelog.py
amyblais 612a959
Merge branch 'master' into amyblais-changelogautomation
amyblais 8c9e4e0
Merge branch 'master' into amyblais-changelogautomation
amyblais File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 | ||
|
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 }}" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.