Add release notes generation and publishing workflow (#9255)
This commit is contained in:
@@ -247,6 +247,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
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<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -256,8 +274,7 @@ jobs:
|
|||||||
prerelease: true
|
prerelease: true
|
||||||
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
|
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
|
||||||
tag_name: v${{ needs.version.outputs.long }}
|
tag_name: v${{ needs.version.outputs.long }}
|
||||||
body: |
|
body: ${{ steps.release_notes.outputs.notes }}
|
||||||
Autogenerated by github action, developer should edit as required before publishing...
|
|
||||||
|
|
||||||
- name: Download source deb
|
- name: Download source deb
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
@@ -381,6 +398,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
@@ -400,6 +419,13 @@ jobs:
|
|||||||
pattern: manifest-${{ needs.version.outputs.long }}
|
pattern: manifest-${{ needs.version.outputs.long }}
|
||||||
path: ./publish
|
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
|
- name: Publish firmware to meshtastic.github.io
|
||||||
uses: peaceiris/actions-gh-pages@v4
|
uses: peaceiris/actions-gh-pages@v4
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -48,6 +48,37 @@ jobs:
|
|||||||
${{ contains(github.event.release.name, 'Beta') && 'beta' || contains(github.event.release.name, 'Alpha') && 'alpha' }}
|
${{ contains(github.event.release.name, 'Beta') && 'beta' || contains(github.event.release.name, 'Alpha') && 'alpha' }}
|
||||||
secrets: inherit
|
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
|
# Create a PR to bump version when a release is Published
|
||||||
bump-version:
|
bump-version:
|
||||||
if: github.event.action == 'published'
|
if: github.event.action == 'published'
|
||||||
|
|||||||
Executable
+276
@@ -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 <new_version>", 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()
|
||||||
Reference in New Issue
Block a user