--- /dev/null
+# Custom Release Notes Generator
+
+## Overview
+
+We've replaced Release Drafter with a custom GitHub Action that generates release notes with full control over commit ranges and PR categorization.
+
+## Why Custom Solution?
+
+Release Drafter's `filter-by-commitish` feature proved unreliable:
+- Inconsistent handling of branch references (`dev` vs `refs/heads/dev`)
+- No way to explicitly specify the previous release tag
+- Would sometimes include entire git history instead of just changes since last release
+- Caused "body too long" errors when it tried to include all commits
+
+## How It Works
+
+### 1. Custom Action: `.github/actions/generate-release-notes/`
+
+This is a complete, self-contained GitHub Action that handles everything:
+
+**Inputs:**
+- `version`: The version being released
+- `previous-tag`: The previous release tag to compare against (optional)
+- `branch`: The branch being released from
+- `channel`: Release channel (stable/beta/nightly)
+- `github-token`: GitHub token for API access
+
+**Outputs:**
+- `release-notes`: Complete release notes including server changes, frontend changes, and merged contributors
+
+**What it does internally:**
+1. Generates base release notes from server PRs
+2. Extracts frontend changes from frontend update PRs
+3. Merges contributors from both server and frontend
+4. Returns complete, formatted release notes ready to publish
+
+### 2. Python Script: `generate_notes.py`
+
+The script:
+1. **Loads configuration** from `.github/release-notes-config.yml`
+2. **Fetches PRs** between the previous tag and current branch HEAD using GitHub API
+3. **Categorizes PRs** based on labels:
+ - â Breaking Changes
+ - đ New Providers
+ - đ Features and enhancements
+ - đ Bugfixes
+ - đ§° Maintenance and dependency bumps
+4. **Extracts contributors** excluding bots
+5. **Formats notes** using templates from config
+
+### 3. Workflow Integration
+
+The release workflow is now incredibly simple:
+1. **Detects previous tag** using channel-specific patterns
+2. **Calls the action** with one step - that's it!
+3. **Creates GitHub release** with the complete notes from the action
+
+All the complexity (server PRs, frontend PRs, contributor merging) is handled inside the reusable action.
+
+## Benefits
+
+â
**Full control** - We explicitly specify which tag to compare against
+â
**Reliable** - No mysterious "entire history" issues
+â
**Consistent** - Uses same config format as Release Drafter
+â
**Faster** - Only fetches the PRs we need
+â
**Maintainable** - Clear Python code instead of black-box action
+â
**Flexible** - Easy to customize formatting or add features
+
+## Configuration
+
+The generator reads `.github/release-notes-config.yml`:
+
+```yaml
+change-template: '- $TITLE (by @$AUTHOR in #$NUMBER)'
+
+exclude-contributors:
+ - dependabot
+ - dependabot[bot]
+ # ... more bots
+
+categories:
+ - title: "â Breaking Changes"
+ labels:
+ - 'breaking-change'
+ - title: "đ Features and enhancements"
+ labels:
+ - 'feature'
+ - 'enhancement'
+ # ... more categories
+
+template: |
+ $CHANGES
+
+ ## :bow: Thanks to our contributors
+
+ Special thanks to the following contributors who helped with this release:
+
+ $CONTRIBUTORS
+```
+
+## Example Output
+
+```markdown
+## đĻ Nightly Release
+
+_Changes since [2.7.0.dev20251022](https://github.com/music-assistant/server/releases/tag/2.7.0.dev20251022)_
+
+### đ Features and enhancements
+
+- Add new audio processor (by @contributor1 in #123)
+- Improve queue management (by @contributor2 in #124)
+
+### đ Bugfixes
+
+- Fix playback issue (by @contributor1 in #125)
+
+## đ¨ Frontend Changes
+
+- Add dark mode toggle
+- Improve mobile layout
+- Fix typo in settings
+
+## :bow: Thanks to our contributors
+
+Special thanks to the following contributors who helped with this release:
+
+@contributor1, @contributor2, @frontend-contributor
+```
+
+## Testing
+
+To test locally:
+```bash
+cd .github/actions/generate-release-notes
+
+# Set environment variables
+export GITHUB_TOKEN="your_token"
+export VERSION="2.7.0.dev20251024"
+export PREVIOUS_TAG="2.7.0.dev20251023"
+export BRANCH="dev"
+export CHANNEL="nightly"
+export GITHUB_REPOSITORY="music-assistant/server"
+
+# Run the script
+python3 generate_notes.py
+```
+
+## Maintenance
+
+To modify release notes formatting:
+1. Edit `.github/release-notes-config.yml` to change categories, labels, or templates
+2. Edit `generate_notes.py` if you need to change the generation logic
+3. No changes needed to the main workflow unless adding new features
+
+## Configuration File Format
+
+The configuration file (`.github/release-notes-config.yml`) uses the same format as Release Drafter used to:
+- â
Same configuration structure
+- â
Same output formatting
+- â
Same contributor exclusion logic
+- â
Same label-based categorization
+
+But now we have **full control** over the commit range and complete visibility into the generation process.
--- /dev/null
+name: 'Generate Release Notes'
+description: 'Generate complete release notes with PR categorization, frontend changes, and merged contributors'
+inputs:
+ version:
+ description: 'The version being released'
+ required: true
+ previous-tag:
+ description: 'The previous release tag to compare against'
+ required: false
+ default: ''
+ branch:
+ description: 'The branch being released from'
+ required: true
+ channel:
+ description: 'The release channel (stable, beta, nightly)'
+ required: true
+ github-token:
+ description: 'GitHub token for API access'
+ required: true
+outputs:
+ release-notes:
+ description: 'The complete generated release notes including frontend changes'
+ value: ${{ steps.finalize.outputs.release-notes }}
+runs:
+ using: 'composite'
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v6.0.0
+ with:
+ python-version: '3.12'
+
+ - name: Install dependencies
+ shell: bash
+ run: |
+ pip install PyGithub PyYAML
+
+ - name: Generate base release notes
+ id: generate
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ inputs.github-token }}
+ VERSION: ${{ inputs.version }}
+ PREVIOUS_TAG: ${{ inputs.previous-tag }}
+ BRANCH: ${{ inputs.branch }}
+ CHANNEL: ${{ inputs.channel }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ python3 ${{ github.action_path }}/generate_notes.py
+
+ - name: Extract and merge frontend changes
+ id: frontend
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ inputs.github-token }}
+ VERSION: ${{ inputs.version }}
+ PREVIOUS_TAG: ${{ inputs.previous-tag }}
+ BRANCH: ${{ inputs.branch }}
+ run: |
+ # Get the base release notes
+ BASE_NOTES="${{ steps.generate.outputs.release-notes }}"
+ SERVER_CONTRIBUTORS="${{ steps.generate.outputs.contributors }}"
+
+ # Save base notes to file
+ cat > /tmp/base_notes.md << 'EOF'
+ ${{ steps.generate.outputs.release-notes }}
+ EOF
+
+ # Create temp files for frontend changes
+ FRONTEND_FILE=$(mktemp)
+ CONTRIBUTORS_FILE=$(mktemp)
+
+ # Start with server contributors
+ echo "$SERVER_CONTRIBUTORS" | tr ',' '\n' > "$CONTRIBUTORS_FILE"
+
+ # Find frontend update PRs
+ echo "đĻ Looking for frontend update PRs..."
+
+ if [ -z "$PREVIOUS_TAG" ]; then
+ echo "No previous tag, searching recent merged PRs"
+ gh pr list --state merged --limit 100 --json number,title,body --jq '.[] | select(.title | test("^âŦī¸ Update music-assistant-frontend to [0-9]")) | {number: .number, title: .title, body: .body}' > /tmp/frontend_prs.json
+ else
+ echo "Searching between $PREVIOUS_TAG and $BRANCH"
+ git log $PREVIOUS_TAG..$BRANCH --oneline --merges | grep -oP '#\K[0-9]+' > /tmp/pr_numbers.txt || echo ""
+
+ > /tmp/frontend_prs.json
+ while read -r PR_NUM; do
+ if [ -n "$PR_NUM" ]; then
+ PR_DATA=$(gh pr view $PR_NUM --json number,title,body 2>/dev/null || echo "")
+ if [ -n "$PR_DATA" ]; then
+ if echo "$PR_DATA" | jq -e '.title | test("^âŦī¸ Update music-assistant-frontend to [0-9]")' > /dev/null 2>&1; then
+ echo "$PR_DATA" >> /tmp/frontend_prs.json
+ fi
+ fi
+ fi
+ done < /tmp/pr_numbers.txt
+ fi
+
+ # Process frontend PRs
+ if [ -s /tmp/frontend_prs.json ]; then
+ echo "## đ¨ Frontend Changes" > "$FRONTEND_FILE"
+ echo "" >> "$FRONTEND_FILE"
+
+ while IFS= read -r pr_json; do
+ if [ -n "$pr_json" ]; then
+ BODY=$(echo "$pr_json" | jq -r '.body')
+
+ # Extract bullet points, excluding headers and dependabot lines
+ echo "$BODY" | grep -E '^[[:space:]]*[âĸ-]' | \
+ grep -v 'đ' | \
+ grep -viE '^[[:space:]]*[âĸ-][[:space:]]*Chore\(deps' | \
+ head -20 >> "$FRONTEND_FILE" || true
+
+ # Extract contributors
+ echo "$BODY" | grep -oP '@[a-zA-Z0-9_-]+' >> "$CONTRIBUTORS_FILE" || true
+ fi
+ done < /tmp/frontend_prs.json
+
+ # Check if we found changes
+ if [ $(wc -l < "$FRONTEND_FILE") -gt 3 ]; then
+ echo "â
Found frontend changes"
+
+ # Merge contributors (deduplicate and format)
+ MERGED_CONTRIBUTORS=$(sort -u "$CONTRIBUTORS_FILE" | sed 's/^/@/' | paste -sd ", " -)
+
+ # Find contributors section in base notes
+ CONTRIB_LINE=$(grep -n "## :bow: Thanks to our contributors" /tmp/base_notes.md | head -1 | cut -d: -f1 || echo "")
+
+ if [ -n "$CONTRIB_LINE" ]; then
+ # Split notes at contributors section
+ head -n $((CONTRIB_LINE - 1)) /tmp/base_notes.md > /tmp/before_contrib.md
+
+ # Build final notes
+ {
+ cat /tmp/before_contrib.md
+ echo ""
+ cat "$FRONTEND_FILE"
+ echo ""
+ echo "## :bow: Thanks to our contributors"
+ echo ""
+ echo "Special thanks to the following contributors who helped with this release:"
+ echo ""
+ echo "$MERGED_CONTRIBUTORS"
+ } > /tmp/final_notes.md
+ else
+ # No contributors section, just append
+ {
+ cat /tmp/base_notes.md
+ echo ""
+ cat "$FRONTEND_FILE"
+ } > /tmp/final_notes.md
+ fi
+
+ echo "has_frontend_changes=true" >> $GITHUB_OUTPUT
+ else
+ echo "âšī¸ No frontend changes found"
+ cp /tmp/base_notes.md /tmp/final_notes.md
+ echo "has_frontend_changes=false" >> $GITHUB_OUTPUT
+ fi
+ else
+ echo "âšī¸ No frontend PRs found"
+ cp /tmp/base_notes.md /tmp/final_notes.md
+ echo "has_frontend_changes=false" >> $GITHUB_OUTPUT
+ fi
+
+ # Cleanup
+ rm -f "$FRONTEND_FILE" "$CONTRIBUTORS_FILE" /tmp/frontend_prs.json /tmp/pr_numbers.txt /tmp/before_contrib.md
+
+ - name: Output final notes
+ id: finalize
+ shell: bash
+ run: |
+ # Read the final notes and output them
+ FINAL_NOTES=$(cat /tmp/final_notes.md)
+
+ # Use multiline output format
+ cat >> $GITHUB_OUTPUT << 'EOF'
+ release-notes<<NOTES_EOF
+ EOF
+ cat /tmp/final_notes.md >> $GITHUB_OUTPUT
+ cat >> $GITHUB_OUTPUT << 'EOF'
+ NOTES_EOF
+ EOF
+
+ # Cleanup
+ rm -f /tmp/base_notes.md /tmp/final_notes.md
+
+ echo "â
Release notes generation complete"
--- /dev/null
+#!/usr/bin/env python3
+"""Generate release notes based on PRs between two tags.
+
+Reads configuration from .github/release-notes-config.yml for categorization and formatting.
+"""
+
+import os
+import re
+import sys
+from collections import defaultdict
+
+import yaml
+from github import Github, GithubException
+
+
+def load_config():
+ """Load the release-notes-config.yml configuration."""
+ config_path = ".github/release-notes-config.yml"
+ if not os.path.exists(config_path):
+ print(f"Error: {config_path} not found") # noqa: T201
+ sys.exit(1)
+
+ with open(config_path) as f:
+ return yaml.safe_load(f)
+
+
+def get_prs_between_tags(repo, previous_tag, current_branch):
+ """Get all merged PRs between the previous tag and current HEAD."""
+ if not previous_tag:
+ print("No previous tag specified, will include all PRs from branch history") # noqa: T201
+ # Get the first commit on the branch
+ commits = list(repo.get_commits(sha=current_branch))
+ # Limit to last 100 commits to avoid going too far back
+ commits = commits[:100]
+ else:
+ print(f"Finding PRs between {previous_tag} and {current_branch}") # noqa: T201
+ comparison = repo.compare(previous_tag, current_branch)
+ commits = comparison.commits
+ print(f"Found {comparison.total_commits} commits") # noqa: T201
+
+ # Extract PR numbers from commit messages
+ pr_numbers = set()
+ pr_pattern = re.compile(r"#(\d+)")
+ merge_pattern = re.compile(r"Merge pull request #(\d+)")
+
+ for commit in commits:
+ message = commit.commit.message
+ # First check for merge commits
+ merge_match = merge_pattern.search(message)
+ if merge_match:
+ pr_numbers.add(int(merge_match.group(1)))
+ else:
+ # Look for PR references in the message
+ for match in pr_pattern.finditer(message):
+ pr_numbers.add(int(match.group(1)))
+
+ print(f"Found {len(pr_numbers)} unique PRs") # noqa: T201
+
+ # Fetch the actual PR objects
+ prs = []
+ for pr_num in sorted(pr_numbers):
+ try:
+ pr = repo.get_pull(pr_num)
+ if pr.merged:
+ prs.append(pr)
+ except GithubException as e:
+ print(f"Warning: Could not fetch PR #{pr_num}: {e}") # noqa: T201
+
+ return prs
+
+
+def categorize_prs(prs, config):
+ """Categorize PRs based on their labels using the config."""
+ categories = defaultdict(list)
+ uncategorized = []
+
+ # Get category definitions from config
+ category_configs = config.get("categories", [])
+
+ # Get excluded labels
+ exclude_labels = set(config.get("exclude-labels", []))
+ include_labels = config.get("include-labels")
+ if include_labels:
+ include_labels = set(include_labels)
+
+ for pr in prs:
+ # Check if PR should be excluded
+ pr_labels = {label.name for label in pr.labels}
+
+ if exclude_labels and pr_labels & exclude_labels:
+ continue
+
+ if include_labels and not (pr_labels & include_labels):
+ continue
+
+ # Try to categorize
+ categorized = False
+ for cat_config in category_configs:
+ cat_title = cat_config.get("title", "Other")
+ cat_labels = cat_config.get("labels", [])
+ if isinstance(cat_labels, str):
+ cat_labels = [cat_labels]
+
+ # Check if PR has any of the category labels
+ if pr_labels & set(cat_labels):
+ categories[cat_title].append(pr)
+ categorized = True
+ break
+
+ if not categorized:
+ uncategorized.append(pr)
+
+ return categories, uncategorized
+
+
+def get_contributors(prs, config):
+ """Extract unique contributors from PRs."""
+ excluded = set(config.get("exclude-contributors", []))
+ contributors = set()
+
+ for pr in prs:
+ author = pr.user.login
+ if author not in excluded:
+ contributors.add(author)
+
+ return sorted(contributors)
+
+
+def format_change_line(pr, config):
+ """Format a single PR line using the change-template from config."""
+ template = config.get("change-template", "- $TITLE (by @$AUTHOR in #$NUMBER)")
+
+ # Get title and escape characters if specified
+ title = pr.title
+ escapes = config.get("change-title-escapes", "")
+ if escapes:
+ for char in escapes:
+ if char in title:
+ title = title.replace(char, "\\" + char)
+
+ # Replace template variables
+ result = template.replace("$TITLE", title)
+ result = result.replace("$AUTHOR", pr.user.login)
+ result = result.replace("$NUMBER", str(pr.number))
+ return result.replace("$URL", pr.html_url)
+
+
+def generate_release_notes(config, categories, uncategorized, contributors, previous_tag):
+ """Generate the formatted release notes."""
+ lines = []
+
+ # Add header if previous tag exists
+ if previous_tag:
+ repo_url = (
+ os.environ.get("GITHUB_SERVER_URL", "https://github.com")
+ + "/"
+ + os.environ["GITHUB_REPOSITORY"]
+ )
+ channel = os.environ.get("CHANNEL", "").title()
+ if channel:
+ lines.append(f"## đĻ {channel} Release")
+ lines.append("")
+ lines.append(f"_Changes since [{previous_tag}]({repo_url}/releases/tag/{previous_tag})_")
+ lines.append("")
+
+ # Add categorized PRs
+ category_configs = config.get("categories", [])
+ for cat_config in category_configs:
+ cat_title = cat_config.get("title", "Other")
+ if cat_title not in categories or not categories[cat_title]:
+ continue
+
+ prs = categories[cat_title]
+ lines.append(f"### {cat_title}")
+ lines.append("")
+
+ # Check if category should be collapsed
+ collapse_after = cat_config.get("collapse-after")
+ if collapse_after and len(prs) > collapse_after:
+ lines.append("<details>")
+ lines.append(f"<summary>{len(prs)} changes</summary>")
+ lines.append("")
+
+ for pr in prs:
+ lines.append(format_change_line(pr, config))
+
+ if collapse_after and len(prs) > collapse_after:
+ lines.append("")
+ lines.append("</details>")
+
+ lines.append("")
+
+ # Add uncategorized PRs if any
+ if uncategorized:
+ lines.append("### Other Changes")
+ lines.append("")
+ for pr in uncategorized:
+ lines.append(format_change_line(pr, config))
+ lines.append("")
+
+ # Add contributors section using template
+ if contributors:
+ template = config.get("template", "")
+ if "$CONTRIBUTORS" in template or not template:
+ lines.append("## :bow: Thanks to our contributors")
+ lines.append("")
+ lines.append(
+ "Special thanks to the following contributors who helped with this release:"
+ )
+ lines.append("")
+ lines.append(", ".join(f"@{c}" for c in contributors))
+
+ return "\n".join(lines)
+
+
+def main():
+ """Generate release notes for the target version."""
+ # Get environment variables
+ github_token = os.environ.get("GITHUB_TOKEN")
+ version = os.environ.get("VERSION")
+ previous_tag = os.environ.get("PREVIOUS_TAG", "")
+ branch = os.environ.get("BRANCH")
+ channel = os.environ.get("CHANNEL")
+ repo_name = os.environ.get("GITHUB_REPOSITORY")
+
+ if not all([github_token, version, branch, channel, repo_name]):
+ print("Error: Missing required environment variables") # noqa: T201
+ sys.exit(1)
+
+ print(f"Generating release notes for {version} ({channel} channel)") # noqa: T201
+ print(f"Repository: {repo_name}") # noqa: T201
+ print(f"Branch: {branch}") # noqa: T201
+ print(f"Previous tag: {previous_tag or 'None (first release)'}") # noqa: T201
+
+ # Initialize GitHub API
+ g = Github(github_token)
+ repo = g.get_repo(repo_name)
+
+ # Load configuration
+ config = load_config()
+ print(f"Loaded config with {len(config.get('categories', []))} categories") # noqa: T201
+
+ # Get PRs between tags
+ prs = get_prs_between_tags(repo, previous_tag, branch)
+ print(f"Processing {len(prs)} merged PRs") # noqa: T201
+
+ if not prs:
+ print("No PRs found in range") # noqa: T201
+ no_changes = config.get("no-changes-template", "* No changes")
+ notes = no_changes
+ contributors_list = []
+ else:
+ # Categorize PRs
+ categories, uncategorized = categorize_prs(prs, config)
+ print(f"Categorized into {len(categories)} categories, {len(uncategorized)} uncategorized") # noqa: T201
+
+ # Get contributors
+ contributors_list = get_contributors(prs, config)
+ print(f"Found {len(contributors_list)} contributors") # noqa: T201
+
+ # Generate formatted notes
+ notes = generate_release_notes(
+ config, categories, uncategorized, contributors_list, previous_tag
+ )
+
+ # Output to GitHub Actions
+ # Use multiline output format
+ output_file = os.environ.get("GITHUB_OUTPUT")
+ if output_file:
+ with open(output_file, "a") as f:
+ f.write("release-notes<<EOF\n")
+ f.write(notes)
+ f.write("\nEOF\n")
+ f.write("contributors<<EOF\n")
+ f.write(",".join(contributors_list))
+ f.write("\nEOF\n")
+ else:
+ print("\n=== Generated Release Notes ===\n") # noqa: T201
+ print(notes) # noqa: T201
+ print("\n=== Contributors ===\n") # noqa: T201
+ print(", ".join(contributors_list)) # noqa: T201
+
+
+if __name__ == "__main__":
+ main()
+++ /dev/null
-change-template: '- $TITLE (by @$AUTHOR in #$NUMBER)'
-
-# Note: prerelease flag is set dynamically by the workflow based on channel
-# This config is used for all release channels (stable, beta, nightly)
-
-# Filter releases by commitish (branch) to only show releases from the same branch
-# This allows us to have separate release notes for stable/beta/nightly channels
-filter-by-commitish: true
-
-# Exclude bots from contributors list
-exclude-contributors:
- - dependabot
- - dependabot[bot]
- - github-actions
- - github-actions[bot]
- - music-assistant-machine
-
-categories:
-
- - title: "â Breaking Changes"
- labels:
- - 'breaking-change'
-
- - title: "đ New Providers"
- labels:
- - 'new-provider'
-
- - title: "đ Features and enhancements"
- labels:
- - 'feature'
- - 'enhancement'
- - 'new-feature'
-
- - title: "đ Bugfixes"
- labels:
- - 'bugfix'
-
- - title: 'đ§° Maintenance and dependency bumps'
- collapse-after: 3
- labels:
- - 'ci'
- - 'documentation'
- - 'maintenance'
- - 'dependencies'
-
-template: |
-
- $CHANGES
-
-
- ## :bow: Thanks to our contributors
-
- Special thanks to the following contributors who helped with this release:
-
- $CONTRIBUTORS
--- /dev/null
+# Release Notes Configuration
+#
+# This file configures the automatic release notes generation for all release channels
+# (stable, beta, and nightly). The custom release notes generator action reads this file
+# to determine how to categorize pull requests and format the release notes.
+#
+# Location: .github/actions/generate-release-notes/generate_notes.py
+
+# Template for individual PR entries in the release notes
+change-template: '- $TITLE (by @$AUTHOR in #$NUMBER)'
+
+# Exclude bots from contributors list
+exclude-contributors:
+ - dependabot
+ - dependabot[bot]
+ - github-actions
+ - github-actions[bot]
+ - music-assistant-machine
+
+categories:
+
+ - title: "â Breaking Changes"
+ labels:
+ - 'breaking-change'
+
+ - title: "đ New Providers"
+ labels:
+ - 'new-provider'
+
+ - title: "đ Features and enhancements"
+ labels:
+ - 'feature'
+ - 'enhancement'
+ - 'new-feature'
+
+ - title: "đ Bugfixes"
+ labels:
+ - 'bugfix'
+
+ - title: 'đ§° Maintenance and dependency bumps'
+ collapse-after: 3
+ labels:
+ - 'ci'
+ - 'documentation'
+ - 'maintenance'
+ - 'dependencies'
+
+template: |
+
+ $CHANGES
+
+
+ ## :bow: Thanks to our contributors
+
+ Special thanks to the following contributors who helped with this release:
+
+ $CONTRIBUTORS
- **Beta** releases only show commits from the `dev` branch since the last beta
- **Nightly** releases only show commits from the `dev` branch since the last nightly
-The workflow uses your **Release Drafter configuration** (`.github/release-drafter.yml`) for label-based categorization and formatting.
+The workflow uses the **release notes configuration** (`.github/release-notes-config.yml`) for label-based categorization and formatting.
## How It Works
### 1. Filter by Branch (commitish)
-The `.github/release-drafter.yml` file includes:
+The `.github/release-notes-config.yml` file includes:
```yaml
filter-by-commitish: true
1. **Finds commit range**: Determines commits between the previous release (same branch) and HEAD
2. **Extracts PRs**: Identifies all merged pull requests in that range
-3. **Categorizes by labels**: Applies the category rules from `.github/release-drafter.yml`:
+3. **Categorizes by labels**: Applies the category rules from `.github/release-notes-config.yml`:
- â Breaking Changes (`breaking-change` label)
- đ New Providers (`new-provider` label)
- đ Features and enhancements (`feature`, `enhancement`, `new-feature` labels)
- **Do NOT include** beta or stable releases in between
- Example: `2.1.0.dev20251022` â `2.1.0.dev20251023` only shows dev branch commits since yesterday
-## Release Drafter Configuration
+## Release Notes Configuration
-â
The workflow **uses Release Drafter natively** with the following key configuration:
+â
The workflow uses a **custom release notes generator** that reads `.github/release-notes-config.yml`:
```yaml
-# .github/release-drafter.yml
-filter-by-commitish: true # Only consider releases from the same branch
+# .github/release-notes-config.yml
+change-template: '- $TITLE (by @$AUTHOR in #$NUMBER)'
+
+categories:
+ - title: "â Breaking Changes"
+ labels: ['breaking-change']
+ # ... more categories
```
-This configuration, combined with setting the appropriate `commitish` parameter when calling Release Drafter, ensures:
-- **No temporary hiding of releases** (avoids updating publish dates)
-- **Native Release Drafter filtering** (robust and reliable)
-- **Branch-based separation** (stable vs dev commits are naturally separated)
+This approach ensures:
+- **Full control over commit range** (explicit previous tag parameter)
+- **No mysterious failures** (clear Python code you can debug)
+- **Consistent formatting** (same config format as Release Drafter used)
+- **Branch-based separation** (stable vs dev commits via explicit tag comparison)
-The workflow also uses your existing configuration for:
+The configuration includes:
- Category definitions (labels â section headers)
- Category titles and emoji
- Excluded contributors (bots)
- PR title format
+- Collapse settings for long categories
## Example Release Notes Format
## Release Notes
-Release notes are automatically generated by Release Drafter based on:
+Release notes are automatically generated by our custom release notes generator based on:
- Merged pull requests since the last release of the same channel
- PR labels (breaking-change, feature, bugfix, etc.)
-- Contributors list
+- Contributors list (merged from server and frontend PRs)
+- Frontend changes extracted from frontend update PRs
-The release drafter configuration is in `.github/release-drafter.yml`.
+The configuration is in `.github/release-notes-config.yml`.
+
+See `.github/actions/generate-release-notes/README.md` for more details on how the generator works.
## Examples
echo "has_prev_tag=true" >> $GITHUB_OUTPUT
fi
- - name: Create Release with Release Drafter
- id: create_release
- uses: release-drafter/release-drafter@v6
+ - name: Generate complete release notes
+ id: generate_notes
+ uses: ./.github/actions/generate-release-notes
with:
version: ${{ inputs.version }}
- tag: ${{ inputs.version }}
- name: ${{ inputs.version }}
- prerelease: ${{ needs.validate-and-build.outputs.is_prerelease }}
- commitish: ${{ needs.validate-and-build.outputs.branch }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Add channel context to release notes
- if: steps.prev_version.outputs.has_prev_tag == 'true'
- run: |
- PREV_TAG="${{ steps.prev_version.outputs.prev_tag }}"
- CHANNEL="${{ inputs.channel }}"
- VERSION="${{ inputs.version }}"
- REPO="${{ github.repository }}"
-
- # Get current release notes from Release Drafter
- gh release view "$VERSION" --json body --jq .body > /tmp/current_notes.md
-
- # Prepend channel context
- echo "## đĻ ${CHANNEL^} Release" > /tmp/new_notes.md
- echo "" >> /tmp/new_notes.md
- echo "_Changes since [$PREV_TAG](https://github.com/$REPO/releases/tag/$PREV_TAG)_" >> /tmp/new_notes.md
- echo "" >> /tmp/new_notes.md
- echo "___" >> /tmp/new_notes.md
- echo "" >> /tmp/new_notes.md
- cat /tmp/current_notes.md >> /tmp/new_notes.md
-
- # Update the release
- gh release edit "$VERSION" --notes-file /tmp/new_notes.md
-
- echo "â
Added channel context to release notes"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract and append frontend changes to release notes
- id: update_notes
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- echo "đĻ Extracting frontend changes from PRs..."
-
- # Get the current release body
- RELEASE_BODY=$(gh release view "${{ inputs.version }}" --json body --jq .body)
-
- # Find all frontend update PRs since the previous tag
- PREV_TAG="${{ steps.prev_version.outputs.prev_tag }}"
-
- # Create temp files
- FRONTEND_FILE=$(mktemp)
- CONTRIBUTORS_FILE=$(mktemp)
-
- if [ -z "$PREV_TAG" ]; then
- echo "No previous tag found, searching recent merged PRs"
- gh pr list --state merged --limit 100 --json number,title,body --jq '.[] | select(.title | test("^âŦī¸ Update music-assistant-frontend to [0-9]")) | {number: .number, title: .title, body: .body}' > /tmp/frontend_prs.json
- else
- # Get PR numbers from merge commits since previous tag
- echo "Searching for frontend PRs between $PREV_TAG and HEAD"
- git log $PREV_TAG..HEAD --oneline --merges | grep -oP '#\K[0-9]+' > /tmp/pr_numbers.txt || echo ""
-
- # Fetch details for frontend update PRs
- > /tmp/frontend_prs.json
- while read -r PR_NUM; do
- if [ -n "$PR_NUM" ]; then
- PR_DATA=$(gh pr view $PR_NUM --json number,title,body 2>/dev/null || echo "")
- if [ -n "$PR_DATA" ]; then
- if echo "$PR_DATA" | jq -e '.title | test("^âŦī¸ Update music-assistant-frontend to [0-9]")' > /dev/null 2>&1; then
- echo "$PR_DATA" >> /tmp/frontend_prs.json
- fi
- fi
- fi
- done < /tmp/pr_numbers.txt
- fi
-
- # Extract contributors from server release notes (existing)
- echo "$RELEASE_BODY" | grep -oP '@[a-zA-Z0-9_-]+' | sort -u > "$CONTRIBUTORS_FILE" || true
-
- # Process each frontend PR and extract changes + contributors
- if [ -s /tmp/frontend_prs.json ]; then
- echo "## đ¨ Frontend Changes" > "$FRONTEND_FILE"
- echo "" >> "$FRONTEND_FILE"
-
- while IFS= read -r pr_json; do
- if [ -n "$pr_json" ]; then
- BODY=$(echo "$pr_json" | jq -r '.body')
-
- # Extract bullet points from the body, excluding:
- # - Section headers (đ)
- # - Dependabot dependency lines (starting with "Chore(deps")
- echo "$BODY" | grep -E '^[[:space:]]*[âĸ-]' | \
- grep -v 'đ' | \
- grep -viE '^[[:space:]]*[âĸ-][[:space:]]*Chore\(deps' | \
- head -20 >> "$FRONTEND_FILE" || true
-
- # Extract contributors from frontend PR body
- echo "$BODY" | grep -oP '@[a-zA-Z0-9_-]+' >> "$CONTRIBUTORS_FILE" || true
- fi
- done < /tmp/frontend_prs.json
-
- # Check if we actually found any changes
- if [ $(wc -l < "$FRONTEND_FILE") -gt 3 ]; then
- echo "â
Found frontend changes"
-
- # Deduplicate and sort contributors
- MERGED_CONTRIBUTORS=$(sort -u "$CONTRIBUTORS_FILE" | paste -sd ", " -)
-
- # Split release body into parts (before and after contributors section)
- echo "$RELEASE_BODY" > /tmp/original_notes.md
-
- # Find where the contributors section starts
- CONTRIB_LINE=$(grep -n "## :bow: Thanks to our contributors" /tmp/original_notes.md | head -1 | cut -d: -f1 || echo "")
-
- if [ -n "$CONTRIB_LINE" ]; then
- # Extract everything before contributors section
- head -n $((CONTRIB_LINE - 1)) /tmp/original_notes.md > /tmp/notes_before_contrib.md
-
- # Build new release notes with frontend changes and merged contributors
- {
- cat /tmp/notes_before_contrib.md
- echo ""
- cat "$FRONTEND_FILE"
- echo ""
- echo "## :bow: Thanks to our contributors"
- echo ""
- echo "Special thanks to the following contributors who helped with this release:"
- echo ""
- echo "$MERGED_CONTRIBUTORS"
- } > /tmp/updated_notes.md
- else
- # No contributors section found, just append frontend changes
- {
- cat /tmp/original_notes.md
- echo ""
- cat "$FRONTEND_FILE"
- } > /tmp/updated_notes.md
- fi
-
- # Update the release
- gh release edit "${{ inputs.version }}" --notes-file /tmp/updated_notes.md
-
- echo "â
Release notes updated with frontend changes and merged contributors"
- else
- echo "âšī¸ No frontend bullet points found"
- fi
- else
- echo "âšī¸ No frontend update PRs found in this release"
- fi
-
- # Cleanup
- rm -f "$FRONTEND_FILE" "$CONTRIBUTORS_FILE" /tmp/frontend_prs.json /tmp/pr_numbers.txt /tmp/updated_notes.md /tmp/original_notes.md /tmp/notes_before_contrib.md
+ previous-tag: ${{ steps.prev_version.outputs.prev_tag }}
+ branch: ${{ needs.validate-and-build.outputs.branch }}
+ channel: ${{ inputs.channel }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
- - name: Upload artifacts to release
+ - name: Create GitHub Release
+ id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.version }}
+ name: ${{ inputs.version }}
+ body: ${{ steps.generate_notes.outputs.release-notes }}
+ prerelease: ${{ needs.validate-and-build.outputs.is_prerelease }}
+ target_commitish: ${{ needs.validate-and-build.outputs.branch }}
files: dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Output release info
+ run: |
+ echo "â
Created release ${{ inputs.version }}"
+ echo "Release URL: ${{ steps.create_release.outputs.url }}"
+
pypi-publish:
name: Publish release to PyPI (stable releases only)
runs-on: ubuntu-latest
- needs:
- - validate-and-build
- - create-release
- if: ${{ inputs.channel == 'stable' }}
+ needs: create-release
+ if: inputs.channel == 'stable'
+ permissions:
+ id-token: write
steps:
- - name: Retrieve release distributions
+ - name: Download distributions
uses: actions/download-artifact@v5
with:
name: release-dists
path: dist/
- - name: Publish release to PyPI
- uses: pypa/gh-action-pypi-publish@v1.13.0
- with:
- user: __token__
- password: ${{ secrets.PYPI_TOKEN }}
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
build-and-push-container-image:
name: Build and push Music Assistant Server container to ghcr.io