diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py index 40a3e03652b..13a29dbaa9c 100644 --- a/.github/scripts/generate_changelog.py +++ b/.github/scripts/generate_changelog.py @@ -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 @@ -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()] @@ -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: @@ -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) @@ -64,17 +84,19 @@ - `### 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 ```` 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..." + - 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``) @@ -82,16 +104,17 @@ - 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: @@ -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: @@ -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 @@ -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 @@ -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() @@ -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) diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index ea07dd219a8..66343e584f9 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -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 @@ -79,7 +91,9 @@ jobs: MILESTONE: ${{ inputs.milestone }} VERSION: ${{ inputs.version }} REPOS: ${{ inputs.server_repos }} + RELEASE_TYPE: ${{ inputs.release_type }} RELEASE_DATE: ${{ inputs.release_date }} + GO_VERSION: ${{ inputs.go_version }} BLOG_POST_URL: ${{ inputs.blog_post_url }} CHANGELOG_PATH: source/product-overview/mattermost-v11-changelog.md run: python .github/scripts/generate_changelog.py