From 8cb8540ef6b911c885a37073ac576d674608447d Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 11 Jan 2026 12:08:39 -0600 Subject: [PATCH] Add release notes generation and publishing workflow (#9255) --- .github/workflows/main_matrix.yml | 30 ++- .github/workflows/release_channels.yml | 31 +++ bin/generate_release_notes.py | 276 +++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 2 deletions(-) create mode 100755 bin/generate_release_notes.py diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index d7bde7bc5..04731f335 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -247,6 +247,24 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Generate release notes + id: release_notes + run: | + chmod +x ./bin/generate_release_notes.py + NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }}) + echo "notes<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release uses: softprops/action-gh-release@v2 @@ -256,8 +274,7 @@ jobs: prerelease: true name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha tag_name: v${{ needs.version.outputs.long }} - body: | - Autogenerated by github action, developer should edit as required before publishing... + body: ${{ steps.release_notes.outputs.notes }} - name: Download source deb uses: actions/download-artifact@v7 @@ -381,6 +398,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 @@ -400,6 +419,13 @@ jobs: pattern: manifest-${{ needs.version.outputs.long }} path: ./publish + - name: Generate release notes + run: | + chmod +x ./bin/generate_release_notes.py + ./bin/generate_release_notes.py ${{ needs.version.outputs.long }} > ./publish/release_notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish firmware to meshtastic.github.io uses: peaceiris/actions-gh-pages@v4 env: diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml index badbb31d4..7f925b67c 100644 --- a/.github/workflows/release_channels.yml +++ b/.github/workflows/release_channels.yml @@ -48,6 +48,37 @@ jobs: ${{ contains(github.event.release.name, 'Beta') && 'beta' || contains(github.event.release.name, 'Alpha') && 'alpha' }} secrets: inherit + publish-release-notes: + if: github.event.action == 'published' + runs-on: ubuntu-latest + steps: + - name: Get release version + id: version + run: | + # Extract version from tag (e.g., v2.7.15.567b8ea -> 2.7.15.567b8ea) + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get release notes + run: | + mkdir -p ./publish + gh release view ${{ github.event.release.tag_name }} --json body --jq '.body' > ./publish/release_notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish release notes to meshtastic.github.io + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} + external_repository: meshtastic/meshtastic.github.io + publish_branch: master + publish_dir: ./publish + destination_dir: firmware-${{ steps.version.outputs.version }} + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com + commit_message: Release notes for ${{ steps.version.outputs.version }} + enable_jekyll: true + # Create a PR to bump version when a release is Published bump-version: if: github.event.action == 'published' diff --git a/bin/generate_release_notes.py b/bin/generate_release_notes.py new file mode 100755 index 000000000..7c9ecb420 --- /dev/null +++ b/bin/generate_release_notes.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Generate release notes from merged PRs on develop and master branches. +Categorizes PRs into Enhancements and Bug Fixes/Maintenance sections. +""" + +import subprocess +import re +import json +import sys +from datetime import datetime + + +def get_last_release_tag(): + """Get the most recent release tag.""" + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def get_merged_prs_since_tag(tag, branch): + """Get all merged PRs since the given tag on the specified branch.""" + # Get commits since tag on the branch - look for PR numbers in parentheses + result = subprocess.run( + [ + "git", + "log", + f"{tag}..origin/{branch}", + "--oneline", + ], + capture_output=True, + text=True, + ) + + prs = [] + seen_pr_numbers = set() + + for line in result.stdout.strip().split("\n"): + if not line: + continue + + # Extract PR number from commit message - format: "Title (#1234)" + pr_match = re.search(r"\(#(\d+)\)", line) + if pr_match: + pr_number = pr_match.group(1) + if pr_number not in seen_pr_numbers: + seen_pr_numbers.add(pr_number) + prs.append(pr_number) + + return prs + + +def get_pr_details(pr_number): + """Get PR details from GitHub API via gh CLI.""" + try: + result = subprocess.run( + [ + "gh", + "pr", + "view", + pr_number, + "--json", + "title,author,labels,url", + ], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + except subprocess.CalledProcessError: + return None + + +def should_exclude_pr(pr_details): + """Check if PR should be excluded from release notes.""" + if not pr_details: + return True + + title = pr_details.get("title", "").lower() + + # Exclude trunk update PRs + if "upgrade trunk" in title or "update trunk" in title or "trunk update" in title: + return True + + # Exclude protobuf update PRs + if "update protobufs" in title or "update protobuf" in title: + return True + + # Exclude automated version bump PRs + if "bump release version" in title or "bump version" in title: + return True + + # Exclude automated dependency digest updates (chore(deps): update X digest to Y) + if re.match(r"^chore\(deps\):\s*update .+ digest to [a-f0-9]+$", title, re.IGNORECASE): + return True + + # Exclude generic "Update X digest to Y" patterns + if re.match(r"^update .+ digest to [a-f0-9]+$", title, re.IGNORECASE): + return True + + return False + + +def is_enhancement(pr_details): + """Determine if PR is an enhancement based on labels and title.""" + labels = [label.get("name", "").lower() for label in pr_details.get("labels", [])] + + # Check labels first + enhancement_labels = ["enhancement", "feature", "feat", "new feature"] + for label in labels: + if any(enh in label for enh in enhancement_labels): + return True + + # Check title prefixes + title = pr_details.get("title", "") + enhancement_prefixes = ["feat:", "feature:", "add:"] + title_lower = title.lower() + for prefix in enhancement_prefixes: + if title_lower.startswith(prefix) or f" {prefix}" in title_lower: + return True + + return False + + +def clean_title(title): + """Clean up PR title for release notes.""" + # Remove common prefixes + prefixes_to_remove = [ + r"^fix:\s*", + r"^feat:\s*", + r"^feature:\s*", + r"^bug:\s*", + r"^bugfix:\s*", + r"^chore:\s*", + r"^chore\([^)]+\):\s*", + r"^refactor:\s*", + r"^docs:\s*", + r"^ci:\s*", + r"^build:\s*", + r"^perf:\s*", + r"^style:\s*", + r"^test:\s*", + ] + + cleaned = title + for prefix in prefixes_to_remove: + cleaned = re.sub(prefix, "", cleaned, flags=re.IGNORECASE) + + # Ensure first letter is capitalized + if cleaned: + cleaned = cleaned[0].upper() + cleaned[1:] + + return cleaned.strip() + + +def format_pr_line(pr_details): + """Format a PR as a markdown bullet point.""" + title = clean_title(pr_details.get("title", "Unknown")) + author = pr_details.get("author", {}).get("login", "unknown") + url = pr_details.get("url", "") + + return f"- {title} by @{author} in {url}" + + +def get_new_contributors(pr_details_list, tag): + """Find contributors who made their first contribution in this release.""" + # Exclude bots from new contributors + bot_authors = {"github-actions", "renovate", "dependabot", "app/renovate", "app/github-actions", "app/dependabot"} + + new_contributors = [] + seen_authors = set() + + for pr in pr_details_list: + author = pr.get("author", {}).get("login", "") + if not author or author in seen_authors: + continue + + # Skip bots + if author.lower() in bot_authors or author.startswith("app/"): + continue + + seen_authors.add(author) + + # Check if this author appears in git history before tag + author_check = subprocess.run( + ["git", "log", f"{tag}", f"--author={author}", "--oneline", "-1"], + capture_output=True, + text=True, + ) + if not author_check.stdout.strip(): + new_contributors.append((author, pr.get("url", ""))) + + return new_contributors + + +def main(): + if len(sys.argv) < 2: + print("Usage: generate_release_notes.py ", file=sys.stderr) + sys.exit(1) + + new_version = sys.argv[1] + + # Get last release tag + try: + last_tag = get_last_release_tag() + except subprocess.CalledProcessError: + print("Error: Could not find last release tag", file=sys.stderr) + sys.exit(1) + + # Collect PRs from both branches + all_pr_numbers = set() + + for branch in ["develop", "master"]: + try: + prs = get_merged_prs_since_tag(last_tag, branch) + all_pr_numbers.update(prs) + except Exception as e: + print(f"Warning: Could not get PRs from {branch}: {e}", file=sys.stderr) + + # Get details for all PRs + enhancements = [] + bug_fixes = [] + all_pr_details = [] + + for pr_number in sorted(all_pr_numbers, key=int): + details = get_pr_details(pr_number) + if details and not should_exclude_pr(details): + all_pr_details.append(details) + if is_enhancement(details): + enhancements.append(details) + else: + bug_fixes.append(details) + + # Generate release notes + output = [] + + if enhancements: + output.append("## 🚀 Enhancements\n") + for pr in enhancements: + output.append(format_pr_line(pr)) + output.append("") + + if bug_fixes: + output.append("## 🐛 Bug fixes and maintenance\n") + for pr in bug_fixes: + output.append(format_pr_line(pr)) + output.append("") + + # Find new contributors + new_contributors = get_new_contributors(all_pr_details, last_tag) + if new_contributors: + output.append("## New Contributors\n") + for author, url in new_contributors: + # Find first PR URL for this contributor + first_pr_url = url + for pr in all_pr_details: + if pr.get("author", {}).get("login") == author: + first_pr_url = pr.get("url", url) + break + output.append(f"- @{author} made their first contribution in {first_pr_url}") + output.append("") + + # Add full changelog link + output.append( + f"**Full Changelog**: https://github.com/meshtastic/firmware/compare/{last_tag}...v{new_version}" + ) + + print("\n".join(output)) + + +if __name__ == "__main__": + main()