mirror of
https://github.com/leanprover/lean4.git
synced 2026-03-17 18:34:06 +00:00
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.
This commit is contained in:
@@ -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`).
|
||||
|
||||
145
script/release_notes.py
Executable file
145
script/release_notes.py
Executable file
@@ -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 <git-tag>\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()
|
||||
Reference in New Issue
Block a user