diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43ec43e0..77b82b23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,10 +96,99 @@ jobs: runs-on: ubuntu-latest needs: pypi-publish steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate release notes from commit messages + run: | + python3 - <<'EOF' + import json, os, re, subprocess, urllib.request + + tag = os.environ["TAG_NAME"] + repo = os.environ["REPO"] + token = os.environ["GH_TOKEN"] + + # Determine previous tag (version-sorted, so patch tags compare correctly) + all_tags = subprocess.run( + ["git", "tag", "--sort=-version:refname"], + capture_output=True, text=True, check=True, + ).stdout.strip().splitlines() + all_tags = [t for t in all_tags if t] + + try: + prev_tag = all_tags[all_tags.index(tag) + 1] + except (ValueError, IndexError): + prev_tag = None + + log_range = f"{prev_tag}..{tag}" if prev_tag else tag + subjects = subprocess.run( + ["git", "log", "--format=%s", log_range], + capture_output=True, text=True, check=True, + ).stdout.strip().splitlines() + + # Extract unique PR numbers preserving order (squash-merge: "(#NNN)") + seen, pr_numbers = set(), [] + for subject in subjects: + for num in re.findall(r'\(#(\d+)\)', subject): + if num not in seen: + pr_numbers.append(int(num)) + seen.add(num) + + def gh_get(url): + req = urllib.request.Request( + url, + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + # Category config mirrors .github/release.yml + label_to_category = { + "enhancement": "New Features 🎉", + "breaking-change": "Breaking Changes 🛠", + "bug": "Bug Fix 🐛", + } + ignore_labels = {"ignore-for-release"} + category_order = ["New Features 🎉", "Breaking Changes 🛠", "Bug Fix 🐛", "Other Changes"] + + prs_by_category: dict = {} + + for pr_num in pr_numbers: + pr = gh_get(f"https://api.github.com/repos/{repo}/pulls/{pr_num}") + labels = {lbl["name"] for lbl in pr.get("labels", [])} + if labels & ignore_labels: + continue + category = next( + (cat for lbl, cat in label_to_category.items() if lbl in labels), + "Other Changes", + ) + prs_by_category.setdefault(category, []).append(pr) + + lines = ["## What's Changed"] + for cat in category_order: + if cat not in prs_by_category: + continue + lines.append(f"### {cat}") + for pr in prs_by_category[cat]: + lines.append(f"* {pr['title']} by @{pr['user']['login']} in {pr['html_url']}") + + if prev_tag: + lines += ["", f"**Full Changelog**: https://github.com/{repo}/compare/{prev_tag}...{tag}"] + + notes = "\n".join(lines) + "\n" + print(notes) + open("release_notes.md", "w").write(notes) + EOF + env: + TAG_NAME: ${{ inputs.custom_version || github.ref_name }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + - name: Publish release on GitHub uses: softprops/action-gh-release@v2 with: - generate_release_notes: true + body_path: release_notes.md tag_name: ${{ inputs.custom_version || github.ref_name }} env: GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }}