From ff3bf918b0af3030d40a7b602c1ffbd4a594175f Mon Sep 17 00:00:00 2001 From: Serene-Arc <33189705+Serene-Arc@users.noreply.github.com> Date: Sat, 1 Jun 2024 14:24:45 +1000 Subject: [PATCH 1/6] Fix new heading in docs --- extra/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/release.py b/extra/release.py index 8ad63cfe9..781853b6c 100755 --- a/extra/release.py +++ b/extra/release.py @@ -103,7 +103,7 @@ def bump_version(version: str): def update_changelog(version: str): # Generate bits to insert into changelog. - header_line = f"{version} (in development)" + header_line = f"In Development" header = "\n\n" + header_line + "\n" + "-" * len(header_line) + "\n\n" header += ( "Changelog goes here! Please add your entry to the bottom of" From 591d052647588a6c816e0363a2687a56f91205cc Mon Sep 17 00:00:00 2001 From: Serene-Arc <33189705+Serene-Arc@users.noreply.github.com> Date: Sat, 1 Jun 2024 14:27:38 +1000 Subject: [PATCH 2/6] Fix formatting of replacement Black requires that there be double quotes instead of single --- extra/release.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extra/release.py b/extra/release.py index 781853b6c..273bf96ea 100755 --- a/extra/release.py +++ b/extra/release.py @@ -19,7 +19,7 @@ VERSION_LOCS = [ [ ( r'__version__\s*=\s*[\'"]([0-9\.]+)[\'"]', - "__version__ = '{version}'", + '__version__ = "{version}"', ) ], ), @@ -28,11 +28,11 @@ VERSION_LOCS = [ [ ( r'version\s*=\s*[\'"]([0-9\.]+)[\'"]', - "version = '{minor}'", + 'version = "{minor}"', ), ( r'release\s*=\s*[\'"]([0-9\.]+)[\'"]', - "release = '{version}'", + 'release = "{version}"', ), ], ), @@ -41,7 +41,7 @@ VERSION_LOCS = [ [ ( r'\s*version\s*=\s*[\'"]([0-9\.]+)[\'"]', - " version='{version}',", + ' version="{version}",', ) ], ), From c0ef37c46a7d59246b933afb992abe3a0c85808e Mon Sep 17 00:00:00 2001 From: Serene-Arc <33189705+Serene-Arc@users.noreply.github.com> Date: Sat, 1 Jun 2024 14:46:18 +1000 Subject: [PATCH 3/6] Fix error with build workflow using wrong ref Looking at the logs, it's using the ref before the one that increments the version in the previous action in the workflow. This code is from https://github.com/actions/checkout/issues/439#issuecomment-965968956 and supposedly fixes this by making it pull specifically from master, rather than the ref that the workflow was called on. --- .github/workflows/make_release.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index 9e24e1b9c..0fa54b301 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Version of the new release' + description: 'Version of the new release, just as a number with no prepended "v"' required: true jobs: @@ -32,6 +32,9 @@ jobs: needs: increment_version steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: master - name: Set up Python uses: actions/setup-python@v4 with: From be778b8da0107f350b0f9ca2bf7bdcc24c2398e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 4 Jun 2024 12:30:51 +0100 Subject: [PATCH 4/6] release.py: Use click for the CLI --- .github/workflows/make_release.yaml | 4 ++-- extra/release.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index 0fa54b301..6c7d13e17 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -21,7 +21,7 @@ jobs: - name: Run version script id: script run: | - python extra/release.py "${{ inputs.version }}" + python extra/release.py bump "${{ inputs.version }}" - uses: EndBug/add-and-commit@v9 name: Commit the changes with: @@ -91,4 +91,4 @@ jobs: - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - \ No newline at end of file + diff --git a/extra/release.py b/extra/release.py index 273bf96ea..3bd90f992 100755 --- a/extra/release.py +++ b/extra/release.py @@ -2,15 +2,14 @@ """A utility script for automating the beets release process. """ -import argparse import datetime import os import re +import click + BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CHANGELOG = os.path.join(BASE, "docs", "changelog.rst") -parser = argparse.ArgumentParser() -parser.add_argument("version", type=str) # Locations (filenames and patterns) of the version number. VERSION_LOCS = [ @@ -155,12 +154,18 @@ def datestamp(): f.write(line) -def prep(args: argparse.Namespace): +@click.group() +def cli(): + pass + + +@cli.command() +@click.argument("version") +def bump(version: str) -> None: # Version number bump. datestamp() - bump_version(args.version) + bump_version(version) if __name__ == "__main__": - args = parser.parse_args() - prep(args) + cli() From 3e5e1eca87449f98156b6943ae2bb58d7d9e2199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 4 Jun 2024 13:02:57 +0100 Subject: [PATCH 5/6] release.py: simplify bumping the version Additionally, update the 'in progress' header in the changelog: instead of using a specific version number, simply say 'Unreleased' since we do not know in advance what version will the changes be eventually released. This also simplifies latest changelog retrieval. --- docs/changelog.rst | 4 +- extra/release.py | 201 +++++++++++++++------------------------------ 2 files changed, 67 insertions(+), 138 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9deb0aa92..887690bc5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,8 +1,8 @@ Changelog ========= -2.0.1 (in development) ----------------------- +Unreleased +---------- Changelog goes here! Please add your entry to the bottom of one of the lists below! diff --git a/extra/release.py b/extra/release.py index 3bd90f992..adaf64528 100755 --- a/extra/release.py +++ b/extra/release.py @@ -2,156 +2,86 @@ """A utility script for automating the beets release process. """ -import datetime -import os +from __future__ import annotations + import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable import click +from packaging.version import Version, parse -BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CHANGELOG = os.path.join(BASE, "docs", "changelog.rst") +BASE = Path(__file__).parent.parent.absolute() +BEETS_INIT = BASE / "beets" / "__init__.py" -# Locations (filenames and patterns) of the version number. -VERSION_LOCS = [ + +def update_docs_config(text: str, new: Version) -> str: + new_major_minor = f"{new.major}.{new.minor}" + text = re.sub(r"(?<=version = )[^\n]+", f'"{new_major_minor}"', text) + return re.sub(r"(?<=release = )[^\n]+", f'"{new}"', text) + + +def update_changelog(text: str, new: Version) -> str: + new_header = f"{new} ({datetime.now(timezone.utc).date():%B %d, %Y})" + return re.sub( + # do not match if the new version is already present + r"\nUnreleased\n--+\n", + rf""" +Unreleased +---------- + +Changelog goes here! Please add your entry to the bottom of one of the lists below! + +{new_header} +{'-' * len(new_header)} +""", + text, + ) + + +UpdateVersionCallable = Callable[[str, Version], str] +FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ ( - os.path.join(BASE, "beets", "__init__.py"), - [ - ( - r'__version__\s*=\s*[\'"]([0-9\.]+)[\'"]', - '__version__ = "{version}"', - ) - ], - ), - ( - os.path.join(BASE, "docs", "conf.py"), - [ - ( - r'version\s*=\s*[\'"]([0-9\.]+)[\'"]', - 'version = "{minor}"', - ), - ( - r'release\s*=\s*[\'"]([0-9\.]+)[\'"]', - 'release = "{version}"', - ), - ], - ), - ( - os.path.join(BASE, "setup.py"), - [ - ( - r'\s*version\s*=\s*[\'"]([0-9\.]+)[\'"]', - ' version="{version}",', - ) - ], + BEETS_INIT, + lambda text, new: re.sub( + r"(?<=__version__ = )[^\n]+", f'"{new}"', text + ), ), + (BASE / "docs" / "changelog.rst", update_changelog), + (BASE / "docs" / "conf.py", update_docs_config), ] GITHUB_USER = "beetbox" GITHUB_REPO = "beets" -def bump_version(version: str): - """Update the version number in setup.py, docs config, changelog, - and root module. - """ - version_parts = [int(p) for p in version.split(".")] - assert len(version_parts) == 3, "invalid version number" - minor = "{}.{}".format(*version_parts) - major = "{}".format(*version_parts) +def validate_new_version( + ctx: click.Context, param: click.Argument, value: Version +) -> Version: + """Validate the version is newer than the current one.""" + with BEETS_INIT.open() as f: + contents = f.read() - # Replace the version each place where it lives. - for filename, locations in VERSION_LOCS: - # Read and transform the file. - out_lines = [] - with open(filename) as f: - found = False - for line in f: - for pattern, template in locations: - match = re.match(pattern, line) - if match: - # Check that this version is actually newer. - old_version = match.group(1) - old_parts = [int(p) for p in old_version.split(".")] - assert ( - version_parts > old_parts - ), "version must be newer than {}".format(old_version) + m = re.search(r'(?<=__version__ = ")[^"]+', contents) + assert m, "Current version not found in __init__.py" + current = parse(m.group()) - # Insert the new version. - out_lines.append( - template.format( - version=version, - major=major, - minor=minor, - ) - + "\n" - ) + if not value > current: + msg = f"version must be newer than {current}" + raise click.BadParameter(msg) - found = True - break - else: - # Normal line. - out_lines.append(line) - if not found: - print(f"No pattern found in {filename}") - # Write the file back. - with open(filename, "w") as f: - f.write("".join(out_lines)) - - update_changelog(version) + return value -def update_changelog(version: str): - # Generate bits to insert into changelog. - header_line = f"In Development" - header = "\n\n" + header_line + "\n" + "-" * len(header_line) + "\n\n" - header += ( - "Changelog goes here! Please add your entry to the bottom of" - " one of the lists below!\n" - ) - # Insert into the right place. - with open(CHANGELOG) as f: - contents = f.readlines() - - contents = [ - line - for line in contents - if not re.match(r"Changelog goes here!.*", line) - ] - contents = "".join(contents) - contents = re.sub("\n{3,}", "\n\n", contents) - - location = contents.find("\n\n") # First blank line. - contents = contents[:location] + header + contents[location:] - # Write back. - with open(CHANGELOG, "w") as f: - f.write(contents) - - -def datestamp(): - """Enter today's date as the release date in the changelog.""" - dt = datetime.datetime.now() - stamp = "({} {}, {})".format(dt.strftime("%B"), dt.day, dt.year) - marker = "(in development)" - - lines = [] - underline_length = None - with open(CHANGELOG) as f: - for line in f: - if marker in line: - # The header line. - line = line.replace(marker, stamp) - lines.append(line) - underline_length = len(line.strip()) - elif underline_length: - # This is the line after the header. Rewrite the dashes. - lines.append("-" * underline_length + "\n") - underline_length = None - else: - lines.append(line) - - with open(CHANGELOG, "w") as f: - for line in lines: - f.write(line) +def bump_version(new: Version) -> None: + """Update the version number in specified files.""" + for path, perform_update in FILENAME_AND_UPDATE_TEXT: + with path.open("r+") as f: + contents = f.read() + f.seek(0) + f.write(perform_update(contents, new)) + f.truncate() @click.group() @@ -160,10 +90,9 @@ def cli(): @cli.command() -@click.argument("version") -def bump(version: str) -> None: - # Version number bump. - datestamp() +@click.argument("version", type=Version, callback=validate_new_version) +def bump(version: Version) -> None: + """Bump the version in project files.""" bump_version(version) From d2a94c1cdd7ef24f18f1fc2fc2873e1210bc0a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 4 Jun 2024 16:20:48 +0100 Subject: [PATCH 6/6] release.py: Add changelog.rst conversion to markdown --- .github/workflows/make_release.yaml | 40 +++++++++-------- extra/release.py | 67 +++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index 6c7d13e17..e51d2d0f3 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -11,22 +11,20 @@ jobs: increment_version: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install pandoc - run: sudo apt update && sudo apt install pandoc -y - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.9" - - name: Run version script - id: script - run: | + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Run version script + id: script + run: | python extra/release.py bump "${{ inputs.version }}" - - uses: EndBug/add-and-commit@v9 - name: Commit the changes - with: - message: 'Increment version numbers to ${{ inputs.version }}' - + - uses: EndBug/add-and-commit@v9 + name: Commit the changes + with: + message: "Increment version numbers to ${{ inputs.version }}" + build: runs-on: ubuntu-latest needs: increment_version @@ -55,6 +53,14 @@ jobs: needs: build steps: - uses: actions/checkout@v4 + - name: Install pandoc + run: sudo apt update && sudo apt install pandoc -y + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Obtain the changelog + run: echo CHANGELOG="$(python ./extra/release.py changelog)" >> $GITHUB_ENV - name: Bump version and push tag id: tag_version uses: mathieudutour/github-tag-action@v6.1 @@ -71,7 +77,7 @@ jobs: with: tag: ${{ steps.tag_version.outputs.new_tag }} name: Release ${{ steps.tag_version.outputs.new_tag }} - body: "Check [here](https://beets.readthedocs.io/en/stable/changelog.html) for the latest changes." + body: ${{ env.CHANGELOG }} artifacts: dist/* publish_to_pypi: @@ -90,5 +96,3 @@ jobs: path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - diff --git a/extra/release.py b/extra/release.py index adaf64528..251b22109 100755 --- a/extra/release.py +++ b/extra/release.py @@ -5,6 +5,7 @@ from __future__ import annotations import re +import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Callable @@ -14,6 +15,13 @@ from packaging.version import Version, parse BASE = Path(__file__).parent.parent.absolute() BEETS_INIT = BASE / "beets" / "__init__.py" +CHANGELOG = BASE / "docs" / "changelog.rst" + +MD_CHANGELOG_SECTION_LIST = re.compile(r"- .+?(?=\n\n###|$)", re.DOTALL) +version_header = r"\d+\.\d+\.\d+ \([^)]+\)" +RST_LATEST_CHANGES = re.compile( + rf"{version_header}\n--+\s+(.+?)\n\n+{version_header}", re.DOTALL +) def update_docs_config(text: str, new: Version) -> str: @@ -48,13 +56,10 @@ FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ r"(?<=__version__ = )[^\n]+", f'"{new}"', text ), ), - (BASE / "docs" / "changelog.rst", update_changelog), + (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] -GITHUB_USER = "beetbox" -GITHUB_REPO = "beets" - def validate_new_version( ctx: click.Context, param: click.Argument, value: Version @@ -84,6 +89,54 @@ def bump_version(new: Version) -> None: f.truncate() +def rst2md(text: str) -> str: + """Use Pandoc to convert text from ReST to Markdown.""" + # Other backslashes with verbatim ranges. + rst = re.sub(r"(?<=[\s(])`([^`]+)`(?=[^_])", r"``\1``", text) + + # Bug numbers. + rst = re.sub(r":bug:`(\d+)`", r":bug: (#\1)", rst) + + # Users. + rst = re.sub(r":user:`(\w+)`", r"@\1", rst) + return ( + subprocess.check_output( + ["/usr/bin/pandoc", "--from=rst", "--to=gfm", "--wrap=none"], + input=rst.encode(), + ) + .decode() + .strip() + ) + + +def changelog_as_markdown() -> str: + """Get the latest changelog entry as hacked up Markdown.""" + with CHANGELOG.open() as f: + contents = f.read() + + m = RST_LATEST_CHANGES.search(contents) + rst = m.group(1) if m else "" + + # Convert with Pandoc. + md = rst2md(rst) + + # Make sections stand out + md = re.sub(r"^(\w.+?):$", r"### \1", md, flags=re.M) + + # Highlight plugin names + md = re.sub( + r"^- `/?plugins/(\w+)`:?", r"- Plugin **`\1`**:", md, flags=re.M + ) + + # Highlights command names. + md = re.sub(r"^- `(\w+)-cmd`:?", r"- Command **`\1`**:", md, flags=re.M) + + # sort list items alphabetically for each of the sections + return MD_CHANGELOG_SECTION_LIST.sub( + lambda m: "\n".join(sorted(m.group().splitlines())), md + ) + + @click.group() def cli(): pass @@ -96,5 +149,11 @@ def bump(version: Version) -> None: bump_version(version) +@cli.command() +def changelog(): + """Get the most recent version's changelog as Markdown.""" + print(changelog_as_markdown()) + + if __name__ == "__main__": cli()