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

1"""Command to push release tags to remote. 

2 

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. 

6 

7Supports Python projects (pyproject.toml) and Go projects (go.mod + VERSION file). 

8The project language is auto-detected when not explicitly specified. 

9 

10Example: 

11 Push a release tag:: 

12 

13 from rhiza_tools.commands.release import release_command 

14 release_command() 

15 

16 Dry run to preview release:: 

17 

18 release_command(dry_run=True) 

19 

20 Release a Go project:: 

21 

22 release_command(language=Language.GO) 

23""" 

24 

25import semver 

26import typer 

27from loguru import logger 

28 

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) 

41 

42 

43def check_clean_working_tree() -> None: 

44 """Verify that the working tree is clean (no uncommitted changes). 

45 

46 Raises: 

47 typer.Exit: If there are uncommitted changes in the working tree. 

48 

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) 

58 

59 

60def check_branch_status(current_branch: str) -> None: 

61 """Check if the current branch is up-to-date with remote. 

62 

63 Args: 

64 current_branch: The name of the current git branch. 

65 

66 Raises: 

67 typer.Exit: If branch is behind remote or has diverged. 

68 

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"]) 

75 

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) 

82 

83 upstream = result.stdout.strip() 

84 

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() 

89 

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) 

116 

117 

118def get_default_branch() -> str: 

119 """Get the default branch name from the remote repository. 

120 

121 Returns: 

122 The name of the default branch (e.g., "main" or "master"). 

123 

124 Raises: 

125 typer.Exit: If the default branch cannot be determined. 

126 

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) 

136 

137 for line in result.stdout.split("\n"): 

138 if "HEAD branch" in line: 

139 return str(line.split()[-1]) 

140 

141 console.error("Could not determine default branch from remote") 

142 raise typer.Exit(code=1) 

143 

144 

145def check_tag_exists(tag: str) -> tuple[bool, bool]: 

146 """Check if a tag exists locally and/or remotely. 

147 

148 Args: 

149 tag: The tag name to check. 

150 

151 Returns: 

152 Tuple of (exists_locally, exists_remotely). 

153 

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 

162 

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 

166 

167 return exists_locally, exists_remotely 

168 

169 

170def push_tag(tag: str, dry_run: bool = False, non_interactive: bool = False) -> None: 

171 """Push a git tag to the remote repository. 

172 

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. 

177 

178 Raises: 

179 typer.Exit: If push fails. 

180 

181 Example: 

182 >>> push_tag("v1.0.0") # doctest: +SKIP 

183 """ 

184 command = ["git", "push", "origin", f"refs/tags/{tag}"] 

185 

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)}") 

189 

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") 

193 

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) 

202 

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.") 

209 

210 # Get repository URL for GitHub Actions link 

211 result = run_git_command(["git", "remote", "get-url", "origin"]) 

212 repo_url = result.stdout.strip() 

213 

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") 

223 

224 if repo_path: 

225 console.info(f"Monitor progress at: https://github.com/{repo_path}/actions") 

226 

227 

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. 

232 

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``. 

235 

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. 

242 

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) 

249 

250 if with_bump: 

251 return _resolve_with_bump_flag(non_interactive, language) 

252 

253 if not non_interactive: 

254 return _resolve_interactive_prompt(language) 

255 

256 # Non-interactive without --with-bump or --bump: no bump 

257 return False, None 

258 

259 

260def _resolve_explicit_bump_type(bump_type: str, language: Language) -> tuple[bool, str | None]: 

261 """Resolve version from an explicitly provided bump type. 

262 

263 Args: 

264 bump_type: The bump type keyword (e.g., "MAJOR", "MINOR", "PATCH"). 

265 language: The programming language for version reading. 

266 

267 Returns: 

268 Tuple of (True, new_version_string). 

269 

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 

284 

285 

286def _resolve_with_bump_flag(non_interactive: bool, language: Language) -> tuple[bool, str | None]: 

287 """Resolve version when --with-bump flag is set. 

288 

289 In non-interactive mode defaults to patch; otherwise prompts interactively. 

290 

291 Args: 

292 non_interactive: If True, default to a patch bump. 

293 language: The programming language for version reading. 

294 

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()) 

303 

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 

310 

311 

312def _resolve_interactive_prompt(language: Language) -> tuple[bool, str | None]: 

313 """Prompt the user interactively whether to bump before releasing. 

314 

315 Args: 

316 language: The programming language for version reading. 

317 

318 Returns: 

319 Tuple of (should_bump, new_version_string). 

320 """ 

321 import questionary as qs 

322 

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 

331 

332 if not should_bump: 

333 return False, None 

334 

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 

341 

342 

343def _perform_version_bump(new_version: str, dry_run: bool, language: Language) -> str: 

344 """Perform version bump with validation. 

345 

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. 

350 

351 Returns: 

352 The new version string. 

353 

354 Raises: 

355 typer.Exit: If the bump operation fails. 

356 """ 

357 console.info(f"Bumping version to: {new_version}") 

358 

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 ) 

369 

370 if dry_run: 

371 console.info("[DRY-RUN] Version would be bumped before release") 

372 

373 return new_version 

374 

375 

376def _validate_tag_state(tag: str, current_version: str) -> None: 

377 """Validate that tag exists locally but not remotely. 

378 

379 Args: 

380 tag: Tag name to check. 

381 current_version: Current version string. 

382 

383 Raises: 

384 typer.Exit: If tag state is invalid. 

385 """ 

386 exists_locally, exists_remotely = check_tag_exists(tag) 

387 

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) 

394 

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) 

402 

403 console.success(f"Tag '{tag}' found locally") 

404 

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}") 

414 

415 

416def _show_commits_since_last_tag(tag: str) -> None: 

417 """Show commits included since the last tag. 

418 

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 

425 

426 tags = [t.strip() for t in result.stdout.split("\n") if t.strip() and t.strip() != tag] 

427 if not tags: 

428 return 

429 

430 last_tag = tags[0] # Most recent tag (excluding current) 

431 

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") 

444 

445 

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. 

454 

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. 

458 

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). 

465 

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) 

475 

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) 

487 

488 

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. 

491 

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. 

496 

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) 

501 

502 tag = f"v{current_version}" 

503 console.info(f"Current version: {current_version}") 

504 console.info(f"Expected tag: {tag}") 

505 

506 return current_version, tag 

507 

508 

509def _check_repository_state(dry_run: bool, current_branch: str, default_branch: str) -> None: 

510 """Check repository state before release. 

511 

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}')") 

520 

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) 

525 

526 

527def _handle_tag_validation(dry_run: bool, bumped_new_version: str | None, tag: str, current_version: str) -> None: 

528 """Validate tag state before release. 

529 

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. 

535 

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) 

551 

552 

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. 

562 

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 

570 

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. 

578 

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. 

582 

583 Example: 

584 Push a release tag:: 

585 

586 release_command() 

587 

588 Preview what would happen:: 

589 

590 release_command(dry_run=True) 

591 

592 Non-interactive mode:: 

593 

594 release_command(non_interactive=True) 

595 

596 Bump and release:: 

597 

598 release_command(bump_type="MINOR", push=True) 

599 

600 Interactive bump with dry-run:: 

601 

602 release_command(with_bump=True, push=True, dry_run=True) 

603 

604 Release a Go project:: 

605 

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 

617 

618 _validate_project_exists(language) 

619 

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)}") 

624 

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 ) 

629 

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) 

633 

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") 

646 

647 # ── Execute: all preflight checks passed, safe to make changes ── 

648 

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) 

653 

654 # Get current version and tag 

655 current_version, tag = _get_release_version(dry_run, bumped_new_version, language) 

656 

657 # Validate tag state (for non-bump cases, ensures local tag exists) 

658 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version) 

659 

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.") 

663 

664 # Show commits since last tag (if any) 

665 _show_commits_since_last_tag(tag) 

666 

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 ) 

671 

672 if dry_run: 

673 console.info("[DRY-RUN] Release process completed (no changes made)") 

674 else: 

675 console.success("Release process completed successfully!")