Skip to content

Commit 4bb435d

Browse files
authored
ci: fix release notes for release branches (#5597)
When we cherry-pick bug fixes onto release branches, we sometimes need to modify the commits to work on a new base. When we do that, we change the commit so it's not the same as the original merge commit from the PR. The GitHub release-notes API excludes these commits from the notes it generates. This PR reproduces the same change notes format, but uses the commit messages to grab the PRs, even if they have been rebased.
1 parent b07572c commit 4bb435d

File tree

5 files changed

+202
-16
lines changed

5 files changed

+202
-16
lines changed

.github/workflows/approve-rc.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,7 @@ jobs:
5858
5959
if [ -n "${PREVIOUS_TAG}" ]; then
6060
echo "Generating release notes from ${PREVIOUS_TAG} to ${STABLE_TAG}"
61-
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
62-
-f tag_name="${STABLE_TAG}" \
63-
-f previous_tag_name="${PREVIOUS_TAG}" \
64-
--jq .body)
61+
NOTES=$(python ci/generate_release_notes.py ${PREVIOUS_TAG} ${STABLE_TAG})
6562
else
6663
echo "No previous tag found, using automatic generation"
6764
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \

.github/workflows/create-rc.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,7 @@ jobs:
6969
7070
if [ -n "${PREVIOUS_TAG}" ]; then
7171
echo "Generating release notes from ${PREVIOUS_TAG} to ${RC_TAG}"
72-
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
73-
-f tag_name="${RC_TAG}" \
74-
-f previous_tag_name="${PREVIOUS_TAG}" \
75-
--jq .body)
72+
NOTES=$(python ci/generate_release_notes.py ${PREVIOUS_TAG} ${RC_TAG})
7673
else
7774
echo "No previous tag found, using automatic generation"
7875
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \

.github/workflows/create-release-branch.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,7 @@ jobs:
6161
6262
if [ -n "${PREVIOUS_TAG}" ]; then
6363
echo "Generating release notes from ${PREVIOUS_TAG} to ${RC_TAG}"
64-
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
65-
-f tag_name="${RC_TAG}" \
66-
-f previous_tag_name="${PREVIOUS_TAG}" \
67-
--jq .body)
64+
NOTES=$(python ci/generate_release_notes.py ${PREVIOUS_TAG} ${RC_TAG})
6865
else
6966
echo "No previous tag found, using automatic generation"
7067
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \

.github/workflows/publish-beta.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,7 @@ jobs:
6565
6666
if [ -n "${RELEASE_NOTES_FROM}" ]; then
6767
echo "Generating release notes from ${RELEASE_NOTES_FROM} to ${BETA_TAG}"
68-
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
69-
-f tag_name="${BETA_TAG}" \
70-
-f previous_tag_name="${RELEASE_NOTES_FROM}" \
71-
--jq .body)
68+
NOTES=$(python ci/generate_release_notes.py ${RELEASE_NOTES_FROM} ${BETA_TAG})
7269
else
7370
echo "No release-root tag found, using automatic generation"
7471
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \

ci/generate_release_notes.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Usage: python ci/generate_release_notes.py <previous_tag> <current_tag>
4+
5+
Generates release notes by comparing two git tags.
6+
7+
This uses the configuration in .github/release.yml to format the release notes.
8+
9+
Format for line is:
10+
11+
* <Title> by @<Author> in <PR Link>
12+
13+
Example output:
14+
15+
* fix: dir namespace cloud storage path removes one subdir level by @jackye1995 in https://github.com/lance-format/lance/pull/5495
16+
* fix: panic unwrap on None in decoder.rs by @camilesing in https://github.com/lance-format/lance/pull/5424
17+
* fix: ensure trailing slash is normalized in rest adapter by @jackye1995 in https://github.com/lance-format/lance/pull/5500
18+
19+
**Full Changelog**: https://github.com/lance-format/lance/compare/v1.0.0...v1.0.1
20+
"""
21+
22+
import json
23+
import re
24+
import subprocess
25+
import sys
26+
from dataclasses import dataclass
27+
28+
import yaml
29+
30+
REPO = "lance-format/lance"
31+
REPO_URL = f"https://github.com/{REPO}"
32+
33+
34+
@dataclass
35+
class Category:
36+
title: str
37+
labels: list[str]
38+
39+
40+
@dataclass
41+
class ChangelogConfig:
42+
exclude_labels: list[str]
43+
categories: list[Category]
44+
45+
46+
@dataclass
47+
class PullRequest:
48+
number: int
49+
title: str
50+
author: str
51+
labels: list[str]
52+
53+
54+
def load_config(config_path: str = ".github/release.yml") -> ChangelogConfig:
55+
with open(config_path) as f:
56+
config = yaml.safe_load(f)
57+
58+
changelog = config.get("changelog", {})
59+
exclude_labels = changelog.get("exclude", {}).get("labels", [])
60+
61+
categories = []
62+
for cat in changelog.get("categories", []):
63+
categories.append(Category(title=cat["title"], labels=cat["labels"]))
64+
65+
return ChangelogConfig(exclude_labels=exclude_labels, categories=categories)
66+
67+
68+
def get_commits_between_tags(previous_tag: str, current_tag: str) -> list[str]:
69+
"""Get commit messages between two tags."""
70+
result = subprocess.run(
71+
["git", "log", f"{previous_tag}..{current_tag}", "--format=%s"],
72+
capture_output=True,
73+
text=True,
74+
check=True,
75+
)
76+
return result.stdout.strip().split("\n")
77+
78+
79+
def extract_pr_number(commit_message: str) -> int | None:
80+
"""Extract PR number from commit message like 'fix: something (#1234)'."""
81+
match = re.search(r"\(#(\d+)\)", commit_message)
82+
if match:
83+
return int(match.group(1))
84+
return None
85+
86+
87+
def get_pr_details(pr_number: int) -> PullRequest | None:
88+
"""Fetch PR details from GitHub API."""
89+
result = subprocess.run(
90+
[
91+
"gh",
92+
"pr",
93+
"view",
94+
str(pr_number),
95+
"--json",
96+
"title,author,labels",
97+
"--jq",
98+
"{title: .title, author: .author.login, labels: [.labels[].name]}",
99+
],
100+
capture_output=True,
101+
text=True,
102+
)
103+
if result.returncode != 0:
104+
return None
105+
106+
data = json.loads(result.stdout)
107+
return PullRequest(
108+
number=pr_number,
109+
title=data["title"],
110+
author=data["author"],
111+
labels=data["labels"],
112+
)
113+
114+
115+
def categorize_pr(pr: PullRequest, config: ChangelogConfig) -> str | None:
116+
"""Return category title for a PR, or None if excluded."""
117+
# Check exclusions
118+
for label in pr.labels:
119+
if label in config.exclude_labels:
120+
return None
121+
122+
# Find matching category
123+
for category in config.categories:
124+
if "*" in category.labels:
125+
return category.title
126+
for label in pr.labels:
127+
if label in category.labels:
128+
return category.title
129+
130+
return None
131+
132+
133+
def format_pr_entry(pr: PullRequest) -> str:
134+
"""Format a single PR entry."""
135+
return f"* {pr.title} by @{pr.author} in {REPO_URL}/pull/{pr.number}"
136+
137+
138+
def generate_release_notes(previous_tag: str, current_tag: str) -> str:
139+
config = load_config()
140+
commits = get_commits_between_tags(previous_tag, current_tag)
141+
142+
# Collect unique PR numbers
143+
pr_numbers = set()
144+
for commit in commits:
145+
pr_num = extract_pr_number(commit)
146+
if pr_num:
147+
pr_numbers.add(pr_num)
148+
149+
# Fetch PR details and categorize
150+
categorized: dict[str, list[PullRequest]] = {
151+
cat.title: [] for cat in config.categories
152+
}
153+
154+
for pr_num in sorted(pr_numbers):
155+
pr = get_pr_details(pr_num)
156+
if pr is None:
157+
print(f"Warning: Could not fetch PR #{pr_num}", file=sys.stderr)
158+
continue
159+
160+
category = categorize_pr(pr, config)
161+
if category:
162+
categorized[category].append(pr)
163+
164+
# Build output
165+
lines = [
166+
f"<!-- Release notes generated using configuration in .github/release.yml at {current_tag} -->",
167+
"",
168+
"## What's Changed",
169+
]
170+
171+
for category in config.categories:
172+
prs = categorized[category.title]
173+
if prs:
174+
lines.append(f"### {category.title}")
175+
for pr in sorted(prs, key=lambda p: p.number):
176+
lines.append(format_pr_entry(pr))
177+
178+
lines.append(
179+
f"\n**Full Changelog**: {REPO_URL}/compare/{previous_tag}...{current_tag}"
180+
)
181+
182+
return "\n".join(lines)
183+
184+
185+
def main():
186+
if len(sys.argv) != 3:
187+
print(__doc__)
188+
sys.exit(1)
189+
190+
previous_tag = sys.argv[1]
191+
current_tag = sys.argv[2]
192+
193+
notes = generate_release_notes(previous_tag, current_tag)
194+
print(notes)
195+
196+
197+
if __name__ == "__main__":
198+
main()

0 commit comments

Comments
 (0)