Add a workflow for easily creating releases (#4952)

I humbly present a solution our lack of releases: a workflow that can be
triggered to automatically create one. This workflow builds the project,
creates a GitHub release, and publishes beets to PyPi, for a one-stop
solution.

@sampsyo this would make it much easier to create releases, as it
requires only one little interaction: going to the actions tab and
entering a version number. Once that's done, the workflow should take
care of the rest.

I have only tested the `build` job so far, since I can't do anything
about the pypi or do a release just to test, but the code is lifted from
other similar actions and should work fine.

It also requires one piece of setup. This is that PyPi must be set up
with a [trusted publisher](https://docs.pypi.org/trusted-publishers/) to
receive the new package. Once that's done, the process should go off
automatically.
This commit is contained in:
Serene 2024-05-30 08:57:02 +10:00 committed by GitHub
commit b88c09720c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 228 deletions

91
.github/workflows/make_release.yaml vendored Normal file
View file

@ -0,0 +1,91 @@
name: Make a Beets Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version of the new release'
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 }}'
build:
runs-on: ubuntu-latest
needs: increment_version
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.9"
- run: pip install build wheel sphinx
- name: Build a binary wheel and a source tarball
env:
TZ: UTC
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v3
with:
name: python-package-distributions
path: dist/
make_github_release:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
custom_tag: ${{ inputs.version }}
- name: Download all the dists
uses: actions/download-artifact@v3
with:
name: python-package-distributions
path: dist/
- name: Create a GitHub release
uses: ncipollo/release-action@v1
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."
artifacts: dist/*
publish_to_pypi:
runs-on: ubuntu-latest
needs: build
environment:
name: pypi
url: https://pypi.org/p/beets
permissions:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v3
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

View file

@ -2,31 +2,15 @@
"""A utility script for automating the beets release process.
"""
import argparse
import datetime
import os
import re
import subprocess
from contextlib import contextmanager
import click
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHANGELOG = os.path.join(BASE, "docs", "changelog.rst")
@contextmanager
def chdir(d):
"""A context manager that temporary changes the working directory."""
olddir = os.getcwd()
os.chdir(d)
yield
os.chdir(olddir)
@click.group()
def release():
pass
parser = argparse.ArgumentParser()
parser.add_argument("version", type=str)
# Locations (filenames and patterns) of the version number.
VERSION_LOCS = [
@ -67,7 +51,7 @@ GITHUB_USER = "beetbox"
GITHUB_REPO = "beets"
def bump_version(version):
def bump_version(version: str):
"""Update the version number in setup.py, docs config, changelog,
and root module.
"""
@ -105,134 +89,45 @@ def bump_version(version):
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!\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.read()
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)
@release.command()
@click.argument("version")
def bump(version):
"""Bump the version number."""
bump_version(version)
def get_latest_changelog():
"""Extract the first section of the changelog."""
started = False
lines = []
with open(CHANGELOG) as f:
for line in f:
if re.match(r"^--+$", line.strip()):
# Section boundary. Start or end.
if started:
# Remove last line, which is the header of the next
# section.
del lines[-1]
break
else:
started = True
elif started:
lines.append(line)
return "".join(lines).strip()
def rst2md(text):
"""Use Pandoc to convert text from ReST to Markdown."""
pandoc = subprocess.Popen(
["pandoc", "--from=rst", "--to=markdown", "--wrap=none"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = pandoc.communicate(text.encode("utf-8"))
md = stdout.decode("utf-8").strip()
# Fix up odd spacing in lists.
return re.sub(r"^- ", "- ", md, flags=re.M)
def changelog_as_markdown():
"""Get the latest changelog entry as hacked up Markdown."""
rst = get_latest_changelog()
# Replace plugin links with plugin names.
rst = re.sub(r":doc:`/plugins/(\w+)`", r"``\1``", rst)
# References with text.
rst = re.sub(r":ref:`([^<]+)(<[^>]+>)`", r"\1", rst)
# Other backslashes with verbatim ranges.
rst = re.sub(r"(\s)`([^`]+)`([^_])", r"\1``\2``\3", rst)
# Command links with command names.
rst = re.sub(r":ref:`(\w+)-cmd`", r"``\1``", rst)
# Bug numbers.
rst = re.sub(r":bug:`(\d+)`", r"#\1", rst)
# Users.
rst = re.sub(r":user:`(\w+)`", r"@\1", rst)
# Convert with Pandoc.
md = rst2md(rst)
# Restore escaped issue numbers.
md = re.sub(r"\\#(\d+)\b", r"#\1", md)
return md
@release.command()
def changelog():
"""Get the most recent version's changelog as Markdown."""
print(changelog_as_markdown())
def get_version(index=0):
"""Read the current version from the changelog."""
with open(CHANGELOG) as f:
cur_index = 0
for line in f:
match = re.search(r"^\d+\.\d+\.\d+", line)
if match:
if cur_index == index:
return match.group(0)
else:
cur_index += 1
@release.command()
def version():
"""Display the current version."""
print(get_version())
@release.command()
def datestamp():
"""Enter today's date as the release date in the changelog."""
dt = datetime.datetime.now()
@ -260,108 +155,12 @@ def datestamp():
f.write(line)
@release.command()
def prep():
"""Run all steps to prepare a release.
- Tag the commit.
- Build the sdist package.
- Generate the Markdown changelog to ``changelog.md``.
- Bump the version number to the next version.
"""
cur_version = get_version()
# Tag.
subprocess.check_call(["git", "tag", f"v{cur_version}"])
# Build.
with chdir(BASE):
subprocess.check_call(["python", "setup.py", "sdist"])
# Generate Markdown changelog.
cl = changelog_as_markdown()
with open(os.path.join(BASE, "changelog.md"), "w") as f:
f.write(cl)
def prep(args: argparse.Namespace):
# Version number bump.
# FIXME It should be possible to specify this as an argument.
version_parts = [int(n) for n in cur_version.split(".")]
version_parts[-1] += 1
next_version = ".".join(map(str, version_parts))
bump_version(next_version)
@release.command()
def publish():
"""Unleash a release unto the world.
- Push the tag to GitHub.
- Upload to PyPI.
"""
version = get_version(1)
# Push to GitHub.
with chdir(BASE):
subprocess.check_call(["git", "push"])
subprocess.check_call(["git", "push", "--tags"])
# Upload to PyPI.
path = os.path.join(BASE, "dist", f"beets-{version}.tar.gz")
subprocess.check_call(["twine", "upload", path])
@release.command()
def ghrelease():
"""Create a GitHub release using the `github-release` command-line
tool.
Reads the changelog to upload from `changelog.md`. Uploads the
tarball from the `dist` directory.
"""
version = get_version(1)
tag = "v" + version
# Load the changelog.
with open(os.path.join(BASE, "changelog.md")) as f:
cl_md = f.read()
# Create the release.
subprocess.check_call(
[
"github-release",
"release",
"-u",
GITHUB_USER,
"-r",
GITHUB_REPO,
"--tag",
tag,
"--name",
f"{GITHUB_REPO} {version}",
"--description",
cl_md,
]
)
# Attach the release tarball.
tarball = os.path.join(BASE, "dist", f"beets-{version}.tar.gz")
subprocess.check_call(
[
"github-release",
"upload",
"-u",
GITHUB_USER,
"-r",
GITHUB_REPO,
"--tag",
tag,
"--name",
os.path.basename(tarball),
"--file",
tarball,
]
)
datestamp()
bump_version(args.version)
if __name__ == "__main__":
release()
args = parser.parse_args()
prep(args)