Skip to content

Commit 768b5b5

Browse files
committed
now vendors a release-note generator
1 parent d99fddc commit 768b5b5

2 files changed

Lines changed: 200 additions & 22 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,36 +80,27 @@ jobs:
8080
8181
- name: Checkout
8282
uses: actions/checkout@v4
83+
with:
84+
fetch-depth: 0
8385

84-
- name: Extract changelog entry
85-
id: changelog
86-
uses: actions/github-script@v7
86+
- name: Setup Python
87+
uses: actions/setup-python@v5
8788
with:
88-
result-encoding: string
89-
script: |
90-
const tag = process.env.GITHUB_REF_NAME || '';
91-
const version = tag.startsWith('v') ? tag.slice(1) : tag;
92-
const fs = require('node:fs');
93-
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
94-
const heading = `## [${version}]`;
95-
const start = changelog.indexOf(heading);
96-
let entry;
97-
if (start === -1) {
98-
entry = `No changelog entry found for version ${version}.`;
99-
} else {
100-
const afterHeading = start + heading.length;
101-
const nextHeader = changelog.indexOf('\n## [', afterHeading);
102-
const sliceEnd = nextHeader === -1 ? changelog.length : nextHeader;
103-
entry = changelog.slice(start, sliceEnd).trim();
104-
}
105-
return entry;
89+
python-version: '3.12'
90+
91+
- name: Generate release changelog
92+
run: |
93+
python3 scripts/generate_release_changelog.py \
94+
--target-ref "${GITHUB_REF_NAME}" \
95+
--tag-name "${GITHUB_REF_NAME}" \
96+
--output "release-changelog.md"
10697
10798
- name: Create GitHub Release
10899
uses: softprops/action-gh-release@v2
109100
with:
110101
draft: false
111102
prerelease: false
112103
generate_release_notes: false
113-
body: ${{ steps.changelog.outputs.result }}
104+
body_path: release-changelog.md
114105
env:
115106
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import re
5+
import subprocess
6+
from pathlib import Path
7+
8+
PROJECT_DIR = Path(__file__).resolve().parent.parent
9+
DEFAULT_OUTPUT = PROJECT_DIR / "release-changelog.md"
10+
11+
12+
def _run_git(args):
13+
completed = subprocess.run(
14+
["git", *args],
15+
cwd=PROJECT_DIR,
16+
check=False,
17+
capture_output=True,
18+
text=True,
19+
)
20+
if completed.returncode != 0:
21+
return False, (completed.stderr or "").strip()
22+
return True, completed.stdout.strip()
23+
24+
25+
def _require_git(args, error_message):
26+
ok, output = _run_git(args)
27+
if not ok:
28+
print(error_message)
29+
print(output)
30+
raise RuntimeError(error_message)
31+
return output
32+
33+
34+
def _resolve_current_tag(target_ref):
35+
output = _require_git(
36+
["tag", "--points-at", target_ref, "--list", "v*", "--sort=-v:refname"],
37+
f"[release_changelog] failed to resolve tags for ref '{target_ref}'",
38+
)
39+
tags = [line.strip() for line in output.splitlines() if line.strip()]
40+
if tags:
41+
return tags[0]
42+
return ""
43+
44+
45+
def _resolve_previous_tag(target_ref):
46+
ok, output = _run_git(["describe", "--tags", "--abbrev=0", "--match", "v*", f"{target_ref}^"])
47+
if not ok:
48+
return ""
49+
return output.strip()
50+
51+
52+
def _resolve_commits(range_spec, max_commits):
53+
output = _require_git(
54+
[
55+
"log",
56+
"--no-merges",
57+
f"--max-count={max_commits}",
58+
"--pretty=format:%h%x09%s",
59+
range_spec,
60+
],
61+
"[release_changelog] failed to resolve commit log",
62+
)
63+
commits = []
64+
for line in output.splitlines():
65+
if not line.strip():
66+
continue
67+
parts = line.split("\t", 1)
68+
short_hash = parts[0].strip()
69+
subject = parts[1].strip() if len(parts) > 1 else ""
70+
commits.append((short_hash, subject))
71+
return commits
72+
73+
74+
CONVENTIONAL_SUBJECT_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(\([^)]+\))?!?:\s*(?P<body>.+)$")
75+
FIX_SUBJECT_RE = re.compile(r"\b(fix|fixes|fixed|bug|bugs|hotfix|patch|resolve|resolved)\b")
76+
FEATURE_SUBJECT_RE = re.compile(
77+
r"\b(feat|feature|features|add|adds|added|implement|implemented|introduce|introduced|support|supported|improve|improved|enhance|enhanced)\b"
78+
)
79+
80+
81+
def _classify_subject(subject):
82+
normalized = subject.strip().lower()
83+
conventional_match = CONVENTIONAL_SUBJECT_RE.match(normalized)
84+
if conventional_match:
85+
commit_type = conventional_match.group("type")
86+
if commit_type in {"feat", "feature"}:
87+
return "features"
88+
if commit_type == "fix":
89+
return "fixes"
90+
91+
if FIX_SUBJECT_RE.search(normalized):
92+
return "fixes"
93+
if FEATURE_SUBJECT_RE.search(normalized):
94+
return "features"
95+
return "other"
96+
97+
98+
def _append_section(lines, section_title, entries):
99+
lines.append(f"## {section_title}")
100+
if not entries:
101+
lines.append("- none")
102+
lines.append("")
103+
return
104+
105+
for short_hash, subject in entries:
106+
message = subject.strip() or "(no subject)"
107+
lines.append(f"- {message} (`{short_hash}`)")
108+
lines.append("")
109+
110+
111+
def _render_markdown(*, display_tag, commits):
112+
grouped = {"features": [], "fixes": [], "other": []}
113+
for short_hash, subject in commits:
114+
section_key = _classify_subject(subject)
115+
grouped[section_key].append((short_hash, subject))
116+
117+
lines = []
118+
lines.append(f"# Release Changelog: {display_tag}")
119+
lines.append("")
120+
_append_section(lines, "Features", grouped["features"])
121+
_append_section(lines, "Fixes", grouped["fixes"])
122+
_append_section(lines, "Other", grouped["other"])
123+
124+
return "\n".join(lines)
125+
126+
127+
def _parse_args():
128+
parser = argparse.ArgumentParser(
129+
description="Generate release-changelog.md from git changes."
130+
)
131+
parser.add_argument(
132+
"--target-ref",
133+
default="HEAD",
134+
help="Target git ref for this release (default: HEAD).",
135+
)
136+
parser.add_argument(
137+
"--tag-name",
138+
default="",
139+
help="Display tag name override (default: auto resolve from target ref).",
140+
)
141+
parser.add_argument(
142+
"--output",
143+
default=str(DEFAULT_OUTPUT),
144+
help="Output markdown file path.",
145+
)
146+
parser.add_argument(
147+
"--max-commits",
148+
type=int,
149+
default=100,
150+
help="Maximum number of commits to list in highlights.",
151+
)
152+
return parser.parse_args()
153+
154+
155+
def main():
156+
args = _parse_args()
157+
target_ref = args.target_ref.strip() or "HEAD"
158+
output_path = Path(args.output).resolve()
159+
160+
_require_git(
161+
["rev-parse", "--verify", f"{target_ref}^{{commit}}"],
162+
f"[release_changelog] target ref '{target_ref}' is not a valid commit",
163+
)
164+
165+
current_tag = _resolve_current_tag(target_ref)
166+
display_tag = args.tag_name.strip() or current_tag or target_ref
167+
previous_tag = _resolve_previous_tag(target_ref)
168+
range_spec = f"{previous_tag}..{target_ref}" if previous_tag else target_ref
169+
170+
commits = _resolve_commits(range_spec, max(args.max_commits, 1))
171+
172+
markdown = _render_markdown(
173+
display_tag=display_tag,
174+
commits=commits,
175+
)
176+
177+
output_path.parent.mkdir(parents=True, exist_ok=True)
178+
output_path.write_text(markdown, encoding="utf-8")
179+
print(f"[release_changelog] wrote {output_path}")
180+
return 0
181+
182+
183+
if __name__ == "__main__":
184+
try:
185+
raise SystemExit(main())
186+
except RuntimeError:
187+
raise SystemExit(1)

0 commit comments

Comments
 (0)