Coverage for src / rhiza_tools / commands / release.py: 87%
257 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 01:10 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 01:10 +0000
1"""Command to push release tags to remote.
3This module implements release functionality that validates the git repository
4state and pushes tags to remote, triggering the release workflow. Tags are
5created by bump-my-version during the bump process.
7Supports Python projects (pyproject.toml) and Go projects (go.mod + VERSION file).
8The project language is auto-detected when not explicitly specified.
10Example:
11 Push a release tag::
13 from rhiza_tools.commands.release import release_command
14 release_command()
16 Dry run to preview release::
18 release_command(dry_run=True)
20 Release a Go project::
22 release_command(language=Language.GO)
23"""
25import semver
26import typer
27from loguru import logger
29from rhiza_tools import console
30from rhiza_tools.commands._shared import (
31 run_git_command,
32)
33from rhiza_tools.commands.bump import (
34 BumpOptions,
35 Language,
36 bump_command,
37 get_bumped_version_from_type,
38 get_current_version,
39 get_interactive_bump_type,
40)
43def check_clean_working_tree() -> None:
44 """Verify that the working tree is clean (no uncommitted changes).
46 Raises:
47 typer.Exit: If there are uncommitted changes in the working tree.
49 Example:
50 >>> check_clean_working_tree() # doctest: +SKIP
51 """
52 result = run_git_command(["git", "status", "--porcelain"])
53 if result.stdout.strip():
54 console.error("You have uncommitted changes:")
55 console.error(result.stdout)
56 console.error("Please commit or stash your changes before releasing.")
57 raise typer.Exit(code=1)
60def check_branch_status(current_branch: str) -> None:
61 """Check if the current branch is up-to-date with remote.
63 Args:
64 current_branch: The name of the current git branch.
66 Raises:
67 typer.Exit: If branch is behind remote or has diverged.
69 Example:
70 >>> check_branch_status("main") # doctest: +SKIP
71 """
72 # Fetch latest from remote
73 console.info("Checking remote status...")
74 run_git_command(["git", "fetch", "origin"])
76 # Get upstream tracking branch
77 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], check=False)
78 if result.returncode != 0:
79 console.error(f"No upstream branch configured for {current_branch}")
80 console.error(f"Set upstream with: git push -u origin {current_branch}")
81 raise typer.Exit(code=1)
83 upstream = result.stdout.strip()
85 # Get commit hashes
86 local = run_git_command(["git", "rev-parse", "@"]).stdout.strip()
87 remote = run_git_command(["git", "rev-parse", upstream]).stdout.strip()
88 base = run_git_command(["git", "merge-base", "@", upstream]).stdout.strip()
90 if local != remote:
91 if local == base:
92 # Local is behind remote (need to pull)
93 console.error(f"Your branch is behind '{upstream}'.")
94 console.error("Pull the latest changes before releasing:")
95 console.error(f" git pull origin {current_branch}")
96 raise typer.Exit(code=1)
97 else:
98 # Either local is ahead of remote OR branches have diverged
99 # Check if remote == base to distinguish between the two cases
100 if remote == base:
101 # Local is ahead of remote (need to push)
102 console.warning(f"Your branch is ahead of '{upstream}'.")
103 console.info("Unpushed commits:")
104 result = run_git_command(["git", "log", "--oneline", "--graph", "--decorate", f"{upstream}..HEAD"])
105 console.info(result.stdout)
106 console.warning("Please push changes to remote before releasing.")
107 raise typer.Exit(code=1)
108 else:
109 # Branches have diverged (need to merge or rebase)
110 console.error(f"Your branch has diverged from '{upstream}'.")
111 console.error("To reconcile, choose one of:")
112 console.error(f" Rebase: git pull --rebase origin {current_branch}")
113 console.error(f" Merge: git merge origin/{current_branch}")
114 console.error("Then resolve any conflicts and retry.")
115 raise typer.Exit(code=1)
118def get_default_branch() -> str:
119 """Get the default branch name from the remote repository.
121 Returns:
122 The name of the default branch (e.g., "main" or "master").
124 Raises:
125 typer.Exit: If the default branch cannot be determined.
127 Example:
128 >>> branch = get_default_branch() # doctest: +SKIP
129 >>> print(branch) # doctest: +SKIP
130 main
131 """
132 result = run_git_command(["git", "remote", "show", "origin"], check=False)
133 if result.returncode != 0:
134 console.error("Could not determine default branch from remote")
135 raise typer.Exit(code=1)
137 for line in result.stdout.split("\n"):
138 if "HEAD branch" in line:
139 return str(line.split()[-1])
141 console.error("Could not determine default branch from remote")
142 raise typer.Exit(code=1)
145def check_tag_exists(tag: str) -> tuple[bool, bool]:
146 """Check if a tag exists locally and/or remotely.
148 Args:
149 tag: The tag name to check.
151 Returns:
152 Tuple of (exists_locally, exists_remotely).
154 Example:
155 >>> local, remote = check_tag_exists("v1.0.0") # doctest: +SKIP
156 >>> if remote: # doctest: +SKIP
157 ... print("Tag already released") # doctest: +SKIP
158 """
159 # Check local
160 result = run_git_command(["git", "rev-parse", tag], check=False)
161 exists_locally = result.returncode == 0
163 # Check remote
164 result = run_git_command(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"], check=False)
165 exists_remotely = result.returncode == 0
167 return exists_locally, exists_remotely
170def push_tag(tag: str, dry_run: bool = False, non_interactive: bool = False) -> None:
171 """Push a git tag to the remote repository.
173 Args:
174 tag: The tag name to push.
175 dry_run: If True, only show what would be done.
176 non_interactive: If True, skip confirmation prompts.
178 Raises:
179 typer.Exit: If push fails.
181 Example:
182 >>> push_tag("v1.0.0") # doctest: +SKIP
183 """
184 command = ["git", "push", "origin", f"refs/tags/{tag}"]
186 if dry_run:
187 dry_run_header = typer.style("[DRY-RUN] Would execute:", fg=typer.colors.YELLOW, bold=True)
188 console.info(f"\n{dry_run_header} {' '.join(command)}")
190 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True)
191 console.info(f"[DRY-RUN] Release tag {tag_styled} would be pushed to remote")
192 console.info("[DRY-RUN] This would trigger the release workflow")
194 # Show what would be pushed
195 result = run_git_command(["git", "show", "-s", "--format=%H %s", tag], check=False)
196 if result.returncode == 0 and result.stdout.strip():
197 console.info(f"[DRY-RUN] Tag points to: {result.stdout.strip()}")
198 else:
199 console.info(f"\n{typer.style('Pushing tag to remote...', fg=typer.colors.CYAN, bold=True)}")
200 console.info(f"Command: {' '.join(command)}")
201 run_git_command(command)
203 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True)
204 success_msg = (
205 f"\n{typer.style('✓', fg=typer.colors.GREEN, bold=True)} Release tag {tag_styled} pushed to remote!"
206 )
207 console.success(success_msg)
208 console.info("The release workflow will now be triggered automatically.")
210 # Get repository URL for GitHub Actions link
211 result = run_git_command(["git", "remote", "get-url", "origin"])
212 repo_url = result.stdout.strip()
214 # Try to extract GitHub repository path for displaying the Actions URL
215 # Support both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) formats
216 repo_path = None
217 if repo_url.startswith("git@github.com:"):
218 # SSH format: git@github.com:user/repo.git
219 repo_path = repo_url[len("git@github.com:") :].rstrip(".git")
220 elif repo_url.startswith("https://github.com/"):
221 # HTTPS format: https://github.com/user/repo.git
222 repo_path = repo_url[len("https://github.com/") :].rstrip(".git")
224 if repo_path:
225 console.info(f"Monitor progress at: https://github.com/{repo_path}/actions")
228def _get_bump_type_interactively(
229 non_interactive: bool, bump_type: str | None, dry_run: bool, with_bump: bool = False, *, language: Language
230) -> tuple[bool, str | None]:
231 """Get bump version interactively or from parameters.
233 Uses the same interactive selection as the bump command to ensure consistent
234 behavior between ``rhiza-tools bump`` and ``rhiza-tools release --with-bump``.
236 Args:
237 non_interactive: If True, skip interactive prompts.
238 bump_type: Explicit bump type provided (e.g., "MAJOR", "MINOR", "PATCH").
239 dry_run: If True, the bump will be simulated (handled by caller).
240 with_bump: If True, enable interactive bump selection directly.
241 language: The programming language for version reading.
243 Returns:
244 Tuple of (should_bump, new_version_string). The version string is the
245 explicit new version (not a bump type keyword).
246 """
247 if bump_type:
248 return _resolve_explicit_bump_type(bump_type, language)
250 if with_bump:
251 return _resolve_with_bump_flag(non_interactive, language)
253 if not non_interactive:
254 return _resolve_interactive_prompt(language)
256 # Non-interactive without --with-bump or --bump: no bump
257 return False, None
260def _resolve_explicit_bump_type(bump_type: str, language: Language) -> tuple[bool, str | None]:
261 """Resolve version from an explicitly provided bump type.
263 Args:
264 bump_type: The bump type keyword (e.g., "MAJOR", "MINOR", "PATCH").
265 language: The programming language for version reading.
267 Returns:
268 Tuple of (True, new_version_string).
270 Raises:
271 typer.Exit: If the current version is invalid or the bump type is unsupported.
272 """
273 current_version_str = get_current_version(language)
274 try:
275 current_semver = semver.Version.parse(current_version_str)
276 except ValueError:
277 console.error(f"Invalid semantic version: {current_version_str}")
278 raise typer.Exit(code=1) from None
279 new_version = get_bumped_version_from_type(current_semver, bump_type.lower())
280 if not new_version:
281 console.error(f"Invalid bump type: {bump_type}")
282 raise typer.Exit(code=1)
283 return True, new_version
286def _resolve_with_bump_flag(non_interactive: bool, language: Language) -> tuple[bool, str | None]:
287 """Resolve version when --with-bump flag is set.
289 In non-interactive mode defaults to patch; otherwise prompts interactively.
291 Args:
292 non_interactive: If True, default to a patch bump.
293 language: The programming language for version reading.
295 Returns:
296 Tuple of (should_bump, new_version_string).
297 """
298 if non_interactive:
299 console.warning("--with-bump in non-interactive mode without --bump type, defaulting to patch")
300 current_version_str = get_current_version(language)
301 current_semver = semver.Version.parse(current_version_str)
302 return True, str(current_semver.bump_patch())
304 current_version_str = get_current_version(language)
305 try:
306 new_version = get_interactive_bump_type(current_version_str)
307 except (typer.Exit, EOFError):
308 return False, None
309 return True, new_version
312def _resolve_interactive_prompt(language: Language) -> tuple[bool, str | None]:
313 """Prompt the user interactively whether to bump before releasing.
315 Args:
316 language: The programming language for version reading.
318 Returns:
319 Tuple of (should_bump, new_version_string).
320 """
321 import questionary as qs
323 try:
324 should_bump = qs.confirm(
325 "Would you like to bump the version before releasing?",
326 default=False,
327 ).ask()
328 except EOFError:
329 logger.debug("Running in non-interactive environment")
330 return False, None
332 if not should_bump:
333 return False, None
335 current_version_str = get_current_version(language)
336 try:
337 new_version = get_interactive_bump_type(current_version_str)
338 except (typer.Exit, EOFError):
339 return False, None
340 return True, new_version
343def _perform_version_bump(new_version: str, dry_run: bool, language: Language) -> str:
344 """Perform version bump with validation.
346 Args:
347 new_version: The explicit new version string to bump to.
348 dry_run: If True, only simulate the bump.
349 language: The programming language for the bump.
351 Returns:
352 The new version string.
354 Raises:
355 typer.Exit: If the bump operation fails.
356 """
357 console.info(f"Bumping version to: {new_version}")
359 bump_command(
360 BumpOptions(
361 version=new_version,
362 dry_run=dry_run,
363 commit=True,
364 push=False, # Don't push yet, we'll do it after tagging
365 allow_dirty=False,
366 language=language,
367 )
368 )
370 if dry_run:
371 console.info("[DRY-RUN] Version would be bumped before release")
373 return new_version
376def _validate_tag_state(tag: str, current_version: str) -> None:
377 """Validate that tag exists locally but not remotely.
379 Args:
380 tag: Tag name to check.
381 current_version: Current version string.
383 Raises:
384 typer.Exit: If tag state is invalid.
385 """
386 exists_locally, exists_remotely = check_tag_exists(tag)
388 if exists_remotely:
389 console.error(f"Tag '{tag}' already exists on remote")
390 console.error(f"The release for version {current_version} has already been published.")
391 console.error("If this was unintentional, you can delete the remote tag and retry:")
392 console.error(f" git push origin :refs/tags/{tag}")
393 raise typer.Exit(code=1)
395 if not exists_locally:
396 console.error(f"Tag '{tag}' does not exist locally")
397 console.error("Create the tag by bumping the version with commit enabled:")
398 console.error(" rhiza-tools bump <version> --commit")
399 console.error("Or use release with --bump to do both at once:")
400 console.error(" rhiza-tools release --bump <PATCH|MINOR|MAJOR> --push")
401 raise typer.Exit(code=1)
403 console.success(f"Tag '{tag}' found locally")
405 # Show tag details
406 result = run_git_command(["git", "show", "-s", "--format=%H|%ci|%s", tag], check=False)
407 if result.returncode == 0 and result.stdout.strip():
408 parts = result.stdout.strip().split("|")
409 if len(parts) == 3:
410 commit_hash, commit_date, commit_msg = parts
411 console.info(f" Commit: {commit_hash[:8]}")
412 console.info(f" Date: {commit_date}")
413 console.info(f" Message: {commit_msg}")
416def _show_commits_since_last_tag(tag: str) -> None:
417 """Show commits included since the last tag.
419 Args:
420 tag: Current tag.
421 """
422 result = run_git_command(["git", "tag", "--sort=-version:refname", "--merged", "HEAD"], check=False)
423 if result.returncode != 0:
424 return
426 tags = [t.strip() for t in result.stdout.split("\n") if t.strip() and t.strip() != tag]
427 if not tags:
428 return
430 last_tag = tags[0] # Most recent tag (excluding current)
432 # Get commit list
433 log_result = run_git_command(
434 ["git", "log", f"{last_tag}..{tag}", "--oneline", "--no-decorate"],
435 check=False,
436 )
437 if log_result.returncode == 0 and log_result.stdout.strip():
438 commits = log_result.stdout.strip().split("\n")
439 console.info(f"\nCommits included in this release (since {last_tag}):")
440 for commit in commits[:10]: # Show first 10
441 console.info(f" • {commit}")
442 if len(commits) > 10:
443 console.info(f" ... and {len(commits) - 10} more")
446def _confirm_and_push_tag(
447 tag: str,
448 push: bool,
449 dry_run: bool,
450 non_interactive: bool,
451 bump_branch: str | None = None,
452) -> None:
453 """Confirm with user and push tag to remote.
455 When *bump_branch* is provided the bump commit is pushed to the remote
456 **before** the tag so that the tag references a commit that exists on
457 the remote.
459 Args:
460 tag: Tag to push.
461 push: If True, push without confirmation.
462 dry_run: If True, only simulate push.
463 non_interactive: If True, skip confirmation.
464 bump_branch: If set, push this branch first (bump commit).
466 Raises:
467 typer.Exit: If user declines to push.
468 """
469 should_push = push
470 if not non_interactive and not push:
471 should_push = typer.confirm("Push tag to remote and trigger release workflow?", default=False)
472 if not should_push:
473 console.info("Release cancelled by user")
474 raise typer.Exit(code=0)
476 if should_push:
477 if dry_run:
478 if bump_branch:
479 console.info(f"[DRY-RUN] Would push bump commit on '{bump_branch}' to remote")
480 console.info(f"[DRY-RUN] Would push tag '{tag}' to remote")
481 else:
482 # Push the bump commit first so the tag references a known commit
483 if bump_branch:
484 console.info("Pushing bump commit to remote...")
485 run_git_command(["git", "push", "origin", bump_branch])
486 push_tag(tag, dry_run=False, non_interactive=non_interactive or push)
489def _get_release_version(dry_run: bool, bumped_new_version: str | None, language: Language) -> tuple[str, str]:
490 """Get current version and tag for release.
492 Args:
493 dry_run: If True and version was bumped, use bumped version.
494 bumped_new_version: New version if bump was performed.
495 language: The programming language for version reading.
497 Returns:
498 Tuple of (current_version, tag).
499 """
500 current_version = bumped_new_version if dry_run and bumped_new_version else get_current_version(language)
502 tag = f"v{current_version}"
503 console.info(f"Current version: {current_version}")
504 console.info(f"Expected tag: {tag}")
506 return current_version, tag
509def _check_repository_state(dry_run: bool, current_branch: str, default_branch: str) -> None:
510 """Check repository state before release.
512 Args:
513 dry_run: If True, skip some checks.
514 current_branch: Current git branch.
515 default_branch: Default git branch.
516 """
517 # Note if not on default branch
518 if current_branch != default_branch:
519 console.info(f"Note: You are on branch '{current_branch}' (default branch is '{default_branch}')")
521 # Check for uncommitted changes (skip in dry-run mode)
522 if not dry_run:
523 check_clean_working_tree()
524 check_branch_status(current_branch)
527def _handle_tag_validation(dry_run: bool, bumped_new_version: str | None, tag: str, current_version: str) -> None:
528 """Validate tag state before release.
530 Args:
531 dry_run: If True and version was bumped, use relaxed validation.
532 bumped_new_version: New version if bump was performed.
533 tag: Tag name to validate.
534 current_version: Current version string.
536 Raises:
537 typer.Exit: If tag validation fails.
538 """
539 if dry_run and bumped_new_version:
540 # In dry-run with bump, the tag won't exist yet - just check it's not already on remote
541 _, exists_remotely = check_tag_exists(tag)
542 if exists_remotely:
543 console.error(f"Tag '{tag}' already exists on remote")
544 console.error(f"The release for version {current_version} has already been published.")
545 console.error("If this was unintentional, you can delete the remote tag and retry:")
546 console.error(f" git push origin :refs/tags/{tag}")
547 raise typer.Exit(code=1)
548 console.info(f"[DRY-RUN] Tag '{tag}' would be created by the bump and release process")
549 else:
550 _validate_tag_state(tag, current_version)
553def release_command(
554 bump_type: str | None = None,
555 push: bool = False,
556 dry_run: bool = False,
557 non_interactive: bool = False,
558 with_bump: bool = False,
559 language: Language | None = None,
560) -> None:
561 """Push a release tag to remote.
563 This command performs the following steps:
564 1. Detects the project language (Python or Go) unless explicitly specified
565 2. Optionally bumps the version if bump_type is provided or with_bump is True
566 3. Reads the current version from pyproject.toml (Python) or VERSION file (Go)
567 4. Validates the git repository state (clean working tree, up-to-date with remote)
568 5. Checks that a tag exists for the current version (created by bump-my-version)
569 6. Pushes the tag to remote, triggering the release workflow
571 Args:
572 bump_type: Optional bump type (MAJOR, MINOR, PATCH) to apply before release.
573 push: If True, push changes without prompting.
574 dry_run: If True, show what would be done without making any changes.
575 non_interactive: If True, skip all confirmation prompts.
576 with_bump: If True, enable interactive bump selection (works with dry-run).
577 language: Programming language (python or go). Auto-detected if not specified.
579 Raises:
580 typer.Exit: If no supported project files are found, repository is not clean,
581 tag doesn't exist, or any git operations fail.
583 Example:
584 Push a release tag::
586 release_command()
588 Preview what would happen::
590 release_command(dry_run=True)
592 Non-interactive mode::
594 release_command(non_interactive=True)
596 Bump and release::
598 release_command(bump_type="MINOR", push=True)
600 Interactive bump with dry-run::
602 release_command(with_bump=True, push=True, dry_run=True)
604 Release a Go project::
606 release_command(language=Language.GO)
607 """
608 # Detect or validate project language
609 if language is None:
610 language = Language.detect()
611 if language is None:
612 console.error("No supported project files found in current directory.")
613 console.error("Python projects need pyproject.toml; Go projects need go.mod and VERSION.")
614 raise typer.Exit(code=1)
615 else:
616 from rhiza_tools.commands.bump import _validate_project_exists
618 _validate_project_exists(language)
620 # Get current branch early
621 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
622 current_branch = result.stdout.strip()
623 console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}")
625 # Interactive mode: ask if user wants to bump version
626 should_bump, new_version = _get_bump_type_interactively(
627 non_interactive, bump_type, dry_run, with_bump, language=language
628 )
630 # ── Preflight validation: check everything BEFORE making any changes ──
631 default_branch = get_default_branch()
632 _check_repository_state(dry_run, current_branch, default_branch)
634 # If bumping, pre-validate that the new tag won't conflict with remote
635 if should_bump and new_version and not dry_run:
636 new_tag = f"v{new_version}"
637 _, exists_remotely = check_tag_exists(new_tag)
638 if exists_remotely:
639 console.error(f"Tag '{new_tag}' already exists on remote")
640 console.error(f"The release for version {new_version} has already been published.")
641 console.error("No changes were made. To resolve:")
642 console.error(f" Delete the remote tag: git push origin :refs/tags/{new_tag}")
643 console.error(" Or choose a different version to bump to.")
644 raise typer.Exit(code=1)
645 console.success(f"Preflight: tag '{new_tag}' is available on remote")
647 # ── Execute: all preflight checks passed, safe to make changes ──
649 # Perform bump if requested (bump_command runs its own internal preflight)
650 bumped_new_version: str | None = None
651 if should_bump and new_version:
652 bumped_new_version = _perform_version_bump(new_version, dry_run, language)
654 # Get current version and tag
655 current_version, tag = _get_release_version(dry_run, bumped_new_version, language)
657 # Validate tag state (for non-bump cases, ensures local tag exists)
658 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version)
660 # Push tag
661 console.info("Preparing to push tag to remote...")
662 console.info(f"Pushing tag '{tag}' to origin will trigger the release workflow.")
664 # Show commits since last tag (if any)
665 _show_commits_since_last_tag(tag)
667 # Confirm and push (bump commit + tag together)
668 _confirm_and_push_tag(
669 tag, push, dry_run, non_interactive, bump_branch=current_branch if bumped_new_version else None
670 )
672 if dry_run:
673 console.info("[DRY-RUN] Release process completed (no changes made)")
674 else:
675 console.success("Release process completed successfully!")