500 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
			
		
		
	
	
			500 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
| #!/usr/bin/env python
 | |
| # -*- coding: utf-8 -*-
 | |
| # Copyright 2020 The Matrix.org Foundation C.I.C.
 | |
| #
 | |
| # Licensed under the Apache License, Version 2.0 (the "License");
 | |
| # you may not use this file except in compliance with the License.
 | |
| # You may obtain a copy of the License at
 | |
| #
 | |
| #     http://www.apache.org/licenses/LICENSE-2.0
 | |
| #
 | |
| # Unless required by applicable law or agreed to in writing, software
 | |
| # distributed under the License is distributed on an "AS IS" BASIS,
 | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| # See the License for the specific language governing permissions and
 | |
| # limitations under the License.
 | |
| 
 | |
| """An interactive script for doing a release. See `cli()` below.
 | |
| """
 | |
| 
 | |
| import re
 | |
| import subprocess
 | |
| import sys
 | |
| import urllib.request
 | |
| from os import path
 | |
| from tempfile import TemporaryDirectory
 | |
| from typing import List, Optional, Tuple
 | |
| 
 | |
| import attr
 | |
| import click
 | |
| import commonmark
 | |
| import git
 | |
| import redbaron
 | |
| from click.exceptions import ClickException
 | |
| from github import Github
 | |
| from packaging import version
 | |
| 
 | |
| 
 | |
| @click.group()
 | |
| def cli():
 | |
|     """An interactive script to walk through the parts of creating a release.
 | |
| 
 | |
|     Requires the dev dependencies be installed, which can be done via:
 | |
| 
 | |
|         pip install -e .[dev]
 | |
| 
 | |
|     Then to use:
 | |
| 
 | |
|         ./scripts-dev/release.py prepare
 | |
| 
 | |
|         # ... ask others to look at the changelog ...
 | |
| 
 | |
|         ./scripts-dev/release.py tag
 | |
| 
 | |
|         # ... wait for asssets to build ...
 | |
| 
 | |
|         ./scripts-dev/release.py publish
 | |
|         ./scripts-dev/release.py upload
 | |
| 
 | |
|     If the env var GH_TOKEN (or GITHUB_TOKEN) is set, or passed into the
 | |
|     `tag`/`publish` command, then a new draft release will be created/published.
 | |
|     """
 | |
| 
 | |
| 
 | |
| @cli.command()
 | |
| def prepare():
 | |
|     """Do the initial stages of creating a release, including creating release
 | |
|     branch, updating changelog and pushing to GitHub.
 | |
|     """
 | |
| 
 | |
|     # Make sure we're in a git repo.
 | |
|     try:
 | |
|         repo = git.Repo()
 | |
|     except git.InvalidGitRepositoryError:
 | |
|         raise click.ClickException("Not in Synapse repo.")
 | |
| 
 | |
|     if repo.is_dirty():
 | |
|         raise click.ClickException("Uncommitted changes exist.")
 | |
| 
 | |
|     click.secho("Updating git repo...")
 | |
|     repo.remote().fetch()
 | |
| 
 | |
|     # Get the current version and AST from root Synapse module.
 | |
|     current_version, parsed_synapse_ast, version_node = parse_version_from_module()
 | |
| 
 | |
|     # Figure out what sort of release we're doing and calcuate the new version.
 | |
|     rc = click.confirm("RC", default=True)
 | |
|     if current_version.pre:
 | |
|         # If the current version is an RC we don't need to bump any of the
 | |
|         # version numbers (other than the RC number).
 | |
|         if rc:
 | |
|             new_version = "{}.{}.{}rc{}".format(
 | |
|                 current_version.major,
 | |
|                 current_version.minor,
 | |
|                 current_version.micro,
 | |
|                 current_version.pre[1] + 1,
 | |
|             )
 | |
|         else:
 | |
|             new_version = "{}.{}.{}".format(
 | |
|                 current_version.major,
 | |
|                 current_version.minor,
 | |
|                 current_version.micro,
 | |
|             )
 | |
|     else:
 | |
|         # If this is a new release cycle then we need to know if it's a minor
 | |
|         # or a patch version bump.
 | |
|         release_type = click.prompt(
 | |
|             "Release type",
 | |
|             type=click.Choice(("minor", "patch")),
 | |
|             show_choices=True,
 | |
|             default="minor",
 | |
|         )
 | |
| 
 | |
|         if release_type == "minor":
 | |
|             if rc:
 | |
|                 new_version = "{}.{}.{}rc1".format(
 | |
|                     current_version.major,
 | |
|                     current_version.minor + 1,
 | |
|                     0,
 | |
|                 )
 | |
|             else:
 | |
|                 new_version = "{}.{}.{}".format(
 | |
|                     current_version.major,
 | |
|                     current_version.minor + 1,
 | |
|                     0,
 | |
|                 )
 | |
|         else:
 | |
|             if rc:
 | |
|                 new_version = "{}.{}.{}rc1".format(
 | |
|                     current_version.major,
 | |
|                     current_version.minor,
 | |
|                     current_version.micro + 1,
 | |
|                 )
 | |
|             else:
 | |
|                 new_version = "{}.{}.{}".format(
 | |
|                     current_version.major,
 | |
|                     current_version.minor,
 | |
|                     current_version.micro + 1,
 | |
|                 )
 | |
| 
 | |
|     # Confirm the calculated version is OK.
 | |
|     if not click.confirm(f"Create new version: {new_version}?", default=True):
 | |
|         click.get_current_context().abort()
 | |
| 
 | |
|     # Switch to the release branch.
 | |
|     parsed_new_version = version.parse(new_version)
 | |
| 
 | |
|     # We assume for debian changelogs that we only do RCs or full releases.
 | |
|     assert not parsed_new_version.is_devrelease
 | |
|     assert not parsed_new_version.is_postrelease
 | |
| 
 | |
|     release_branch_name = (
 | |
|         f"release-v{parsed_new_version.major}.{parsed_new_version.minor}"
 | |
|     )
 | |
|     release_branch = find_ref(repo, release_branch_name)
 | |
|     if release_branch:
 | |
|         if release_branch.is_remote():
 | |
|             # If the release branch only exists on the remote we check it out
 | |
|             # locally.
 | |
|             repo.git.checkout(release_branch_name)
 | |
|             release_branch = repo.active_branch
 | |
|     else:
 | |
|         # If a branch doesn't exist we create one. We ask which one branch it
 | |
|         # should be based off, defaulting to sensible values depending on the
 | |
|         # release type.
 | |
|         if current_version.is_prerelease:
 | |
|             default = release_branch_name
 | |
|         elif release_type == "minor":
 | |
|             default = "develop"
 | |
|         else:
 | |
|             default = "master"
 | |
| 
 | |
|         branch_name = click.prompt(
 | |
|             "Which branch should the release be based on?", default=default
 | |
|         )
 | |
| 
 | |
|         base_branch = find_ref(repo, branch_name)
 | |
|         if not base_branch:
 | |
|             print(f"Could not find base branch {branch_name}!")
 | |
|             click.get_current_context().abort()
 | |
| 
 | |
|         # Check out the base branch and ensure it's up to date
 | |
|         repo.head.reference = base_branch
 | |
|         repo.head.reset(index=True, working_tree=True)
 | |
|         if not base_branch.is_remote():
 | |
|             update_branch(repo)
 | |
| 
 | |
|         # Create the new release branch
 | |
|         release_branch = repo.create_head(release_branch_name, commit=base_branch)
 | |
| 
 | |
|     # Switch to the release branch and ensure its up to date.
 | |
|     repo.git.checkout(release_branch_name)
 | |
|     update_branch(repo)
 | |
| 
 | |
|     # Update the `__version__` variable and write it back to the file.
 | |
|     version_node.value = '"' + new_version + '"'
 | |
|     with open("synapse/__init__.py", "w") as f:
 | |
|         f.write(parsed_synapse_ast.dumps())
 | |
| 
 | |
|     # Generate changelogs
 | |
|     subprocess.run("python3 -m towncrier", shell=True)
 | |
| 
 | |
|     # Generate debian changelogs
 | |
|     if parsed_new_version.pre is not None:
 | |
|         # If this is an RC then we need to coerce the version string to match
 | |
|         # Debian norms, e.g. 1.39.0rc2 gets converted to 1.39.0~rc2.
 | |
|         base_ver = parsed_new_version.base_version
 | |
|         pre_type, pre_num = parsed_new_version.pre
 | |
|         debian_version = f"{base_ver}~{pre_type}{pre_num}"
 | |
|     else:
 | |
|         debian_version = new_version
 | |
| 
 | |
|     subprocess.run(
 | |
|         f'dch -M -v {debian_version} "New synapse release {debian_version}."',
 | |
|         shell=True,
 | |
|     )
 | |
|     subprocess.run('dch -M -r -D stable ""', shell=True)
 | |
| 
 | |
|     # Show the user the changes and ask if they want to edit the change log.
 | |
|     repo.git.add("-u")
 | |
|     subprocess.run("git diff --cached", shell=True)
 | |
| 
 | |
|     if click.confirm("Edit changelog?", default=False):
 | |
|         click.edit(filename="CHANGES.md")
 | |
| 
 | |
|     # Commit the changes.
 | |
|     repo.git.add("-u")
 | |
|     repo.git.commit(f"-m {new_version}")
 | |
| 
 | |
|     # We give the option to bail here in case the user wants to make sure things
 | |
|     # are OK before pushing.
 | |
|     if not click.confirm("Push branch to github?", default=True):
 | |
|         print("")
 | |
|         print("Run when ready to push:")
 | |
|         print("")
 | |
|         print(f"\tgit push -u {repo.remote().name} {repo.active_branch.name}")
 | |
|         print("")
 | |
|         sys.exit(0)
 | |
| 
 | |
|     # Otherwise, push and open the changelog in the browser.
 | |
|     repo.git.push("-u", repo.remote().name, repo.active_branch.name)
 | |
| 
 | |
|     click.launch(
 | |
|         f"https://github.com/matrix-org/synapse/blob/{repo.active_branch.name}/CHANGES.md"
 | |
|     )
 | |
| 
 | |
| 
 | |
| @cli.command()
 | |
| @click.option("--gh-token", envvar=["GH_TOKEN", "GITHUB_TOKEN"])
 | |
| def tag(gh_token: Optional[str]):
 | |
|     """Tags the release and generates a draft GitHub release"""
 | |
| 
 | |
|     # Make sure we're in a git repo.
 | |
|     try:
 | |
|         repo = git.Repo()
 | |
|     except git.InvalidGitRepositoryError:
 | |
|         raise click.ClickException("Not in Synapse repo.")
 | |
| 
 | |
|     if repo.is_dirty():
 | |
|         raise click.ClickException("Uncommitted changes exist.")
 | |
| 
 | |
|     click.secho("Updating git repo...")
 | |
|     repo.remote().fetch()
 | |
| 
 | |
|     # Find out the version and tag name.
 | |
|     current_version, _, _ = parse_version_from_module()
 | |
|     tag_name = f"v{current_version}"
 | |
| 
 | |
|     # Check we haven't released this version.
 | |
|     if tag_name in repo.tags:
 | |
|         raise click.ClickException(f"Tag {tag_name} already exists!\n")
 | |
| 
 | |
|     # Get the appropriate changelogs and tag.
 | |
|     changes = get_changes_for_version(current_version)
 | |
| 
 | |
|     click.echo_via_pager(changes)
 | |
|     if click.confirm("Edit text?", default=False):
 | |
|         changes = click.edit(changes, require_save=False)
 | |
| 
 | |
|     repo.create_tag(tag_name, message=changes)
 | |
| 
 | |
|     if not click.confirm("Push tag to GitHub?", default=True):
 | |
|         print("")
 | |
|         print("Run when ready to push:")
 | |
|         print("")
 | |
|         print(f"\tgit push {repo.remote().name} tag {current_version}")
 | |
|         print("")
 | |
|         return
 | |
| 
 | |
|     repo.git.push(repo.remote().name, "tag", tag_name)
 | |
| 
 | |
|     # If no token was given, we bail here
 | |
|     if not gh_token:
 | |
|         click.launch(f"https://github.com/matrix-org/synapse/releases/edit/{tag_name}")
 | |
|         return
 | |
| 
 | |
|     # Create a new draft release
 | |
|     gh = Github(gh_token)
 | |
|     gh_repo = gh.get_repo("matrix-org/synapse")
 | |
|     release = gh_repo.create_git_release(
 | |
|         tag=tag_name,
 | |
|         name=tag_name,
 | |
|         message=changes,
 | |
|         draft=True,
 | |
|         prerelease=current_version.is_prerelease,
 | |
|     )
 | |
| 
 | |
|     # Open the release and the actions where we are building the assets.
 | |
|     click.launch(release.html_url)
 | |
|     click.launch(
 | |
|         f"https://github.com/matrix-org/synapse/actions?query=branch%3A{tag_name}"
 | |
|     )
 | |
| 
 | |
|     click.echo("Wait for release assets to be built")
 | |
| 
 | |
| 
 | |
| @cli.command()
 | |
| @click.option("--gh-token", envvar=["GH_TOKEN", "GITHUB_TOKEN"], required=True)
 | |
| def publish(gh_token: str):
 | |
|     """Publish release."""
 | |
| 
 | |
|     # Make sure we're in a git repo.
 | |
|     try:
 | |
|         repo = git.Repo()
 | |
|     except git.InvalidGitRepositoryError:
 | |
|         raise click.ClickException("Not in Synapse repo.")
 | |
| 
 | |
|     if repo.is_dirty():
 | |
|         raise click.ClickException("Uncommitted changes exist.")
 | |
| 
 | |
|     current_version, _, _ = parse_version_from_module()
 | |
|     tag_name = f"v{current_version}"
 | |
| 
 | |
|     if not click.confirm(f"Publish {tag_name}?", default=True):
 | |
|         return
 | |
| 
 | |
|     # Publish the draft release
 | |
|     gh = Github(gh_token)
 | |
|     gh_repo = gh.get_repo("matrix-org/synapse")
 | |
|     for release in gh_repo.get_releases():
 | |
|         if release.title == tag_name:
 | |
|             break
 | |
|     else:
 | |
|         raise ClickException(f"Failed to find GitHub release for {tag_name}")
 | |
| 
 | |
|     assert release.title == tag_name
 | |
| 
 | |
|     if not release.draft:
 | |
|         click.echo("Release already published.")
 | |
|         return
 | |
| 
 | |
|     release = release.update_release(
 | |
|         name=release.title,
 | |
|         message=release.body,
 | |
|         tag_name=release.tag_name,
 | |
|         prerelease=release.prerelease,
 | |
|         draft=False,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @cli.command()
 | |
| def upload():
 | |
|     """Upload release to pypi."""
 | |
| 
 | |
|     current_version, _, _ = parse_version_from_module()
 | |
|     tag_name = f"v{current_version}"
 | |
| 
 | |
|     pypi_asset_names = [
 | |
|         f"matrix_synapse-{current_version}-py3-none-any.whl",
 | |
|         f"matrix-synapse-{current_version}.tar.gz",
 | |
|     ]
 | |
| 
 | |
|     with TemporaryDirectory(prefix=f"synapse_upload_{tag_name}_") as tmpdir:
 | |
|         for name in pypi_asset_names:
 | |
|             filename = path.join(tmpdir, name)
 | |
|             url = f"https://github.com/matrix-org/synapse/releases/download/{tag_name}/{name}"
 | |
| 
 | |
|             click.echo(f"Downloading {name} into {filename}")
 | |
|             urllib.request.urlretrieve(url, filename=filename)
 | |
| 
 | |
|         if click.confirm("Upload to PyPI?", default=True):
 | |
|             subprocess.run("twine upload *", shell=True, cwd=tmpdir)
 | |
| 
 | |
|     click.echo(
 | |
|         f"Done! Remember to merge the tag {tag_name} into the appropriate branches"
 | |
|     )
 | |
| 
 | |
| 
 | |
| def parse_version_from_module() -> Tuple[
 | |
|     version.Version, redbaron.RedBaron, redbaron.Node
 | |
| ]:
 | |
|     # Parse the AST and load the `__version__` node so that we can edit it
 | |
|     # later.
 | |
|     with open("synapse/__init__.py") as f:
 | |
|         red = redbaron.RedBaron(f.read())
 | |
| 
 | |
|     version_node = None
 | |
|     for node in red:
 | |
|         if node.type != "assignment":
 | |
|             continue
 | |
| 
 | |
|         if node.target.type != "name":
 | |
|             continue
 | |
| 
 | |
|         if node.target.value != "__version__":
 | |
|             continue
 | |
| 
 | |
|         version_node = node
 | |
|         break
 | |
| 
 | |
|     if not version_node:
 | |
|         print("Failed to find '__version__' definition in synapse/__init__.py")
 | |
|         sys.exit(1)
 | |
| 
 | |
|     # Parse the current version.
 | |
|     current_version = version.parse(version_node.value.value.strip('"'))
 | |
|     assert isinstance(current_version, version.Version)
 | |
| 
 | |
|     return current_version, red, version_node
 | |
| 
 | |
| 
 | |
| def find_ref(repo: git.Repo, ref_name: str) -> Optional[git.HEAD]:
 | |
|     """Find the branch/ref, looking first locally then in the remote."""
 | |
|     if ref_name in repo.refs:
 | |
|         return repo.refs[ref_name]
 | |
|     elif ref_name in repo.remote().refs:
 | |
|         return repo.remote().refs[ref_name]
 | |
|     else:
 | |
|         return None
 | |
| 
 | |
| 
 | |
| def update_branch(repo: git.Repo):
 | |
|     """Ensure branch is up to date if it has a remote"""
 | |
|     if repo.active_branch.tracking_branch():
 | |
|         repo.git.merge(repo.active_branch.tracking_branch().name)
 | |
| 
 | |
| 
 | |
| def get_changes_for_version(wanted_version: version.Version) -> str:
 | |
|     """Get the changelogs for the given version.
 | |
| 
 | |
|     If an RC then will only get the changelog for that RC version, otherwise if
 | |
|     its a full release will get the changelog for the release and all its RCs.
 | |
|     """
 | |
| 
 | |
|     with open("CHANGES.md") as f:
 | |
|         changes = f.read()
 | |
| 
 | |
|     # First we parse the changelog so that we can split it into sections based
 | |
|     # on the release headings.
 | |
|     ast = commonmark.Parser().parse(changes)
 | |
| 
 | |
|     @attr.s(auto_attribs=True)
 | |
|     class VersionSection:
 | |
|         title: str
 | |
| 
 | |
|         # These are 0-based.
 | |
|         start_line: int
 | |
|         end_line: Optional[int] = None  # Is none if its the last entry
 | |
| 
 | |
|     headings: List[VersionSection] = []
 | |
|     for node, _ in ast.walker():
 | |
|         # We look for all text nodes that are in a level 1 heading.
 | |
|         if node.t != "text":
 | |
|             continue
 | |
| 
 | |
|         if node.parent.t != "heading" or node.parent.level != 1:
 | |
|             continue
 | |
| 
 | |
|         # If we have a previous heading then we update its `end_line`.
 | |
|         if headings:
 | |
|             headings[-1].end_line = node.parent.sourcepos[0][0] - 1
 | |
| 
 | |
|         headings.append(VersionSection(node.literal, node.parent.sourcepos[0][0] - 1))
 | |
| 
 | |
|     changes_by_line = changes.split("\n")
 | |
| 
 | |
|     version_changelog = []  # The lines we want to include in the changelog
 | |
| 
 | |
|     # Go through each section and find any that match the requested version.
 | |
|     regex = re.compile(r"^Synapse v?(\S+)")
 | |
|     for section in headings:
 | |
|         groups = regex.match(section.title)
 | |
|         if not groups:
 | |
|             continue
 | |
| 
 | |
|         heading_version = version.parse(groups.group(1))
 | |
|         heading_base_version = version.parse(heading_version.base_version)
 | |
| 
 | |
|         # Check if heading version matches the requested version, or if its an
 | |
|         # RC of the requested version.
 | |
|         if wanted_version not in (heading_version, heading_base_version):
 | |
|             continue
 | |
| 
 | |
|         version_changelog.extend(changes_by_line[section.start_line : section.end_line])
 | |
| 
 | |
|     return "\n".join(version_changelog)
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     cli()
 |