Coverage for src / rhiza_tools / commands / release.py: 100%
259 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 02:21 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 02:21 +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"""
25from pathlib import Path
27import semver
28import typer
29from loguru import logger
31from rhiza_tools import console
32from rhiza_tools.commands._shared import (
33 NON_INTERACTIVE_ERRORS,
34 run_git_command,
35)
36from rhiza_tools.commands.bump import (
37 BumpOptions,
38 Language,
39 bump_command,
40 get_bumped_version_from_type,
41 get_current_version,
42 get_interactive_bump_type,
43)
45# Combined tuple for catching both typer.Exit and non-interactive environment errors.
46_EXIT_OR_NON_INTERACTIVE: tuple[type[BaseException], ...] = (typer.Exit, *NON_INTERACTIVE_ERRORS)
49def check_clean_working_tree() -> None:
50 """Verify that the working tree is clean (no uncommitted changes).
52 Raises:
53 typer.Exit: If there are uncommitted changes in the working tree.
55 Example:
56 >>> check_clean_working_tree() # doctest: +SKIP
57 """
58 result = run_git_command(["git", "status", "--porcelain"])
59 if result.stdout.strip():
60 console.error("You have uncommitted changes:")
61 console.error(result.stdout)
62 console.error("Please commit or stash your changes before releasing.")
63 raise typer.Exit(code=1)
66def check_branch_status(current_branch: str) -> None:
67 """Check if the current branch is up-to-date with remote.
69 Args:
70 current_branch: The name of the current git branch.
72 Raises:
73 typer.Exit: If branch is behind remote or has diverged.
75 Example:
76 >>> check_branch_status("main") # doctest: +SKIP
77 """
78 # Fetch latest from remote
79 console.info("Checking remote status...")
80 run_git_command(["git", "fetch", "origin"])
82 # Get upstream tracking branch
83 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], check=False)
84 if result.returncode != 0:
85 console.error(f"No upstream branch configured for {current_branch}")
86 console.error(f"Set upstream with: git push -u origin {current_branch}")
87 raise typer.Exit(code=1)
89 upstream = result.stdout.strip()
91 # Get commit hashes
92 local = run_git_command(["git", "rev-parse", "@"]).stdout.strip()
93 remote = run_git_command(["git", "rev-parse", upstream]).stdout.strip()
94 base = run_git_command(["git", "merge-base", "@", upstream]).stdout.strip()
96 if local != remote:
97 if local == base:
98 # Local is behind remote (need to pull)
99 console.error(f"Your branch is behind '{upstream}'.")
100 console.error("Pull the latest changes before releasing:")
101 console.error(f" git pull origin {current_branch}")
102 raise typer.Exit(code=1)
103 else:
104 # Either local is ahead of remote OR branches have diverged
105 # Check if remote == base to distinguish between the two cases
106 if remote == base:
107 # Local is ahead of remote (need to push)
108 console.warning(f"Your branch is ahead of '{upstream}'.")
109 console.info("Unpushed commits:")
110 result = run_git_command(["git", "log", "--oneline", "--graph", "--decorate", f"{upstream}..HEAD"])
111 console.info(result.stdout)
112 console.warning("Please push changes to remote before releasing.")
113 raise typer.Exit(code=1)
114 else:
115 # Branches have diverged (need to merge or rebase)
116 console.error(f"Your branch has diverged from '{upstream}'.")
117 console.error("To reconcile, choose one of:")
118 console.error(f" Rebase: git pull --rebase origin {current_branch}")
119 console.error(f" Merge: git merge origin/{current_branch}")
120 console.error("Then resolve any conflicts and retry.")
121 raise typer.Exit(code=1)
124def get_default_branch() -> str:
125 """Get the default branch name from the remote repository.
127 Returns:
128 The name of the default branch (e.g., "main" or "master").
130 Raises:
131 typer.Exit: If the default branch cannot be determined.
133 Example:
134 >>> branch = get_default_branch() # doctest: +SKIP
135 >>> print(branch) # doctest: +SKIP
136 main
137 """
138 result = run_git_command(["git", "remote", "show", "origin"], check=False)
139 if result.returncode != 0:
140 console.error("Could not determine default branch from remote")
141 raise typer.Exit(code=1)
143 for line in result.stdout.split("\n"):
144 if "HEAD branch" in line:
145 return str(line.split()[-1])
147 console.error("Could not determine default branch from remote")
148 raise typer.Exit(code=1)
151def check_tag_exists(tag: str) -> tuple[bool, bool]:
152 """Check if a tag exists locally and/or remotely.
154 Args:
155 tag: The tag name to check.
157 Returns:
158 Tuple of (exists_locally, exists_remotely).
160 Example:
161 >>> local, remote = check_tag_exists("v1.0.0") # doctest: +SKIP
162 >>> if remote: # doctest: +SKIP
163 ... print("Tag already released") # doctest: +SKIP
164 """
165 # Check local
166 result = run_git_command(["git", "rev-parse", tag], check=False)
167 exists_locally = result.returncode == 0
169 # Check remote
170 result = run_git_command(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"], check=False)
171 exists_remotely = result.returncode == 0
173 return exists_locally, exists_remotely
176def push_tag(tag: str, dry_run: bool = False, non_interactive: bool = False) -> None:
177 """Push a git tag to the remote repository.
179 Args:
180 tag: The tag name to push.
181 dry_run: If True, only show what would be done.
182 non_interactive: If True, skip confirmation prompts.
184 Raises:
185 typer.Exit: If push fails.
187 Example:
188 >>> push_tag("v1.0.0") # doctest: +SKIP
189 """
190 command = ["git", "push", "origin", f"refs/tags/{tag}"]
192 if dry_run:
193 dry_run_header = typer.style("[DRY-RUN] Would execute:", fg=typer.colors.YELLOW, bold=True)
194 console.info(f"\n{dry_run_header} {' '.join(command)}")
196 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True)
197 console.info(f"[DRY-RUN] Release tag {tag_styled} would be pushed to remote")
198 console.info("[DRY-RUN] This would trigger the release workflow")
200 # Show what would be pushed
201 result = run_git_command(["git", "show", "-s", "--format=%H %s", tag], check=False)
202 if result.returncode == 0 and result.stdout.strip():
203 console.info(f"[DRY-RUN] Tag points to: {result.stdout.strip()}")
204 else:
205 console.info(f"\n{typer.style('Pushing tag to remote...', fg=typer.colors.CYAN, bold=True)}")
206 console.info(f"Command: {' '.join(command)}")
207 run_git_command(command)
209 tag_styled = typer.style(tag, fg=typer.colors.GREEN, bold=True)
210 success_msg = (
211 f"\n{typer.style('✓', fg=typer.colors.GREEN, bold=True)} Release tag {tag_styled} pushed to remote!"
212 )
213 console.success(success_msg)
214 console.info("The release workflow will now be triggered automatically.")
216 # Get repository URL for GitHub Actions link
217 result = run_git_command(["git", "remote", "get-url", "origin"])
218 repo_url = result.stdout.strip()
220 # Try to extract GitHub repository path for displaying the Actions URL
221 # Support both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) formats
222 repo_path = None
223 if repo_url.startswith("git@github.com:"):
224 # SSH format: git@github.com:user/repo.git
225 repo_path = repo_url[len("git@github.com:") :].rstrip(".git")
226 elif repo_url.startswith("https://github.com/"):
227 # HTTPS format: https://github.com/user/repo.git
228 repo_path = repo_url[len("https://github.com/") :].rstrip(".git")
230 if repo_path:
231 console.info(f"Monitor progress at: https://github.com/{repo_path}/actions")
234def _get_bump_type_interactively(
235 non_interactive: bool, bump_type: str | None, dry_run: bool, with_bump: bool = False, *, language: Language
236) -> tuple[bool, str | None]:
237 """Get bump version interactively or from parameters.
239 Uses the same interactive selection as the bump command to ensure consistent
240 behavior between ``rhiza-tools bump`` and ``rhiza-tools release --with-bump``.
242 Args:
243 non_interactive: If True, skip interactive prompts.
244 bump_type: Explicit bump type provided (e.g., "MAJOR", "MINOR", "PATCH").
245 dry_run: If True, the bump will be simulated (handled by caller).
246 with_bump: If True, enable interactive bump selection directly.
247 language: The programming language for version reading.
249 Returns:
250 Tuple of (should_bump, new_version_string). The version string is the
251 explicit new version (not a bump type keyword).
252 """
253 if bump_type:
254 return _resolve_explicit_bump_type(bump_type, language)
256 if with_bump:
257 return _resolve_with_bump_flag(non_interactive, language)
259 if not non_interactive:
260 return _resolve_interactive_prompt(language)
262 # Non-interactive without --with-bump or --bump: no bump
263 return False, None
266def _resolve_explicit_bump_type(bump_type: str, language: Language) -> tuple[bool, str | None]:
267 """Resolve version from an explicitly provided bump type.
269 Args:
270 bump_type: The bump type keyword (e.g., "MAJOR", "MINOR", "PATCH").
271 language: The programming language for version reading.
273 Returns:
274 Tuple of (True, new_version_string).
276 Raises:
277 typer.Exit: If the current version is invalid or the bump type is unsupported.
278 """
279 current_version_str = get_current_version(language)
280 try:
281 current_semver = semver.Version.parse(current_version_str)
282 except ValueError:
283 console.error(f"Invalid semantic version: {current_version_str}")
284 raise typer.Exit(code=1) from None
285 new_version = get_bumped_version_from_type(current_semver, bump_type.lower())
286 if not new_version:
287 console.error(f"Invalid bump type: {bump_type}")
288 raise typer.Exit(code=1)
289 return True, new_version
292def _resolve_with_bump_flag(non_interactive: bool, language: Language) -> tuple[bool, str | None]:
293 """Resolve version when --with-bump flag is set.
295 In non-interactive mode defaults to patch; otherwise prompts interactively.
297 Args:
298 non_interactive: If True, default to a patch bump.
299 language: The programming language for version reading.
301 Returns:
302 Tuple of (should_bump, new_version_string).
303 """
304 if non_interactive:
305 console.warning("--with-bump in non-interactive mode without --bump type, defaulting to patch")
306 current_version_str = get_current_version(language)
307 current_semver = semver.Version.parse(current_version_str)
308 return True, str(current_semver.bump_patch())
310 current_version_str = get_current_version(language)
311 try:
312 new_version = get_interactive_bump_type(current_version_str)
313 except _EXIT_OR_NON_INTERACTIVE:
314 return False, None
315 return True, new_version
318def _resolve_interactive_prompt(language: Language) -> tuple[bool, str | None]:
319 """Prompt the user interactively whether to bump before releasing.
321 Args:
322 language: The programming language for version reading.
324 Returns:
325 Tuple of (should_bump, new_version_string).
326 """
327 import questionary as qs
329 try:
330 should_bump = qs.confirm(
331 "Would you like to bump the version before releasing?",
332 default=False,
333 ).ask()
334 except NON_INTERACTIVE_ERRORS:
335 logger.debug("Running in non-interactive environment")
336 return False, None
338 if not should_bump:
339 return False, None
341 current_version_str = get_current_version(language)
342 try:
343 new_version = get_interactive_bump_type(current_version_str)
344 except _EXIT_OR_NON_INTERACTIVE:
345 return False, None
346 return True, new_version
349def _perform_version_bump(new_version: str, dry_run: bool, language: Language, config: Path | None = None) -> str:
350 """Perform version bump with validation.
352 Args:
353 new_version: The explicit new version string to bump to.
354 dry_run: If True, only simulate the bump.
355 language: The programming language for the bump.
356 config: Optional path to the .cfg.toml bumpversion config file.
358 Returns:
359 The new version string.
361 Raises:
362 typer.Exit: If the bump operation fails.
363 """
364 console.info(f"Bumping version to: {new_version}")
366 bump_command(
367 BumpOptions(
368 version=new_version,
369 dry_run=dry_run,
370 commit=True,
371 push=False, # Don't push yet, we'll do it after tagging
372 allow_dirty=False,
373 language=language,
374 config=config,
375 )
376 )
378 if dry_run:
379 console.info("[DRY-RUN] Version would be bumped before release")
381 return new_version
384def _validate_tag_state(tag: str, current_version: str) -> None:
385 """Validate that tag exists locally but not remotely.
387 Args:
388 tag: Tag name to check.
389 current_version: Current version string.
391 Raises:
392 typer.Exit: If tag state is invalid.
393 """
394 exists_locally, exists_remotely = check_tag_exists(tag)
396 if exists_remotely:
397 console.error(f"Tag '{tag}' already exists on remote")
398 console.error(f"The release for version {current_version} has already been published.")
399 console.error("If this was unintentional, you can delete the remote tag and retry:")
400 console.error(f" git push origin :refs/tags/{tag}")
401 raise typer.Exit(code=1)
403 if not exists_locally:
404 console.error(f"Tag '{tag}' does not exist locally")
405 console.error("Create the tag by bumping the version with commit enabled:")
406 console.error(" rhiza-tools bump <version> --commit")
407 console.error("Or use release with --bump to do both at once:")
408 console.error(" rhiza-tools release --bump <PATCH|MINOR|MAJOR> --push")
409 raise typer.Exit(code=1)
411 console.success(f"Tag '{tag}' found locally")
413 # Show tag details
414 result = run_git_command(["git", "show", "-s", "--format=%H|%ci|%s", tag], check=False)
415 if result.returncode == 0 and result.stdout.strip():
416 parts = result.stdout.strip().split("|")
417 if len(parts) == 3:
418 commit_hash, commit_date, commit_msg = parts
419 console.info(f" Commit: {commit_hash[:8]}")
420 console.info(f" Date: {commit_date}")
421 console.info(f" Message: {commit_msg}")
424def _show_commits_since_last_tag(tag: str) -> None:
425 """Show commits included since the last tag.
427 Args:
428 tag: Current tag.
429 """
430 result = run_git_command(["git", "tag", "--sort=-version:refname", "--merged", "HEAD"], check=False)
431 if result.returncode != 0:
432 return
434 tags = [t.strip() for t in result.stdout.split("\n") if t.strip() and t.strip() != tag]
435 if not tags:
436 return
438 last_tag = tags[0] # Most recent tag (excluding current)
440 # Get commit list
441 log_result = run_git_command(
442 ["git", "log", f"{last_tag}..{tag}", "--oneline", "--no-decorate"],
443 check=False,
444 )
445 if log_result.returncode == 0 and log_result.stdout.strip():
446 commits = log_result.stdout.strip().split("\n")
447 console.info(f"\nCommits included in this release (since {last_tag}):")
448 for commit in commits[:10]: # Show first 10
449 console.info(f" • {commit}")
450 if len(commits) > 10:
451 console.info(f" ... and {len(commits) - 10} more")
454def _confirm_and_push_tag(
455 tag: str,
456 push: bool,
457 dry_run: bool,
458 non_interactive: bool,
459 bump_branch: str | None = None,
460) -> None:
461 """Confirm with user and push tag to remote.
463 When *bump_branch* is provided the bump commit is pushed to the remote
464 **before** the tag so that the tag references a commit that exists on
465 the remote.
467 Args:
468 tag: Tag to push.
469 push: If True, push without confirmation.
470 dry_run: If True, only simulate push.
471 non_interactive: If True, skip confirmation.
472 bump_branch: If set, push this branch first (bump commit).
474 Raises:
475 typer.Exit: If user declines to push.
476 """
477 should_push = push
478 if not non_interactive and not push:
479 should_push = typer.confirm("Push tag to remote and trigger release workflow?", default=False)
480 if not should_push:
481 console.info("Release cancelled by user")
482 raise typer.Exit(code=0)
484 if should_push:
485 if dry_run:
486 if bump_branch:
487 console.info(f"[DRY-RUN] Would push bump commit on '{bump_branch}' to remote")
488 console.info(f"[DRY-RUN] Would push tag '{tag}' to remote")
489 else:
490 # Push the bump commit first so the tag references a known commit
491 if bump_branch:
492 console.info("Pushing bump commit to remote...")
493 run_git_command(["git", "push", "origin", bump_branch])
494 push_tag(tag, dry_run=False, non_interactive=non_interactive or push)
497def _get_release_version(dry_run: bool, bumped_new_version: str | None, language: Language) -> tuple[str, str]:
498 """Get current version and tag for release.
500 Args:
501 dry_run: If True and version was bumped, use bumped version.
502 bumped_new_version: New version if bump was performed.
503 language: The programming language for version reading.
505 Returns:
506 Tuple of (current_version, tag).
507 """
508 current_version = bumped_new_version if dry_run and bumped_new_version else get_current_version(language)
510 tag = f"v{current_version}"
511 console.info(f"Current version: {current_version}")
512 console.info(f"Expected tag: {tag}")
514 return current_version, tag
517def _check_repository_state(dry_run: bool, current_branch: str, default_branch: str) -> None:
518 """Check repository state before release.
520 Args:
521 dry_run: If True, skip some checks.
522 current_branch: Current git branch.
523 default_branch: Default git branch.
524 """
525 # Note if not on default branch
526 if current_branch != default_branch:
527 console.info(f"Note: You are on branch '{current_branch}' (default branch is '{default_branch}')")
529 # Check for uncommitted changes (skip in dry-run mode)
530 if not dry_run:
531 check_clean_working_tree()
532 check_branch_status(current_branch)
535def _handle_tag_validation(dry_run: bool, bumped_new_version: str | None, tag: str, current_version: str) -> None:
536 """Validate tag state before release.
538 Args:
539 dry_run: If True and version was bumped, use relaxed validation.
540 bumped_new_version: New version if bump was performed.
541 tag: Tag name to validate.
542 current_version: Current version string.
544 Raises:
545 typer.Exit: If tag validation fails.
546 """
547 if dry_run and bumped_new_version:
548 # In dry-run with bump, the tag won't exist yet - just check it's not already on remote
549 _, exists_remotely = check_tag_exists(tag)
550 if exists_remotely:
551 console.error(f"Tag '{tag}' already exists on remote")
552 console.error(f"The release for version {current_version} has already been published.")
553 console.error("If this was unintentional, you can delete the remote tag and retry:")
554 console.error(f" git push origin :refs/tags/{tag}")
555 raise typer.Exit(code=1)
556 console.info(f"[DRY-RUN] Tag '{tag}' would be created by the bump and release process")
557 else:
558 _validate_tag_state(tag, current_version)
561def release_command(
562 bump_type: str | None = None,
563 push: bool = False,
564 dry_run: bool = False,
565 non_interactive: bool = False,
566 with_bump: bool = False,
567 language: Language | None = None,
568 config: Path | None = None,
569) -> None:
570 """Push a release tag to remote.
572 This command performs the following steps:
573 1. Detects the project language (Python or Go) unless explicitly specified
574 2. Optionally bumps the version if bump_type is provided or with_bump is True
575 3. Reads the current version from pyproject.toml (Python) or VERSION file (Go)
576 4. Validates the git repository state (clean working tree, up-to-date with remote)
577 5. Checks that a tag exists for the current version (created by bump-my-version)
578 6. Pushes the tag to remote, triggering the release workflow
580 Args:
581 bump_type: Optional bump type (MAJOR, MINOR, PATCH) to apply before release.
582 push: If True, push changes without prompting.
583 dry_run: If True, show what would be done without making any changes.
584 non_interactive: If True, skip all confirmation prompts.
585 with_bump: If True, enable interactive bump selection (works with dry-run).
586 language: Programming language (python or go). Auto-detected if not specified.
587 config: Optional path to the .cfg.toml bumpversion config file.
589 Raises:
590 typer.Exit: If no supported project files are found, repository is not clean,
591 tag doesn't exist, or any git operations fail.
593 Example:
594 Push a release tag::
596 release_command()
598 Preview what would happen::
600 release_command(dry_run=True)
602 Non-interactive mode::
604 release_command(non_interactive=True)
606 Bump and release::
608 release_command(bump_type="MINOR", push=True)
610 Interactive bump with dry-run::
612 release_command(with_bump=True, push=True, dry_run=True)
614 Release a Go project::
616 release_command(language=Language.GO)
617 """
618 # Detect or validate project language
619 if language is None:
620 language = Language.detect()
621 if language is None:
622 console.error("No supported project files found in current directory.")
623 console.error("Python projects need pyproject.toml; Go projects need go.mod and VERSION.")
624 raise typer.Exit(code=1)
625 else:
626 from rhiza_tools.commands.bump import _validate_project_exists
628 _validate_project_exists(language)
630 # Get current branch early
631 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
632 current_branch = result.stdout.strip()
633 console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}")
635 # Interactive mode: ask if user wants to bump version
636 should_bump, new_version = _get_bump_type_interactively(
637 non_interactive, bump_type, dry_run, with_bump, language=language
638 )
640 # ── Preflight validation: check everything BEFORE making any changes ──
641 default_branch = get_default_branch()
642 _check_repository_state(dry_run, current_branch, default_branch)
644 # If bumping, pre-validate that the new tag won't conflict with remote
645 if should_bump and new_version and not dry_run:
646 new_tag = f"v{new_version}"
647 _, exists_remotely = check_tag_exists(new_tag)
648 if exists_remotely:
649 console.error(f"Tag '{new_tag}' already exists on remote")
650 console.error(f"The release for version {new_version} has already been published.")
651 console.error("No changes were made. To resolve:")
652 console.error(f" Delete the remote tag: git push origin :refs/tags/{new_tag}")
653 console.error(" Or choose a different version to bump to.")
654 raise typer.Exit(code=1)
655 console.success(f"Preflight: tag '{new_tag}' is available on remote")
657 # ── Execute: all preflight checks passed, safe to make changes ──
659 # Perform bump if requested (bump_command runs its own internal preflight)
660 bumped_new_version: str | None = None
661 if should_bump and new_version:
662 bumped_new_version = _perform_version_bump(new_version, dry_run, language, config)
664 # Get current version and tag
665 current_version, tag = _get_release_version(dry_run, bumped_new_version, language)
667 # Validate tag state (for non-bump cases, ensures local tag exists)
668 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version)
670 # Push tag
671 console.info("Preparing to push tag to remote...")
672 console.info(f"Pushing tag '{tag}' to origin will trigger the release workflow.")
674 # Show commits since last tag (if any)
675 _show_commits_since_last_tag(tag)
677 # Confirm and push (bump commit + tag together)
678 _confirm_and_push_tag(
679 tag, push, dry_run, non_interactive, bump_branch=current_branch if bumped_new_version else None
680 )
682 if dry_run:
683 console.info("[DRY-RUN] Release process completed (no changes made)")
684 else:
685 console.success("Release process completed successfully!")