diff --git a/docs/developer/releaser-tool.rst b/docs/developer/releaser-tool.rst index 2f525c32..09b13790 100644 --- a/docs/developer/releaser-tool.rst +++ b/docs/developer/releaser-tool.rst @@ -59,6 +59,20 @@ the following command: python -m openwisp_utils.releaser +If the process stops after it creates the release pull request, resume it +without repeating the version bump or changelog preparation: + +.. code-block:: shell + + python -m openwisp_utils.releaser resume https://github.com/owner/repository/pull/123 + +The resume command watches that release pull request until it is merged +and then creates any missing downstream artifacts. It reuses an existing +version tag on the merged base branch, an existing GitHub release for that +tag, and matching follow-up pull requests instead of creating duplicates. +A pull request that was closed without merging must be reopened or +replaced before the release can be resumed. + The Interactive Workflow ------------------------ @@ -92,11 +106,17 @@ process: 1. Updates the version number in your project's ``__init__.py``. 2. Writes the new release notes to your ``CHANGES.rst`` or ``CHANGES.md`` - file. -3. Creates a ``release/`` branch and commits the changes. + file. +3. Creates a ``release/`` branch and commits only the updated + changelog and detected version file, leaving unrelated worktree changes + unstaged. 4. Pushes the new branch to GitHub. 5. Creates a pull request and waits for you to merge it. 6. Once merged, it creates and pushes a signed git tag. 7. Finally, it creates a draft release on GitHub with the changelog notes. 8. If releasing a bugfix, it offers to port the changelog to the ``main`` or ``master`` branch. +9. If releasing a new feature from a Python or npm project, it offers to + bump the version to the next alpha release: it opens a pull request + which sets the version to the next minor release with the ``alpha`` + marker and adds a new ``[unreleased]`` section to the change log. diff --git a/openwisp_utils/releaser/__main__.py b/openwisp_utils/releaser/__main__.py index 196c8b8c..3f3b695c 100644 --- a/openwisp_utils/releaser/__main__.py +++ b/openwisp_utils/releaser/__main__.py @@ -1,3 +1,4 @@ +import argparse import subprocess import sys @@ -5,8 +6,16 @@ from openwisp_utils.releaser.release import main if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("command", nargs="?", choices=["resume"]) + parser.add_argument("pr_url", nargs="?") + args = parser.parse_args() + if args.command == "resume" and not args.pr_url: + parser.error("resume requires the release pull request URL") + if args.command is None and args.pr_url: + parser.error("a pull request URL can only be used with the resume command") try: - main() + main(resume_pr_url=args.pr_url) except KeyboardInterrupt: print("\n\nโŒ Release process terminated by user.") sys.exit(1) diff --git a/openwisp_utils/releaser/github.py b/openwisp_utils/releaser/github.py index 2aae2bfe..23f64136 100644 --- a/openwisp_utils/releaser/github.py +++ b/openwisp_utils/releaser/github.py @@ -1,4 +1,5 @@ import uuid +from urllib.parse import urlparse import requests from openwisp_utils.utils import retryable_request as utils_retryable_request @@ -38,11 +39,60 @@ def create_pr(self, head, base, title): def is_pr_merged(self, pr_url): """Checks if a pull request has been merged.""" - pr_number = pr_url.split("/")[-1] + pr_number = pr_url.rstrip("/").split("/")[-1] url = f"{self.base_url}/pulls/{pr_number}" response = retryable_request(method="get", url=url, headers=self.headers) return response.json().get("merged", False) + def get_pr(self, pr_url): + """Returns metadata for a pull request in the configured repository.""" + parsed_url = urlparse(pr_url) + path = parsed_url.path.strip("/").split("/") + if ( + parsed_url.scheme not in {"http", "https"} + or parsed_url.netloc != "github.com" + or len(path) != 4 + or "/".join(path[:2]) != self.repo + or path[2] != "pull" + or not path[3].isdigit() + ): + raise ValueError( + "The pull request URL must belong to the configured GitHub repository." + ) + pr_number = path[3] + url = f"{self.base_url}/pulls/{pr_number}" + response = retryable_request(method="get", url=url, headers=self.headers) + return response.json() + + def get_release(self, tag_name): + """Returns the release for a tag, or None when it has not been created.""" + response = retryable_request( + method="get", + url=f"{self.base_url}/releases/tags/{tag_name}", + headers=self.headers, + allowed_status_codes=(404,), + ) + if response.status_code == 404: + return None + return response.json() + + def find_pr(self, head, base, title): + """Returns an existing matching pull request, regardless of its state.""" + response = retryable_request( + method="get", + url=f"{self.base_url}/pulls", + headers=self.headers, + params={"state": "all", "base": base, "per_page": 100}, + ) + for pull_request in response.json(): + if ( + pull_request.get("title") == title + and pull_request.get("head", {}).get("ref") == head + and pull_request.get("base", {}).get("ref") == base + ): + return pull_request + return None + def create_release(self, tag_name, title, body): """Creates a draft release on GitHub.""" url = f"{self.base_url}/releases" @@ -54,8 +104,17 @@ def create_release(self, tag_name, title, body): "prerelease": False, } response = retryable_request( - method="post", url=url, headers=self.headers, json=payload + method="post", + url=url, + headers=self.headers, + json=payload, + allowed_status_codes=(422,), ) + if response.status_code == 422: + existing_release = self.get_release(tag_name) + if existing_release: + return existing_release["html_url"] + response.raise_for_status() return response.json()["html_url"] def check_pr_creation_permission(self) -> tuple[bool, str]: diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index da860466..d387a76c 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -17,23 +17,163 @@ from openwisp_utils.releaser.config import load_config from openwisp_utils.releaser.github import GitHub from openwisp_utils.releaser.utils import ( + AbortSignal, SkipSignal, adjust_markdown_headings, branch_exists, demote_markdown_headings, format_file_with_docstrfmt, get_current_branch, + get_remote_branch_commit, rst_to_markdown, + run_git, ) from openwisp_utils.releaser.version import ( bump_version, determine_new_version, get_current_version, + supports_prerelease, ) MAIN_BRANCHES = ["master", "main"] +def wait_for_pr_merge(gh, pr_url): + """Waits for a release pull request and returns its merged metadata.""" + print("โณ Waiting for PR to be merged... (checking every 20s)") + while True: + pull_request = gh.get_pr(pr_url) + if pull_request.get("merged"): + print("โœ… PR merged!") + return pull_request + if pull_request.get("state") == "closed": + raise RuntimeError( + "The release pull request was closed without being merged. " + "Reopen or replace it before resuming the release." + ) + time.sleep(20) + + +def get_release_version(pull_request): + """Returns the version encoded in a release pull request branch.""" + branch = pull_request.get("head", {}).get("ref", "") + if not branch.startswith("release/") or not branch.removeprefix("release/"): + raise RuntimeError( + "The pull request must originate from a release/ branch." + ) + return branch.removeprefix("release/") + + +def tag_exists_on_branch(tag_name, branch): + """Checks whether a tag resolves to a commit contained by a branch.""" + tag = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_name}^{{commit}}"], + capture_output=True, + text=True, + ) + if tag.returncode: + return False + return ( + subprocess.run( + ["git", "merge-base", "--is-ancestor", f"{tag_name}^{{commit}}", branch], + capture_output=True, + text=True, + ).returncode + == 0 + ) + + +def complete_release_artifacts( + gh, + config, + version, + base_branch, + latest_changelog_block, + tag_date_str, + changelog_date_str, +): + """Creates only the release artifacts that do not already exist.""" + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + run_git(["fetch", "origin", "--tags"], "fetch remote tags") + + tag_name = version + if tag_exists_on_branch(tag_name, base_branch): + print(f"๐Ÿท๏ธ Git tag '{tag_name}' already exists on '{base_branch}'.") + else: + local_tag = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_name}^{{commit}}"], + capture_output=True, + text=True, + ) + if local_tag.returncode == 0: + raise RuntimeError( + f"Git tag '{tag_name}' exists but is not contained by '{base_branch}'." + ) + tag_message = f"Version {version} [{tag_date_str}]" + run_git(["tag", "-s", tag_name, "-m", tag_message], f"create tag '{tag_name}'") + run_git(["push", "origin", tag_name], f"push tag '{tag_name}'") + print(f"๐Ÿท๏ธ Git tag '{tag_name}' created and pushed.") + + release_title = f"{version} [{changelog_date_str}]" + if config["changelog_format"] == "md": + release_body_md = "\n".join(latest_changelog_block.splitlines()[1:]).strip() + release_body_md = demote_markdown_headings(release_body_md) + else: + release_body_rst = "\n".join(latest_changelog_block.splitlines()[2:]).strip() + release_body_md = rst_to_markdown(release_body_rst) + + existing_release = gh.get_release(tag_name) + if existing_release: + print(f"๐Ÿ“ฆ GitHub release already exists: {existing_release['html_url']}") + else: + try: + release_url = gh.create_release(tag_name, release_title, release_body_md) + print(f"๐Ÿ“ฆ Draft release created on GitHub: {release_url}") + except SkipSignal: + print( + "\nOperation skipped. Please create the GitHub release manually." + f"\n Tag: {tag_name}" + f"\n Title: {release_title}" + "\n Body: (You can find the content in the latest commit)." + ) + questionary.confirm( + "Press Enter when you have created the release manually." + ).ask() + + +def resume(pr_url): + """Completes a release after its already-created pull request is merged.""" + config, gh = check_prerequisites() + pull_request = wait_for_pr_merge(gh, pr_url) + version = get_release_version(pull_request) + base_branch = pull_request["base"]["ref"] + + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + latest_changelog_block = get_release_block_from_file(config, version) + if not latest_changelog_block: + raise RuntimeError( + f"Could not find the changelog entry for version {version} on '{base_branch}'." + ) + + changelog_date = datetime.now().strftime("%Y-%m-%d") + header = latest_changelog_block.splitlines()[0] + date_match = re.search(r"\[(\d{4}-\d{2}-\d{2})\]", header) + if date_match: + changelog_date = date_match.group(1) + complete_release_artifacts( + gh, + config, + version, + base_branch, + latest_changelog_block, + datetime.now().strftime("%d-%m-%Y"), + changelog_date, + ) + _complete_follow_up(gh, config, version, latest_changelog_block, base_branch) + + def check_prerequisites(): """Checks for all required prerequisite.""" print("๐Ÿ”Ž Checking prerequisites...") @@ -90,6 +230,20 @@ def check_prerequisites(): return config, gh +def resolve_main_branch(question): + """Returns the local main branch, asking the user when both exist.""" + master_exists = branch_exists("master") + main_exists = branch_exists("main") + if master_exists and main_exists: + return questionary.select(question, choices=MAIN_BRANCHES).ask() + if master_exists: + return "master" + if main_exists: + return "main" + print("Neither 'master' nor 'main' branches were found locally.") + return None + + def port_changelog_to_main(gh, config, version, changelog_body, original_branch): """Checks out the main branch, updates the changelog, and creates a new PR.""" print("\n" + "=" * 50) @@ -110,31 +264,23 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) full_block_to_port = f"{version_header}\n{underline}\n\n{changelog_body}" try: - master_exists = branch_exists("master") - main_exists = branch_exists("main") - if master_exists and main_exists: - main_branch = questionary.select( - "Which branch should the changelog be ported to?", - choices=MAIN_BRANCHES, - ).ask() - elif master_exists: - main_branch = "master" - elif main_exists: - main_branch = "main" - else: - print( - "Neither 'master' nor 'main' branches were found locally. " - "Skipping changelog porting." - ) - return + main_branch = resolve_main_branch( + "Which branch should the changelog be ported to?" + ) if not main_branch: - print("Porting cancelled.") + print("Skipping changelog porting.") return port_branch = f"chore/port-changelog-{version}" commit_message = f"[docs] Port changelog for {version}" pr_title = f"[docs] Port changelog for release {version}" + existing_pr = gh.find_pr(port_branch, main_branch, pr_title) + if isinstance(existing_pr, dict): + print( + f"Changelog port pull request already exists: {existing_pr['html_url']}" + ) + return print(f"Checking out '{main_branch}' and pulling latest changes...") subprocess.run( @@ -168,7 +314,9 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) print(f"Pushing branch '{port_branch}' to origin...") subprocess.run( - ["git", "push", "origin", port_branch], check=True, capture_output=True + ["git", "push", "-u", "origin", port_branch], + check=True, + capture_output=True, ) print("Creating pull request...") @@ -195,7 +343,164 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) ) -def main(): +def bump_to_next_alpha(gh, config, released_version, original_branch): + """Bumps the version to the next alpha release and opens a PR for it.""" + print("\n" + "=" * 50) + print("๐Ÿค– Starting Version Bump to the Next Alpha Release") + print("=" * 50) + package_type = config.get("package_type") + if not supports_prerelease(package_type): + print( + f"Skipping alpha version bump: '{package_type}' projects cannot store " + "an alpha marker." + ) + return + next_version = determine_new_version(released_version, "final", is_bugfix=False) + if not next_version: + print("No version provided. Version bump cancelled.") + return + + base_branch = resolve_main_branch("Which branch should the version be bumped on?") + if not base_branch: + print("Skipping the version bump.") + return + bump_branch = f"chore/bump-version-{next_version}" + pr_title = f"[bump] Bumped version to {next_version} alpha" + force_with_lease = None + bump_branch_created = False + changes_committed = False + + existing_pr = gh.find_pr(bump_branch, base_branch, pr_title) + if isinstance(existing_pr, dict): + print(f"Version bump pull request already exists: {existing_pr['html_url']}") + return + + try: + print(f"Checking out '{base_branch}' and pulling latest changes...") + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + while True: + remote_commit = get_remote_branch_commit(bump_branch) + if not branch_exists(bump_branch) and not remote_commit: + break + decision = questionary.select( + f"Branch '{bump_branch}' already exists. How would you like to proceed?", + choices=[ + f"Reset it to '{base_branch}'", + "Use a different branch name", + "Abort the version bump", + ], + ).ask() + if decision == f"Reset it to '{base_branch}'": + force_with_lease = remote_commit + break + elif decision == "Use a different branch name": + bump_branch = questionary.text("Enter the branch name:").ask() + if not bump_branch: + raise AbortSignal("No branch name provided.") + else: # Abort or None + raise AbortSignal("User aborted the version bump.") + print(f"Creating new branch '{bump_branch}'...") + run_git(["checkout", "-B", bump_branch], f"create branch '{bump_branch}'") + bump_branch_created = True + was_bumped = bump_version(config, next_version, version_type="alpha") + if was_bumped: + print(f"โœ… Version bumped to {next_version} and set to 'alpha'.") + else: + print( + "\nโš ๏ธ The version number could not be bumped automatically." + "\n Please bump it manually before the changelog is committed." + ) + questionary.confirm( + "Press Enter when you have bumped the version number..." + ).ask() + changelog_path = config["changelog_path"] + prefix = "Version " if config.get("changelog_uses_version_prefix", True) else "" + version_header = f"{prefix}{next_version} [unreleased]" + if config["changelog_format"] == "md": + unreleased_block = f"## {version_header}\n\nWork in progress." + else: # rst + underline = "-" * len(version_header) + unreleased_block = f"{version_header}\n{underline}\n\nWork in progress." + + update_changelog_file(changelog_path, unreleased_block) + if config["changelog_format"] == "rst": + format_file_with_docstrfmt(changelog_path) + print(f"โœ… {changelog_path} has been updated.") + print("Committing changes...") + run_git(["add", "-u"], "stage the version bump") + run_git(["commit", "-m", pr_title], "commit the version bump") + changes_committed = True + + print(f"โคด๏ธ Pushing branch '{bump_branch}' to origin...") + push_args = ["push", "-u", "origin", bump_branch] + if force_with_lease: + push_args.insert( + 1, + f"--force-with-lease=refs/heads/{bump_branch}:{force_with_lease}", + ) + run_git(push_args, f"push branch '{bump_branch}'") + print("Creating pull request...") + pr_url = gh.create_pr(bump_branch, base_branch, pr_title) + print(f"\nโœ… Successfully created Pull Request for the version bump: {pr_url}") + except (SkipSignal, AbortSignal) as e: + print( + f"\nโš ๏ธ {e}" + "\nPlease complete the version bump manually." + f"\n Branch: {bump_branch}" + f"\n Base: {base_branch}" + f"\n Title: {pr_title}" + ) + finally: + if not bump_branch_created or changes_committed: + print(f"\nSwitching back to original branch '{original_branch}'...") + subprocess.run( + ["git", "checkout", original_branch], check=True, capture_output=True + ) + else: + print( + f"\nKeeping branch '{bump_branch}' checked out because it has " + "uncommitted version-bump changes." + ) + + +def _complete_follow_up(gh, config, version, latest_changelog_block, base_branch): + """Offers the existing post-release follow-up appropriate for the base branch.""" + is_bugfix = base_branch not in MAIN_BRANCHES + if is_bugfix: + print("\n๐Ÿ› Bugfix release complete.") + if questionary.confirm( + "Do you want to create a PR to port the changelog to the main branch now?" + ).ask(): + lines_to_skip = 2 if config["changelog_format"] != "md" else 1 + changelog_body_for_porting = "\n".join( + latest_changelog_block.splitlines()[lines_to_skip:] + ).strip() + port_changelog_to_main( + gh, config, version, changelog_body_for_porting, base_branch + ) + else: + print("Skipping changelog port. Please remember to do it manually.") + elif ( + supports_prerelease(config.get("package_type")) + and questionary.confirm( + "Do you want to bump the version to the next alpha release now?" + ).ask() + ): + bump_to_next_alpha(gh, config, version, base_branch) + elif supports_prerelease(config.get("package_type")): + print("Skipping the version bump. Please remember to do it manually.") + else: + print( + f"Skipping alpha version bump: '{config.get('package_type')}' projects " + "cannot store an alpha marker." + ) + + +def main(resume_pr_url=None): + if resume_pr_url: + resume(resume_pr_url) + return config, gh = check_prerequisites() original_branch = get_current_branch() is_bugfix = original_branch not in MAIN_BRANCHES @@ -305,8 +610,11 @@ def main(): ["git", "checkout", "-b", release_branch], check=True, capture_output=True ) - print("Adding tracked changes to git...") - subprocess.run(["git", "add", "-u"], check=True, capture_output=True) + paths_to_add = [changelog_path] + if version_path := config.get("version_path"): + paths_to_add.append(version_path) + print("Adding release changes to git...") + subprocess.run(["git", "add", *paths_to_add], check=True, capture_output=True) commit_message = f"[release] Version {new_version}" subprocess.run( @@ -316,7 +624,9 @@ def main(): print(f"โคด๏ธ Pushing new branch '{release_branch}' to GitHub...") subprocess.run( - ["git", "push", "origin", release_branch], check=True, capture_output=True + ["git", "push", "-u", "origin", release_branch], + check=True, + capture_output=True, ) try: @@ -337,59 +647,18 @@ def main(): ) questionary.confirm("Press Enter when you have merged the PR manually.").ask() - subprocess.run( - ["git", "checkout", original_branch], check=True, capture_output=True + complete_release_artifacts( + gh, + config, + new_version, + original_branch, + latest_changelog_block, + tag_date_str, + changelog_date_str, ) - subprocess.run( - ["git", "pull", "origin", original_branch], check=True, capture_output=True - ) - - tag_name = new_version - tag_message = f"Version {new_version} [{tag_date_str}]" - subprocess.run(["git", "tag", "-s", tag_name, "-m", tag_message], check=True) - subprocess.run(["git", "push", "origin", tag_name], check=True) - print(f"๐Ÿท๏ธ Git tag '{tag_name}' created and pushed.") - - release_title = f"{new_version} [{changelog_date_str}]" - - if config["changelog_format"] == "md": - release_body_md = "\n".join(latest_changelog_block.splitlines()[1:]).strip() - release_body_md = demote_markdown_headings(release_body_md) - else: - release_body_rst = "\n".join(latest_changelog_block.splitlines()[2:]).strip() - release_body_md = rst_to_markdown(release_body_rst) - - try: - release_url = gh.create_release(tag_name, release_title, release_body_md) - print(f"๐Ÿ“ฆ Draft release created on GitHub: {release_url}") - except SkipSignal: - print( - "\nOperation skipped. Please create the GitHub release manually." - f"\n Tag: {tag_name}" - f"\n Title: {release_title}" - "\n Body: (You can find the content in the latest commit)." - ) - questionary.confirm( - "Press Enter when you have created the release manually." - ).ask() print("\n๐ŸŽ‰ Release process completed successfully!") - if is_bugfix: - print("\n๐Ÿ› Bugfix release complete.") - if questionary.confirm( - "Do you want to create a PR to port the changelog to the main branch now?" - ).ask(): - lines_to_skip = 2 if config["changelog_format"] != "md" else 1 - changelog_body_for_porting = "\n".join( - latest_changelog_block.splitlines()[lines_to_skip:] - ).strip() - port_changelog_to_main( - gh, - config, - new_version, - changelog_body_for_porting, - original_branch, - ) - else: - print("Skipping changelog port. Please remember to do it manually.") + _complete_follow_up( + gh, config, new_version, latest_changelog_block, original_branch + ) diff --git a/openwisp_utils/releaser/tests/conftest.py b/openwisp_utils/releaser/tests/conftest.py index 1e50066a..39354eb3 100644 --- a/openwisp_utils/releaser/tests/conftest.py +++ b/openwisp_utils/releaser/tests/conftest.py @@ -197,6 +197,7 @@ def subprocess_side_effect(command, *args, **kwargs): "openwisp_utils.releaser.release.subprocess.run", side_effect=subprocess_side_effect, ), + "run_git": mocker.patch("openwisp_utils.releaser.release.run_git"), "GitHub": mocker.patch("openwisp_utils.releaser.release.GitHub"), "time": mocker.patch("openwisp_utils.releaser.release.time.sleep"), "print": mocker.patch("builtins.print"), @@ -208,6 +209,9 @@ def subprocess_side_effect(command, *args, **kwargs): "bump_version": mocker.patch( "openwisp_utils.releaser.release.bump_version", return_value=True ), + "bump_to_next_alpha": mocker.patch( + "openwisp_utils.releaser.release.bump_to_next_alpha" + ), "update_changelog": mocker.patch( "openwisp_utils.releaser.release.update_changelog_file" ), @@ -240,9 +244,11 @@ def subprocess_side_effect(command, *args, **kwargs): mock_gh_instance = mocks["GitHub"].return_value mock_gh_instance.create_pr.side_effect = ["http://pr.url/1", "http://pr.url/2"] mock_gh_instance.is_pr_merged.return_value = True + mock_gh_instance.get_release.return_value = None mock_config = { "repo": "test/repo", + "package_type": "python", "changelog_path": "CHANGES.rst", "changelog_format": "rst", "changelog_uses_version_prefix": True, diff --git a/openwisp_utils/releaser/tests/test_github.py b/openwisp_utils/releaser/tests/test_github.py index 5e7bfd58..50b3b797 100644 --- a/openwisp_utils/releaser/tests/test_github.py +++ b/openwisp_utils/releaser/tests/test_github.py @@ -56,6 +56,27 @@ def test_is_pr_merged(mock_retryable_request, github_client): assert call_args["url"].endswith("/pulls/123") +@patch("openwisp_utils.releaser.github.retryable_request") +def test_get_pr_validates_repository(mock_retryable_request, github_client): + mock_retryable_request.return_value = mock_response(200, {"merged": True}) + + pull_request = github_client.get_pr("https://github.com/owner/repo/pull/123") + + assert pull_request["merged"] is True + with pytest.raises(ValueError, match="configured GitHub repository"): + github_client.get_pr("https://github.com/other/repo/pull/123") + + +@patch("openwisp_utils.releaser.github.retryable_request") +def test_get_release_returns_none_for_missing_tag( + mock_retryable_request, github_client +): + mock_retryable_request.return_value = mock_response(404) + + assert github_client.get_release("1.0.0") is None + assert mock_retryable_request.call_args.kwargs["allowed_status_codes"] == (404,) + + @patch("openwisp_utils.releaser.github.retryable_request") def test_create_release(mock_retryable_request, github_client): mock_retryable_request.return_value = mock_response( @@ -68,6 +89,23 @@ def test_create_release(mock_retryable_request, github_client): assert call_args["json"]["tag_name"] == "v1.0.0" +@patch("openwisp_utils.releaser.github.retryable_request") +def test_create_release_reuses_release_after_duplicate_response( + mock_retryable_request, github_client, mocker +): + mock_retryable_request.return_value = mock_response(422) + mocker.patch.object( + github_client, + "get_release", + return_value={"html_url": "http://example.com/releases/1"}, + ) + + release_url = github_client.create_release("1.0.0", "Version 1.0.0", "Notes") + + assert release_url == "http://example.com/releases/1" + assert mock_retryable_request.call_args.kwargs["allowed_status_codes"] == (422,) + + @patch("openwisp_utils.releaser.github.utils_retryable_request") def test_check_pr_creation_permission_success(mock_utils_request, github_client): """Tests the successful permission check flow where the probe fails as expected.""" diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index f2431543..ffb7642b 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -1,9 +1,13 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest -from openwisp_utils.releaser.release import check_prerequisites +from openwisp_utils.releaser.release import ( + bump_to_next_alpha, + check_prerequisites, + complete_release_artifacts, +) from openwisp_utils.releaser.release import main as run_release -from openwisp_utils.releaser.release import port_changelog_to_main +from openwisp_utils.releaser.release import port_changelog_to_main, resume from openwisp_utils.releaser.utils import SkipSignal @@ -40,6 +44,58 @@ def test_release_flow_manual_bump(mock_all): ) +def test_alpha_bump_uses_bump_prefix(mock_all, mocker): + mock_config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + } + mocker.patch( + "openwisp_utils.releaser.release.determine_new_version", + return_value="1.3.0", + ) + mocker.patch( + "openwisp_utils.releaser.release.resolve_main_branch", return_value="main" + ) + mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=False) + mocker.patch( + "openwisp_utils.releaser.release.get_remote_branch_commit", return_value=None + ) + + bump_to_next_alpha(mock_all["GitHub"].return_value, mock_config, "1.2.0", "main") + + mock_all["run_git"].assert_any_call( + ["commit", "-m", "[bump] Bumped version to 1.3.0 alpha"], + "commit the version bump", + ) + + +def test_alpha_bump_prompts_for_manual_version_update(mock_all, mocker): + mock_config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + } + mocker.patch( + "openwisp_utils.releaser.release.determine_new_version", + return_value="1.3.0", + ) + mocker.patch( + "openwisp_utils.releaser.release.resolve_main_branch", return_value="main" + ) + mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=False) + mocker.patch( + "openwisp_utils.releaser.release.get_remote_branch_commit", return_value=None + ) + mock_all["bump_version"].return_value = False + + bump_to_next_alpha(mock_all["GitHub"].return_value, mock_config, "1.2.0", "main") + + mock_all["questionary_confirm"].assert_any_call( + "Press Enter when you have bumped the version number..." + ) + + def test_prerequisite_check_failure(mocker): """Tests that the script exits if the prerequisite check fails.""" mocker.patch("openwisp_utils.releaser.release.shutil.which", return_value=None) @@ -150,6 +206,81 @@ def test_main_flow_pr_merge_wait(mock_all): assert mock_gh_instance.is_pr_merged.call_count == 2 +def test_resume_waits_for_and_completes_merged_release_pr(mock_all): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.side_effect = [ + {"merged": False, "state": "open"}, + { + "merged": True, + "state": "closed", + "head": {"ref": "release/1.3.0"}, + "base": {"ref": "master"}, + }, + ] + mock_all["get_release_block_from_file"].return_value = ( + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change" + ) + + resume("https://github.com/test/repo/pull/123") + + mock_all["time"].assert_called_once_with(20) + mock_gh.create_release.assert_called_once() + mock_all["bump_to_next_alpha"].assert_called_once() + + +def test_resume_rejects_closed_unmerged_release_pr(mock_all): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.return_value = {"merged": False, "state": "closed"} + + with pytest.raises(RuntimeError, match="closed without being merged"): + resume("https://github.com/test/repo/pull/123") + + +def test_resume_reuses_existing_tag_and_github_release(mock_all, mocker): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.return_value = { + "merged": True, + "state": "closed", + "head": {"ref": "release/1.3.0"}, + "base": {"ref": "master"}, + } + mock_gh.get_release.return_value = {"html_url": "https://example.com/releases/1"} + mock_all["get_release_block_from_file"].return_value = ( + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change" + ) + mocker.patch( + "openwisp_utils.releaser.release.tag_exists_on_branch", return_value=True + ) + + resume("https://github.com/test/repo/pull/123") + + mock_gh.create_release.assert_not_called() + assert ["tag", "-s", "1.3.0", "-m", "Version 1.3.0 [02-09-2026]"] not in [ + call.args[0] for call in mock_all["run_git"].call_args_list + ] + + +def test_complete_release_artifacts_rejects_conflicting_tag(mock_all, mocker): + mocker.patch( + "openwisp_utils.releaser.release.tag_exists_on_branch", return_value=False + ) + mocker.patch( + "openwisp_utils.releaser.release.subprocess.run", + return_value=MagicMock(returncode=0), + ) + + with pytest.raises(RuntimeError, match="exists but is not contained"): + complete_release_artifacts( + mock_all["GitHub"].return_value, + {"changelog_format": "rst"}, + "1.3.0", + "master", + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change", + "11-08-2025", + "2025-08-11", + ) + + @patch("openwisp_utils.releaser.release.update_changelog_file") @patch("openwisp_utils.releaser.release.format_file_with_docstrfmt") @patch("openwisp_utils.releaser.release.subprocess.run") diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index 468aaf52..dab51881 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -9,9 +9,11 @@ rst_to_markdown, ) from openwisp_utils.releaser.utils import ( + AbortSignal, SkipSignal, branch_exists, format_file_with_docstrfmt, + get_remote_branch_commit, retryable_request, ) @@ -43,6 +45,59 @@ """ +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit(mock_subprocess): + mock_subprocess.return_value = MagicMock( + returncode=0, stdout="a" * 40 + "\trefs/heads/bump\n" + ) + assert get_remote_branch_commit("bump") == "a" * 40 + mock_subprocess.assert_called_once_with( + ["git", "ls-remote", "--exit-code", "--heads", "origin", "bump"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + + +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_missing_branch(mock_subprocess): + mock_subprocess.return_value = MagicMock(returncode=2) + assert get_remote_branch_commit("bump") is None + + +@patch("openwisp_utils.releaser.utils.questionary.select") +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_retries_after_failure( + mock_subprocess, mock_questionary +): + error = subprocess.CalledProcessError(128, "git", stderr="Authentication failed") + mock_subprocess.side_effect = [ + error, + MagicMock(returncode=0, stdout="a" * 40 + "\trefs/heads/bump\n"), + ] + mock_questionary.return_value.ask.return_value = "Retry" + assert get_remote_branch_commit("bump") == "a" * 40 + assert mock_subprocess.call_count == 2 + + +@pytest.mark.parametrize( + ("decision", "exception"), + [("Skip", SkipSignal), ("Abort", AbortSignal)], +) +@patch("openwisp_utils.releaser.utils.questionary.select") +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_handles_failure_decisions( + mock_subprocess, mock_questionary, decision, exception +): + mock_subprocess.side_effect = subprocess.CalledProcessError( + 128, "git", stderr="Authentication failed" + ) + mock_questionary.return_value.ask.return_value = decision + with pytest.raises(exception): + get_remote_branch_commit("bump") + + def test_rst_to_markdown_conversion(): """Test basic reStructuredText to Markdown conversion.""" # Test that the function calls it correctly. @@ -52,6 +107,20 @@ def test_rst_to_markdown_conversion(): mock_convert.assert_called_once() +def test_rst_to_markdown_converts_dependency_version_links(): + rst = """- Bumped ``openwisp-users`` from ``~=1.2.0`` to `~=1.3.0 + `_. +- Bumped ``openwisp-utils[rest]`` from ``~=1.2.0`` to `~=1.3.0 + `__. +- Bumped ``django-reversion`` from ``~=6.0.0`` to `~=6.3.0 + `_. +""" + expected = """- Bumped `openwisp-users` from `~=1.2.0` to [~=1.3.0](https://github.com/openwisp/openwisp-users/blob/1.3.0/CHANGES.rst). +- Bumped `openwisp-utils[rest]` from `~=1.2.0` to [~=1.3.0](https://github.com/openwisp/openwisp-utils/blob/1.3.0/CHANGES.rst). +- Bumped `django-reversion` from `~=6.0.0` to [~=6.3.0](https://github.com/etianen/django-reversion/blob/v6.3.0/CHANGELOG.rst).""" + assert rst_to_markdown(rst) == expected + + def test_adjust_markdown_headings(): """Test that markdown headings are correctly adjusted for the CHANGES.md file.""" raw_md = """ diff --git a/openwisp_utils/releaser/tests/test_version_bumping.py b/openwisp_utils/releaser/tests/test_version_bumping.py index 323a52cc..950d4157 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -6,6 +6,7 @@ bump_version, determine_new_version, get_current_version, + supports_prerelease, ) SAMPLE_INIT_FILE = """ @@ -66,6 +67,10 @@ def test_bump_version_success(mock_config): expected_content = 'VERSION = (1, 2, 0, "final")' assert expected_content in written_content + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + bump_version(mock_config, "1.3.0", version_type="alpha") + assert 'VERSION = (1, 3, 0, "alpha")' in m_open().write.call_args[0][0] + def test_bump_version_version_py(): """Tests bumping version when VERSION is in version.py.""" @@ -131,6 +136,21 @@ def test_bump_version_invalid_format(): bump_version(mock_config, "1.2") +@pytest.mark.parametrize( + ("package_type", "content"), + [ + ("python", SAMPLE_INIT_FILE), + ("npm", '{"version": "1.2.3"}'), + ], +) +def test_bump_version_rejects_prerelease_version(package_type, content): + config = {"package_type": package_type, "version_path": "version-file"} + with patch("builtins.open", mock_open(read_data=content)) as mocked_open: + with pytest.raises(SystemExit): + bump_version(config, "1.3.0-alpha", version_type="alpha") + mocked_open.assert_not_called() + + @patch("openwisp_utils.releaser.version.questionary") def test_determine_new_version_not_final(mock_questionary): """Tests the version suggestion when the current version is not 'final'.""" @@ -231,6 +251,28 @@ def test_bump_version_npm(): written_content = m_open().write.call_args[0][0] assert '"version": "1.2.4"' in written_content + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + bump_version(config, "1.3.0", version_type="alpha") + assert '"version": "1.3.0-alpha"' in m_open().write.call_args[0][0] + + +def test_bump_version_prerelease_support(): + """Only version files that can store a marker accept a pre-release type.""" + assert supports_prerelease("python") is True + assert supports_prerelease("npm") is True + plain_version_packages = { + "generic": "VERSION", + "ansible": "templates/openwisp2/version.py", + "docker": "images/common/openwisp/VERSION", + } + for package_type, version_path in plain_version_packages.items(): + assert supports_prerelease(package_type) is False + config = {"package_type": package_type, "version_path": version_path} + m_open = mock_open(read_data="1.2.3\n") + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + with pytest.raises(RuntimeError, match="cannot express"): + bump_version(config, "1.3.0", version_type="alpha") + # Docker (docker-openwisp) Package Version Tests def test_get_current_version_docker(): diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index 032e29a1..58d1891a 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -14,11 +14,53 @@ class SkipSignal(Exception): pass -def retryable_request(**kwargs): +class AbortSignal(Exception): + """Signal that the user has chosen to abort an operation.""" + + pass + + +def run_git(args, description, allowed_returncodes=()): + """Runs a git command, prompting Retry/Skip/Abort on failure.""" + while True: + try: + return subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + except subprocess.CalledProcessError as e: + if e.returncode in allowed_returncodes: + return subprocess.CompletedProcess( + e.cmd, e.returncode, e.output, e.stderr + ) + print(f"\nโŒ ERROR: Failed to {description}.", file=sys.stderr) + print("Git output:", file=sys.stderr) + indented_stderr = "\n".join( + [f" {line}" for line in (e.stderr or "").strip().split("\n")] + ) + print(indented_stderr, file=sys.stderr) + decision = questionary.select( + "An error occurred. What would you like to do?", + choices=["Retry", "Skip", "Abort"], + ).ask() + if decision == "Retry": + continue + elif decision == "Skip": + raise SkipSignal(f"User chose to skip: {description}.") + else: # Abort or None + raise AbortSignal(f"User aborted while trying to {description}.") + + +def retryable_request(allowed_status_codes=(), **kwargs): """Executes a requests call and provides a retry/skip/abort prompt on failure.""" while True: try: response = requests.request(**kwargs) + if response.status_code in allowed_status_codes: + return response response.raise_for_status() return response except requests.RequestException as e: @@ -95,12 +137,47 @@ def branch_exists(branch_name): return result.returncode == 0 +def get_remote_branch_commit(branch_name): + """Return the origin branch commit, or None when the branch does not exist.""" + result = run_git( + ["ls-remote", "--exit-code", "--heads", "origin", branch_name], + f"look up remote branch '{branch_name}'", + allowed_returncodes=(2,), + ) + if result.returncode == 0: + return result.stdout.split()[0] + if result.returncode == 2: + return None + + def rst_to_markdown(text): """Convert reStructuredText to Markdown using pypandoc.""" + links = [] + + def protect_dependency_link(match): + """Replace a matched ReST dependency link with a marker. + + Stores its GitHub Flavoured Markdown equivalent for restoration + after Pandoc conversion. + """ + link = f"[{''.join(match['version'].split())}]({match['url']})" + marker = f"OPENWISPRELEASERLINK{len(links)}" + links.append((marker, link)) + return marker + + text = re.sub( + r"`(?P[><=~!][^`\s]*)\s+<(?Phttps?://[^>\s]+)>`_{1,2}", + protect_dependency_link, + text, + flags=re.DOTALL, + ) escaped_text = re.sub(r"(?