Final attempt to fix the release process
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 Oct 2025 13:52:03 +0000 (15:52 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 Oct 2025 13:52:03 +0000 (15:52 +0200)
.github/actions/generate-release-notes/README.md [new file with mode: 0644]
.github/actions/generate-release-notes/action.yml [new file with mode: 0644]
.github/actions/generate-release-notes/generate_notes.py [new file with mode: 0755]
.github/release-drafter.yml [deleted file]
.github/release-notes-config.yml [new file with mode: 0644]
.github/workflows/RELEASE_NOTES_GENERATION.md
.github/workflows/RELEASE_WORKFLOW_GUIDE.md
.github/workflows/release.yml

diff --git a/.github/actions/generate-release-notes/README.md b/.github/actions/generate-release-notes/README.md
new file mode 100644 (file)
index 0000000..9b44116
--- /dev/null
@@ -0,0 +1,163 @@
+# 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.
diff --git a/.github/actions/generate-release-notes/action.yml b/.github/actions/generate-release-notes/action.yml
new file mode 100644 (file)
index 0000000..cfe6f08
--- /dev/null
@@ -0,0 +1,187 @@
+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"
diff --git a/.github/actions/generate-release-notes/generate_notes.py b/.github/actions/generate-release-notes/generate_notes.py
new file mode 100755 (executable)
index 0000000..e83da71
--- /dev/null
@@ -0,0 +1,285 @@
+#!/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()
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
deleted file mode 100644 (file)
index c3f5652..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-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
diff --git a/.github/release-notes-config.yml b/.github/release-notes-config.yml
new file mode 100644 (file)
index 0000000..bd680bb
--- /dev/null
@@ -0,0 +1,57 @@
+# 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
index c57283879105caca5fa73aec3f60fa673b51e52f..b646f312a23195e7e3616bd17a6f9d09a256b46d 100644 (file)
@@ -8,13 +8,13 @@ The release workflow generates release notes **specific to each channel** by lev
 - **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
@@ -51,7 +51,7 @@ Release Drafter automatically:
 
 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)
@@ -82,25 +82,32 @@ The workflow then enhances these notes by:
 - **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
 
index 1c764d2dc7271cc8817f74f16a4611bffec5e50c..3984fe1483a6ff8b416c9253c4a83d6a9825700d 100644 (file)
@@ -144,12 +144,15 @@ These can be updated in the workflow file's `env` section.
 
 ## 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
 
index 8e96756e0c5b7b156437ffa38b2c6f35977f2fb7..b34d6306f53e293b7b94834aaea77e51d1c0d69a 100644 (file)
@@ -195,188 +195,50 @@ jobs:
             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