From 28a70987284f403fd2d17812d39ee3fbecaa5418 Mon Sep 17 00:00:00 2001 From: Kim Morrison Date: Sat, 4 Jan 2025 12:31:02 +1100 Subject: [PATCH] feat: add script for generating release notes (#6519) This PR adds a script to automatically generate release notes using the new `changelog-*` labels and "This PR ..." conventions. Usage: ``` script/release_notes.py v4.X.0 ``` where `v4.X.0` is the **previous** release, i.e. the script will process all commits *since* that tag. --- doc/dev/release_checklist.md | 27 ++----- script/release_notes.py | 145 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 19 deletions(-) create mode 100755 script/release_notes.py diff --git a/doc/dev/release_checklist.md b/doc/dev/release_checklist.md index 5541d5e2d51e..fc2ff4ba4ee9 100644 --- a/doc/dev/release_checklist.md +++ b/doc/dev/release_checklist.md @@ -5,11 +5,6 @@ See below for the checklist for release candidates. We'll use `v4.6.0` as the intended release version as a running example. -- One week before the planned release, ensure that - (1) someone has written the release notes and - (2) someone has written the first draft of the release blog post. - If there is any material in `./releases_drafts/` on the `releases/v4.6.0` branch, then the release notes are not done. - (See the section "Writing the release notes".) - `git checkout releases/v4.6.0` (This branch should already exist, from the release candidates.) - `git pull` @@ -86,7 +81,7 @@ We'll use `v4.6.0` as the intended release version as a running example. - Toolchain bump PR notes: - In addition to updating the `lean-toolchain` and `lakefile.lean`, in `.github/workflows/lean4checker.yml` update the line - `git checkout v4.6.0` to the appropriate tag. + `git checkout v4.6.0` to the appropriate tag. - Push the PR branch to the main Mathlib repository rather than a fork, or CI may not work reliably - Create and push the tag - Create a new branch from the tag, push it, and open a pull request against `stable`. @@ -139,16 +134,13 @@ We'll use `v4.7.0-rc1` as the intended release version in this example. git checkout -b releases/v4.7.0 ``` - In `RELEASES.md` replace `Development in progress` in the `v4.7.0` section with `Release notes to be written.` -- We will rely on automatically generated release notes for release candidates, - and the written release notes will be used for stable versions only. - It is essential to choose the nightly that will become the release candidate as early as possible, to avoid confusion. +- It is essential to choose the nightly that will become the release candidate as early as possible, to avoid confusion. - In `src/CMakeLists.txt`, - verify that you see `set(LEAN_VERSION_MINOR 7)` (for whichever `7` is appropriate); this should already have been updated when the development cycle began. - `set(LEAN_VERSION_IS_RELEASE 1)` (this should be a change; on `master` and nightly releases it is always `0`). - Commit your changes to `src/CMakeLists.txt`, and push. - `git tag v4.7.0-rc1` - `git push origin v4.7.0-rc1` -- Ping the FRO Zulip that release notes need to be written. The release notes do not block completing the rest of this checklist. - Now wait, while CI runs. - You can monitor this at `https://github.com/leanprover/lean4/actions/workflows/ci.yml`, looking for the `v4.7.0-rc1` tag. - This step can take up to an hour. @@ -248,15 +240,12 @@ Please read https://leanprover-community.github.io/contribute/tags_and_branches. # Writing the release notes -We are currently trying a system where release notes are compiled all at once from someone looking through the commit history. -The exact steps are a work in progress. -Here is the general idea: +Release notes are automatically generated from the commit history, using `script/release_notes.py`. -* The work is done right on the `releases/v4.6.0` branch sometime after it is created but before the stable release is made. - The release notes for `v4.6.0` will later be copied to `master` when we begin a new development cycle. -* There can be material for release notes entries in commit messages. -* There can also be pre-written entries in `./releases_drafts`, which should be all incorporated in the release notes and then deleted from the branch. +Run this as `script/release_notes.py v4.6.0`, where `v4.6.0` is the *previous* release version. This will generate output +for all commits since that tag. Note that there is output on both stderr, which should be manually reviewed, +and on stdout, which should be manually copied to `RELEASES.md`. + +There can also be pre-written entries in `./releases_drafts`, which should be all incorporated in the release notes and then deleted from the branch. See `./releases_drafts/README.md` for more information. -* The release notes should be written from a downstream expert user's point of view. -This section will be updated when the next release notes are written (for `v4.10.0`). diff --git a/script/release_notes.py b/script/release_notes.py new file mode 100755 index 000000000000..d8bb03ca5d92 --- /dev/null +++ b/script/release_notes.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +import sys +import re +import json +import requests +import subprocess +from collections import defaultdict +from git import Repo + +def get_commits_since_tag(repo, tag): + try: + tag_commit = repo.commit(tag) + commits = list(repo.iter_commits(f"{tag_commit.hexsha}..HEAD")) + return [ + (commit.hexsha, commit.message.splitlines()[0], commit.message) + for commit in commits + ] + except Exception as e: + sys.stderr.write(f"Error retrieving commits: {e}\n") + sys.exit(1) + +def check_pr_number(first_line): + match = re.search(r"\(\#(\d+)\)$", first_line) + if match: + return int(match.group(1)) + return None + +def fetch_pr_labels(pr_number): + try: + # Use gh CLI to fetch PR details + result = subprocess.run([ + "gh", "api", f"repos/leanprover/lean4/pulls/{pr_number}" + ], capture_output=True, text=True, check=True) + pr_data = result.stdout + pr_json = json.loads(pr_data) + return [label["name"] for label in pr_json.get("labels", [])] + except subprocess.CalledProcessError as e: + sys.stderr.write(f"Failed to fetch PR #{pr_number} using gh: {e.stderr}\n") + return [] + +def format_section_title(label): + title = label.replace("changelog-", "").capitalize() + if title == "Doc": + return "Documentation" + elif title == "Pp": + return "Pretty Printing" + return title + +def sort_sections_order(): + return [ + "Language", + "Library", + "Compiler", + "Pretty Printing", + "Documentation", + "Server", + "Lake", + "Other", + "Uncategorised" + ] + +def format_markdown_description(pr_number, description): + link = f"[#{pr_number}](https://github.com/leanprover/lean4/pull/{pr_number})" + return f"{link} {description}" + +def main(): + if len(sys.argv) != 2: + sys.stderr.write("Usage: script.py \n") + sys.exit(1) + + tag = sys.argv[1] + try: + repo = Repo(".") + except Exception as e: + sys.stderr.write(f"Error opening Git repository: {e}\n") + sys.exit(1) + + commits = get_commits_since_tag(repo, tag) + + sys.stderr.write(f"Found {len(commits)} commits since tag {tag}:\n") + for commit_hash, first_line, _ in commits: + sys.stderr.write(f"- {commit_hash}: {first_line}\n") + + changelog = defaultdict(list) + + for commit_hash, first_line, full_message in commits: + # Skip commits with the specific first lines + if first_line == "chore: update stage0" or first_line.startswith("chore: CI: bump "): + continue + + pr_number = check_pr_number(first_line) + + if not pr_number: + sys.stderr.write(f"No PR number found in {first_line}\n") + continue + + # Remove the first line from the full_message for further processing + body = full_message[len(first_line):].strip() + + paragraphs = body.split('\n\n') + second_paragraph = paragraphs[0] if len(paragraphs) > 0 else "" + + labels = fetch_pr_labels(pr_number) + + # Skip entries with the "changelog-no" label + if "changelog-no" in labels: + continue + + report_errors = first_line.startswith("feat:") or first_line.startswith("fix:") + + if not second_paragraph.startswith("This PR "): + if report_errors: + sys.stderr.write(f"No PR description found in commit:\n{commit_hash}\n{first_line}\n{body}\n\n") + fallback_description = re.sub(r":$", "", first_line.split(" ", 1)[1]).rsplit(" (#", 1)[0] + markdown_description = format_markdown_description(pr_number, fallback_description) + else: + continue + else: + markdown_description = format_markdown_description(pr_number, second_paragraph.replace("This PR ", "")) + + changelog_labels = [label for label in labels if label.startswith("changelog-")] + if len(changelog_labels) > 1: + sys.stderr.write(f"Warning: Multiple changelog-* labels found for PR #{pr_number}: {changelog_labels}\n") + + if not changelog_labels: + if report_errors: + sys.stderr.write(f"Warning: No changelog-* label found for PR #{pr_number}\n") + else: + continue + + for label in changelog_labels: + changelog[label].append((pr_number, markdown_description)) + + section_order = sort_sections_order() + sorted_changelog = sorted(changelog.items(), key=lambda item: section_order.index(format_section_title(item[0])) if format_section_title(item[0]) in section_order else len(section_order)) + + for label, entries in sorted_changelog: + section_title = format_section_title(label) if label != "Uncategorised" else "Uncategorised" + print(f"## {section_title}\n") + for _, entry in sorted(entries, key=lambda x: x[0]): + print(f"* {entry}\n") + +if __name__ == "__main__": + main()