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

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 

25from pathlib import Path 

26 

27import semver 

28import typer 

29from loguru import logger 

30 

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) 

44 

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) 

47 

48 

49def check_clean_working_tree() -> None: 

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

51 

52 Raises: 

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

54 

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) 

64 

65 

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

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

68 

69 Args: 

70 current_branch: The name of the current git branch. 

71 

72 Raises: 

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

74 

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

81 

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) 

88 

89 upstream = result.stdout.strip() 

90 

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

95 

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) 

122 

123 

124def get_default_branch() -> str: 

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

126 

127 Returns: 

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

129 

130 Raises: 

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

132 

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) 

142 

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

144 if "HEAD branch" in line: 

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

146 

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

148 raise typer.Exit(code=1) 

149 

150 

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

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

153 

154 Args: 

155 tag: The tag name to check. 

156 

157 Returns: 

158 Tuple of (exists_locally, exists_remotely). 

159 

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 

168 

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 

172 

173 return exists_locally, exists_remotely 

174 

175 

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

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

178 

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. 

183 

184 Raises: 

185 typer.Exit: If push fails. 

186 

187 Example: 

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

189 """ 

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

191 

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

195 

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

199 

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) 

208 

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

215 

216 # Get repository URL for GitHub Actions link 

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

218 repo_url = result.stdout.strip() 

219 

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

229 

230 if repo_path: 

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

232 

233 

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. 

238 

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

241 

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. 

248 

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) 

255 

256 if with_bump: 

257 return _resolve_with_bump_flag(non_interactive, language) 

258 

259 if not non_interactive: 

260 return _resolve_interactive_prompt(language) 

261 

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

263 return False, None 

264 

265 

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

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

268 

269 Args: 

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

271 language: The programming language for version reading. 

272 

273 Returns: 

274 Tuple of (True, new_version_string). 

275 

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 

290 

291 

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

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

294 

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

296 

297 Args: 

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

299 language: The programming language for version reading. 

300 

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

309 

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 

316 

317 

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

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

320 

321 Args: 

322 language: The programming language for version reading. 

323 

324 Returns: 

325 Tuple of (should_bump, new_version_string). 

326 """ 

327 import questionary as qs 

328 

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 

337 

338 if not should_bump: 

339 return False, None 

340 

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 

347 

348 

349def _perform_version_bump(new_version: str, dry_run: bool, language: Language, config: Path | None = None) -> str: 

350 """Perform version bump with validation. 

351 

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. 

357 

358 Returns: 

359 The new version string. 

360 

361 Raises: 

362 typer.Exit: If the bump operation fails. 

363 """ 

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

365 

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 ) 

377 

378 if dry_run: 

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

380 

381 return new_version 

382 

383 

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

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

386 

387 Args: 

388 tag: Tag name to check. 

389 current_version: Current version string. 

390 

391 Raises: 

392 typer.Exit: If tag state is invalid. 

393 """ 

394 exists_locally, exists_remotely = check_tag_exists(tag) 

395 

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) 

402 

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) 

410 

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

412 

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

422 

423 

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

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

426 

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 

433 

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

435 if not tags: 

436 return 

437 

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

439 

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

452 

453 

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. 

462 

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. 

466 

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

473 

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) 

483 

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) 

495 

496 

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. 

499 

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. 

504 

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) 

509 

510 tag = f"v{current_version}" 

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

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

513 

514 return current_version, tag 

515 

516 

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

518 """Check repository state before release. 

519 

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

528 

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) 

533 

534 

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

536 """Validate tag state before release. 

537 

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. 

543 

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) 

559 

560 

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. 

571 

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 

579 

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. 

588 

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. 

592 

593 Example: 

594 Push a release tag:: 

595 

596 release_command() 

597 

598 Preview what would happen:: 

599 

600 release_command(dry_run=True) 

601 

602 Non-interactive mode:: 

603 

604 release_command(non_interactive=True) 

605 

606 Bump and release:: 

607 

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

609 

610 Interactive bump with dry-run:: 

611 

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

613 

614 Release a Go project:: 

615 

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 

627 

628 _validate_project_exists(language) 

629 

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

634 

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 ) 

639 

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) 

643 

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

656 

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

658 

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) 

663 

664 # Get current version and tag 

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

666 

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

668 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version) 

669 

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

673 

674 # Show commits since last tag (if any) 

675 _show_commits_since_last_tag(tag) 

676 

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 ) 

681 

682 if dry_run: 

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

684 else: 

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