prepare-release-pr.py (5296B)
1 # mypy: disallow-untyped-defs 2 """ 3 This script is part of the pytest release process which is triggered manually in the Actions 4 tab of the repository. 5 6 The user will need to enter the base branch to start the release from (for example 7 ``6.1.x`` or ``main``) and if it should be a major release. 8 9 The appropriate version will be obtained based on the given branch automatically. 10 11 After that, it will create a release using the `release` tox environment, and push a new PR. 12 13 **Token**: currently the token from the GitHub Actions is used, pushed with 14 `pytest bot <pytestbot@gmail.com>` commit author. 15 """ 16 17 import argparse 18 from pathlib import Path 19 import re 20 from subprocess import check_call 21 from subprocess import check_output 22 from subprocess import run 23 24 from colorama import Fore 25 from colorama import init 26 from github3.repos import Repository 27 28 29 class InvalidFeatureRelease(Exception): 30 pass 31 32 33 SLUG = "pytest-dev/pytest" 34 35 PR_BODY = """\ 36 Created by the [prepare release pr]\ 37 (https://github.com/pytest-dev/pytest/actions/workflows/prepare-release-pr.yml) workflow. 38 39 Once all builds pass and it has been **approved** by one or more maintainers, start the \ 40 [deploy](https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml) workflow, using these parameters: 41 42 * `Use workflow from`: `release-{version}`. 43 * `Release version`: `{version}`. 44 45 Or execute on the command line: 46 47 ```console 48 gh workflow run deploy.yml -r release-{version} -f version={version} 49 ``` 50 51 After the workflow has been approved by a core maintainer, the package will be uploaded to PyPI automatically. 52 """ 53 54 55 def login(token: str) -> Repository: 56 import github3 57 58 github = github3.login(token=token) 59 owner, repo = SLUG.split("/") 60 return github.repository(owner, repo) 61 62 63 def prepare_release_pr( 64 base_branch: str, is_major: bool, token: str, prerelease: str 65 ) -> None: 66 print() 67 print(f"Processing release for branch {Fore.CYAN}{base_branch}") 68 69 check_call(["git", "checkout", f"origin/{base_branch}"]) 70 71 changelog = Path("changelog") 72 73 features = list(changelog.glob("*.feature.rst")) 74 breaking = list(changelog.glob("*.breaking.rst")) 75 is_feature_release = bool(features or breaking) 76 77 try: 78 version = find_next_version( 79 base_branch, is_major, is_feature_release, prerelease 80 ) 81 except InvalidFeatureRelease as e: 82 print(f"{Fore.RED}{e}") 83 raise SystemExit(1) from None 84 85 print(f"Version: {Fore.CYAN}{version}") 86 87 release_branch = f"release-{version}" 88 89 run( 90 ["git", "config", "user.name", "pytest bot"], 91 check=True, 92 ) 93 run( 94 ["git", "config", "user.email", "pytestbot@gmail.com"], 95 check=True, 96 ) 97 98 run( 99 ["git", "checkout", "-b", release_branch, f"origin/{base_branch}"], 100 check=True, 101 ) 102 103 print(f"Branch {Fore.CYAN}{release_branch}{Fore.RESET} created.") 104 105 if is_major: 106 template_name = "release.major.rst" 107 elif prerelease: 108 template_name = "release.pre.rst" 109 elif is_feature_release: 110 template_name = "release.minor.rst" 111 else: 112 template_name = "release.patch.rst" 113 114 # important to use tox here because we have changed branches, so dependencies 115 # might have changed as well 116 cmdline = [ 117 "tox", 118 "-e", 119 "release", 120 "--", 121 version, 122 template_name, 123 release_branch, # doc_version 124 "--skip-check-links", 125 ] 126 print("Running", " ".join(cmdline)) 127 run( 128 cmdline, 129 check=True, 130 ) 131 132 oauth_url = f"https://{token}:x-oauth-basic@github.com/{SLUG}.git" 133 run( 134 ["git", "push", oauth_url, f"HEAD:{release_branch}", "--force"], 135 check=True, 136 ) 137 print(f"Branch {Fore.CYAN}{release_branch}{Fore.RESET} pushed.") 138 139 body = PR_BODY.format(version=version) 140 repo = login(token) 141 pr = repo.create_pull( 142 f"Prepare release {version}", 143 base=base_branch, 144 head=release_branch, 145 body=body, 146 ) 147 print(f"Pull request {Fore.CYAN}{pr.url}{Fore.RESET} created.") 148 149 150 def find_next_version( 151 base_branch: str, is_major: bool, is_feature_release: bool, prerelease: str 152 ) -> str: 153 output = check_output(["git", "tag"], encoding="UTF-8") 154 valid_versions = [] 155 for v in output.splitlines(): 156 m = re.match(r"\d.\d.\d+$", v.strip()) 157 if m: 158 valid_versions.append(tuple(int(x) for x in v.split("."))) 159 160 valid_versions.sort() 161 last_version = valid_versions[-1] 162 163 if is_major: 164 return f"{last_version[0]+1}.0.0{prerelease}" 165 elif is_feature_release: 166 return f"{last_version[0]}.{last_version[1] + 1}.0{prerelease}" 167 else: 168 return f"{last_version[0]}.{last_version[1]}.{last_version[2] + 1}{prerelease}" 169 170 171 def main() -> None: 172 init(autoreset=True) 173 parser = argparse.ArgumentParser() 174 parser.add_argument("base_branch") 175 parser.add_argument("token") 176 parser.add_argument("--major", action="store_true", default=False) 177 parser.add_argument("--prerelease", default="") 178 options = parser.parse_args() 179 prepare_release_pr( 180 base_branch=options.base_branch, 181 is_major=options.major, 182 token=options.token, 183 prerelease=options.prerelease, 184 ) 185 186 187 if __name__ == "__main__": 188 main()