diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index 9e24e1b9cc..e51d2d0f33 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -4,34 +4,35 @@ 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: 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: | - python extra/release.py "${{ inputs.version }}" - - uses: EndBug/add-and-commit@v9 - name: Commit the changes - with: - message: 'Increment version numbers to ${{ inputs.version }}' - + - 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 }}" + build: runs-on: ubuntu-latest needs: increment_version steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: master - name: Set up Python uses: actions/setup-python@v4 with: @@ -52,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 @@ -68,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: @@ -87,5 +96,3 @@ jobs: path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 9deb0aa92f..887690bc51 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 8ad63cfe93..251b221090 100755 --- a/extra/release.py +++ b/extra/release.py @@ -2,165 +2,158 @@ """A utility script for automating the beets release process. """ -import argparse -import datetime -import os +from __future__ import annotations + import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +import click +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: + 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, + ) -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 = [ - ( - os.path.join(BASE, "beets", "__init__.py"), - [ - ( - r'__version__\s*=\s*[\'"]([0-9\.]+)[\'"]', - "__version__ = '{version}'", - ) - ], - ), +UpdateVersionCallable = Callable[[str, Version], str] +FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ ( - 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 + ), ), + (CHANGELOG, 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) - - # 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) - - # Insert the new version. - out_lines.append( - template.format( - version=version, - major=major, - minor=minor, - ) - + "\n" - ) - - 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) - - -def update_changelog(version: str): - # Generate bits to insert into changelog. - header_line = f"{version} (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" + +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() + + m = re.search(r'(?<=__version__ = ")[^"]+', contents) + assert m, "Current version not found in __init__.py" + current = parse(m.group()) + + if not value > current: + msg = f"version must be newer than {current}" + raise click.BadParameter(msg) + + return value + + +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() + + +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() ) - # 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 prep(args: argparse.Namespace): - # Version number bump. - datestamp() - bump_version(args.version) + + +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 + + +@cli.command() +@click.argument("version", type=Version, callback=validate_new_version) +def bump(version: Version) -> None: + """Bump the version in project files.""" + bump_version(version) + + +@cli.command() +def changelog(): + """Get the most recent version's changelog as Markdown.""" + print(changelog_as_markdown()) if __name__ == "__main__": - args = parser.parse_args() - prep(args) + cli()