tools/release.py: add generate-notes command and --notes-file flag

Add a generate-notes subcommand that fetches release notes from GitHub
and prints them to stdout, allowing the notes to be prepared and edited
separately from the cut workflow. The cut command gains a --notes-file
flag to read pre-prepared notes, skipping the fetch and editor steps.
When --notes-file is not provided, cut falls back to the existing
fetch-and-edit behavior.

Assisted-by: OpenCode (Claude Opus 4.6)
This commit is contained in:
Jonathan Lebon 2026-04-22 21:43:05 -04:00
parent 7fa7cd7993
commit 86b93659fe

View file

@ -27,15 +27,26 @@ def main():
bump_parser.set_defaults(
func=lambda args: bump_version(args.version, args.pr))
notes_parser = subparsers.add_parser(
"generate-notes", help="Generate release notes from GitHub")
notes_parser.add_argument(
"version", help="Version to generate notes for (e.g., 0.3.0)")
notes_parser.set_defaults(
func=lambda args: generate_notes(args.version))
cut_parser = subparsers.add_parser(
"cut", help="Cut a release")
cut_parser.add_argument(
"version", help="Version to release (e.g., 0.3.0)")
cut_parser.add_argument(
"--notes-file", default=None,
help="Path to release notes file (skips fetching and editing)")
cut_parser.add_argument(
"--no-push", action="store_true",
help="Prepare release without pushing to remote")
cut_parser.set_defaults(
func=lambda args: cut_release(args.version, args.no_push))
func=lambda args: cut_release(args.version, args.no_push,
args.notes_file))
args = parser.parse_args()
args.func(args)
@ -170,12 +181,19 @@ def compute_license_tag() -> tuple[list[str], str]:
return summary_lines, license_tag
def cut_release(version: str, no_push: bool):
def generate_notes(version: str):
"""Fetch auto-generated release notes from GitHub and print to stdout."""
tag = f"v{version}"
notes = draft_release_notes(tag)
sys.stdout.write(notes)
def cut_release(version: str, no_push: bool, notes_file: str | None):
"""Cut a release for chunkah."""
tag = f"v{version}"
source_tarball = f"{NAME}-{version}.tar.gz"
vendor_tarball = f"{NAME}-{version}-vendor.tar.gz"
notes_file = Path(f".release-notes-{version}.md")
saved_notes_file = Path(f".release-notes-{version}.md")
try:
if tag_exists(tag):
@ -194,21 +212,31 @@ def cut_release(version: str, no_push: bool):
step("Verifying version matches Cargo.toml...")
verify_version(version)
# Check for saved notes from a previous failed run
if notes_file.exists():
print(f"Found saved release notes from previous run: {notes_file}")
notes = notes_file.read_text()
if notes_file:
step(f"Reading release notes from {notes_file}...")
notes_path = Path(notes_file)
if not notes_path.exists():
die(f"Notes file not found: {notes_file}")
edited_notes = notes_path.read_text()
if not edited_notes.strip():
die("Release notes are empty, aborting")
else:
step("Fetching release notes from GitHub...")
notes = fetch_release_notes(tag)
# Check for saved notes from a previous failed run
if saved_notes_file.exists():
print(f"Found saved release notes from previous run: "
f"{saved_notes_file}")
notes = saved_notes_file.read_text()
else:
step("Fetching release notes from GitHub...")
notes = draft_release_notes(tag)
step("Opening editor for release notes...")
edited_notes = edit_notes(notes)
if not edited_notes.strip():
die("Release notes are empty, aborting")
step("Opening editor for release notes...")
edited_notes = edit_notes(notes)
if not edited_notes.strip():
die("Release notes are empty, aborting")
# Save notes immediately after editing
notes_file.write_text(edited_notes)
# Save notes immediately after editing
saved_notes_file.write_text(edited_notes)
step(f"Creating signed tag {tag}...")
create_signed_tag(tag, edited_notes)
@ -247,17 +275,19 @@ def cut_release(version: str, no_push: bool):
print()
print(f"Release {tag} published successfully!")
# Clean up notes file on success
if notes_file.exists():
notes_file.unlink()
# Clean up saved notes file on success
if saved_notes_file.exists():
saved_notes_file.unlink()
except subprocess.CalledProcessError as e:
if notes_file.exists():
print(f"Release notes saved to: {notes_file}", file=sys.stderr)
if saved_notes_file.exists():
print(f"Release notes saved to: {saved_notes_file}",
file=sys.stderr)
die(f"Command failed: {e.cmd}")
except Exception as e:
if notes_file.exists():
print(f"Release notes saved to: {notes_file}", file=sys.stderr)
if saved_notes_file.exists():
print(f"Release notes saved to: {saved_notes_file}",
file=sys.stderr)
die(str(e))
@ -304,11 +334,12 @@ def is_worktree_dirty() -> bool:
return run_output("git", "status", "--porcelain").strip() != ""
def fetch_release_notes(tag: str) -> str:
"""Fetch auto-generated release notes from GitHub."""
return run_output("gh", "api", "--method", "POST",
def draft_release_notes(tag: str) -> str:
"""Fetch release notes from GitHub and prepend the tag as a header."""
body = run_output("gh", "api", "--method", "POST",
"repos/coreos/chunkah/releases/generate-notes",
"-f", f"tag_name={tag}", "--jq", ".body")
return f"{tag}\n\n{body}"
def edit_notes(initial: str) -> str: