Skip to content

Commit c9a9d4b

Browse files
authored
feat(sdk): add reviewed release notes and align Python timeouts (#7)
* feat(release): publish reviewed changelog notes with each version * fix(client): align timeout defaults with Anthropic
1 parent f3f11f9 commit c9a9d4b

11 files changed

Lines changed: 594 additions & 6 deletions

File tree

‎.github/pull_request_template.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Commands and results:
2424
## Impact
2525

2626
- [ ] Public API or behavior changed
27+
- [ ] User-facing changes added to `CHANGELOG.md` under `Unreleased`, or not applicable (explain below)
2728
- [ ] Synchronous and asynchronous clients remain aligned
2829
- [ ] Generated API documentation updated
2930
- [ ] Contract fixtures updated

‎.github/scripts/release_notes.py‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
"""Validate changelogs and publish their approved version entry to GitHub."""
3+
4+
import argparse
5+
import datetime
6+
import json
7+
import os
8+
import re
9+
import subprocess
10+
import sys
11+
import tempfile
12+
import urllib.error
13+
import urllib.parse
14+
import urllib.request
15+
from pathlib import Path
16+
17+
VERSION = r"[0-9][0-9A-Za-z.!+-]*"
18+
HEADING = re.compile(rf"## \[(Unreleased|{VERSION})\](?: - (\d{{4}}-\d{{2}}-\d{{2}}))?")
19+
20+
21+
def outside_fences(text):
22+
fence = None
23+
for line in text.splitlines():
24+
marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
25+
if fence:
26+
if re.fullmatch(rf"\s{{0,3}}{re.escape(fence[0])}{{{len(fence)},}}\s*", line):
27+
fence = None
28+
yield ""
29+
elif marker:
30+
fence = marker[1]
31+
yield ""
32+
else:
33+
yield line
34+
if fence:
35+
raise ValueError("Unclosed Markdown code fence")
36+
37+
38+
def validate_entry(version, body):
39+
visible = "\n".join(outside_fences(body))
40+
if re.search(r"\b(?:TODO|TBD)\b|<[^>]*(?:describe|placeholder)[^>]*>", visible, re.I):
41+
raise ValueError(f"{version}: replace placeholder text with release notes")
42+
items = re.findall(r"^\s*[-*] (.+)$", visible, re.M)
43+
meaningful = [re.sub(r"[^\w]+", "", item).lower() for item in items]
44+
if (
45+
not meaningful
46+
or not all(meaningful)
47+
or any(item in {"placeholder", "pending", "comingsoon", "none", "na"} for item in meaningful)
48+
):
49+
raise ValueError(f"{version}: add at least one substantive changelog bullet")
50+
51+
52+
def parse_changelog(text):
53+
# Comments may guide authors but must not appear in the published notes.
54+
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
55+
if "<!--" in text or "-->" in text:
56+
raise ValueError("Unclosed or unmatched Markdown comment")
57+
sections = {}
58+
current = None
59+
for line, visible in zip(text.splitlines(), list(outside_fences(text))):
60+
if re.match(r"^##(?:\s|$)", visible):
61+
match = HEADING.fullmatch(line)
62+
if not match:
63+
raise ValueError(f"Expected '## [version]' or '## [version] - YYYY-MM-DD': {line}")
64+
current, date = match.groups()
65+
if current in sections:
66+
raise ValueError(f"Duplicate changelog section: {current}")
67+
if date:
68+
datetime.date.fromisoformat(date)
69+
if current == "Unreleased" and date:
70+
raise ValueError("Unreleased must not have a release date")
71+
sections[current] = []
72+
elif current is not None:
73+
sections[current].append(line)
74+
if not sections or next(iter(sections)) != "Unreleased":
75+
raise ValueError("The first version section must be '## [Unreleased]'")
76+
entries = {version: "\n".join(lines).strip() + "\n" for version, lines in sections.items()}
77+
for version, body in entries.items():
78+
if version != "Unreleased" or body.strip():
79+
validate_entry(version, body)
80+
return entries
81+
82+
83+
def extract_notes(text, version):
84+
if not re.fullmatch(VERSION, version):
85+
raise ValueError("Use a version without the v prefix, not Unreleased")
86+
entries = parse_changelog(text)
87+
if version not in entries:
88+
raise ValueError(f"CHANGELOG.md has no entry for {version}; include it in the release PR")
89+
return entries[version]
90+
91+
92+
def existing_release(repository, tag):
93+
url = f"https://api.github.com/repos/{repository}/releases/tags/{urllib.parse.quote(tag, safe='')}"
94+
request = urllib.request.Request(
95+
url,
96+
headers={
97+
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
98+
"Accept": "application/vnd.github+json",
99+
"User-Agent": "sdk-release-notes",
100+
},
101+
)
102+
try:
103+
with urllib.request.urlopen(request, timeout=30) as response:
104+
return json.load(response)
105+
except urllib.error.HTTPError as exc:
106+
if exc.code == 404:
107+
return None
108+
raise
109+
110+
111+
def publish_notes(version, commit_sha, prerelease, notes):
112+
repository = os.environ["GITHUB_REPOSITORY"]
113+
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
114+
raise ValueError("Invalid GITHUB_REPOSITORY")
115+
if not re.fullmatch(r"[0-9a-f]{40}", commit_sha):
116+
raise ValueError("commit_sha must be a full lowercase commit SHA")
117+
tag = f"v{version}"
118+
119+
def git(*args):
120+
return subprocess.check_output(["git", *args], text=True).strip()
121+
122+
if git("rev-parse", "HEAD") != commit_sha:
123+
raise ValueError("Release notes must be read from the approved commit")
124+
subprocess.run(["git", "fetch", "--force", "origin", f"refs/tags/{tag}:refs/tags/{tag}"], check=True)
125+
if git("cat-file", "-t", f"refs/tags/{tag}") != "tag":
126+
raise ValueError(f"{tag} must be an annotated release tag")
127+
if git("rev-parse", f"refs/tags/{tag}^{{commit}}") != commit_sha:
128+
raise ValueError(f"{tag} does not point to the approved commit")
129+
130+
existing = existing_release(repository, tag)
131+
if existing is not None:
132+
if (
133+
existing.get("tag_name") != tag
134+
or existing.get("name") != tag
135+
or existing.get("draft") is not False
136+
or existing.get("prerelease") is not prerelease
137+
or (existing.get("body") or "").strip() != notes.strip()
138+
):
139+
raise ValueError(f"Existing GitHub Release {tag} differs from the approved notes or metadata")
140+
url = existing["html_url"]
141+
print(f"Reusing GitHub Release: {url}")
142+
else:
143+
with tempfile.TemporaryDirectory() as directory:
144+
notes_file = Path(directory) / "release-notes.md"
145+
notes_file.write_text(notes, encoding="utf-8")
146+
command = [
147+
"gh",
148+
"release",
149+
"create",
150+
tag,
151+
"--repo",
152+
repository,
153+
"--verify-tag",
154+
"--target",
155+
commit_sha,
156+
"--title",
157+
tag,
158+
"--notes-file",
159+
str(notes_file),
160+
]
161+
if prerelease:
162+
command += ["--prerelease", "--latest=false"]
163+
url = subprocess.check_output(command, text=True).strip()
164+
print(f"Created GitHub Release: {url}")
165+
if os.environ.get("GITHUB_STEP_SUMMARY"):
166+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
167+
summary.write(f"\nGitHub Release: {url}\n")
168+
169+
170+
def main():
171+
parser = argparse.ArgumentParser(description=__doc__)
172+
parser.add_argument("command", choices=["check", "extract", "publish"])
173+
parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md"))
174+
parser.add_argument("--version")
175+
parser.add_argument("--output", type=Path)
176+
parser.add_argument("--commit-sha")
177+
parser.add_argument("--prerelease", choices=["true", "false"])
178+
args = parser.parse_args()
179+
text = args.changelog.read_text(encoding="utf-8")
180+
if args.command == "check":
181+
parse_changelog(text)
182+
print("Changelog is valid")
183+
return
184+
if not args.version:
185+
parser.error("--version is required")
186+
notes = extract_notes(text, args.version)
187+
if args.command == "extract":
188+
if args.output:
189+
args.output.write_text(notes, encoding="utf-8")
190+
else:
191+
print(notes, end="")
192+
else:
193+
if not args.commit_sha or args.prerelease is None:
194+
parser.error("publish requires --commit-sha and --prerelease")
195+
publish_notes(args.version, args.commit_sha, args.prerelease == "true", notes)
196+
197+
198+
if __name__ == "__main__":
199+
try:
200+
main()
201+
except (ValueError, KeyError, OSError, urllib.error.URLError, subprocess.CalledProcessError) as exc:
202+
sys.exit(f"Release notes: {exc}")

0 commit comments

Comments
 (0)