Coverage for src/rhiza_tools/commands/release/__init__.py: 100%
94 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +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 typer
29from rhiza_tools import console
30from rhiza_tools.commands._shared import (
31 get_latest_remote_version,
32 parse_semver_or_exit,
33)
34from rhiza_tools.commands._shared import (
35 run_git_command as run_git_command,
36)
37from rhiza_tools.commands.bump import (
38 Language,
39 _validate_project_exists,
40 get_current_version,
41)
43# Git plumbing (tag lookup, push, branch checks) lives in release/git.py; re-exported
44# here so the public import surface (and existing tests) keep working.
45from rhiza_tools.commands.release.git import (
46 _confirm_and_push_tag as _confirm_and_push_tag,
47)
48from rhiza_tools.commands.release.git import (
49 _show_commits_since_last_tag as _show_commits_since_last_tag,
50)
51from rhiza_tools.commands.release.git import (
52 _validate_tag_state as _validate_tag_state,
53)
54from rhiza_tools.commands.release.git import (
55 check_branch_status as check_branch_status,
56)
57from rhiza_tools.commands.release.git import (
58 check_clean_working_tree as check_clean_working_tree,
59)
60from rhiza_tools.commands.release.git import (
61 check_tag_exists as check_tag_exists,
62)
63from rhiza_tools.commands.release.git import (
64 get_current_branch as get_current_branch,
65)
66from rhiza_tools.commands.release.git import (
67 get_default_branch as get_default_branch,
68)
69from rhiza_tools.commands.release.git import (
70 push_tag as push_tag,
71)
73# Bump-type resolution lives in release/versioning.py; re-exported here so the
74# public import surface (and existing tests) keep using ``release.<helper>``.
75from rhiza_tools.commands.release.versioning import (
76 _perform_version_bump,
77 _resolve_required_bump,
78)
79from rhiza_tools.commands.release.versioning import (
80 _resolve_explicit_bump_type as _resolve_explicit_bump_type,
81)
84def _get_release_version(dry_run: bool, bumped_new_version: str | None, language: Language) -> tuple[str, str]:
85 """Get current version and tag for release.
87 Args:
88 dry_run: If True and version was bumped, use bumped version.
89 bumped_new_version: New version if bump was performed.
90 language: The programming language for version reading.
92 Returns:
93 Tuple of (current_version, tag).
94 """
95 current_version = bumped_new_version if dry_run and bumped_new_version else get_current_version(language)
97 tag = f"v{current_version}"
98 console.info(f"Current version: {current_version}")
99 console.info(f"Expected tag: {tag}")
101 return current_version, tag
104def _check_repository_state(dry_run: bool, current_branch: str, default_branch: str) -> None:
105 """Check repository state before release.
107 Args:
108 dry_run: If True, skip some checks.
109 current_branch: Current git branch.
110 default_branch: Default git branch.
111 """
112 # Note if not on default branch
113 if current_branch != default_branch:
114 console.info(f"Note: You are on branch '{current_branch}' (default branch is '{default_branch}')")
116 # Check for uncommitted changes (skip in dry-run mode)
117 if not dry_run:
118 check_clean_working_tree()
119 check_branch_status(current_branch)
122def _handle_tag_validation(dry_run: bool, bumped_new_version: str | None, tag: str, current_version: str) -> None:
123 """Validate tag state before release.
125 Args:
126 dry_run: If True and version was bumped, use relaxed validation.
127 bumped_new_version: New version if bump was performed.
128 tag: Tag name to validate.
129 current_version: Current version string.
131 Raises:
132 typer.Exit: If tag validation fails.
133 """
134 if dry_run and bumped_new_version:
135 # In dry-run with bump, the tag won't exist yet - just check it's not already on remote
136 _, exists_remotely = check_tag_exists(tag)
137 if exists_remotely:
138 console.error(f"Tag '{tag}' already exists on remote")
139 console.error(f"The release for version {current_version} has already been published.")
140 console.error("If this was unintentional, you can delete the remote tag and retry:")
141 console.error(f" git push origin :refs/tags/{tag}")
142 raise typer.Exit(code=1)
143 console.info(f"[DRY-RUN] Tag '{tag}' would be created by the bump and release process")
144 else:
145 _validate_tag_state(tag, current_version)
148def _check_release_version_monotonic(version_str: str, allow_older: bool) -> None:
149 """Ensure the version being released is newer than the latest remote release.
151 This is the authoritative guard against issue #1126: it refuses to push a
152 tag whose version is not strictly greater than the highest version already
153 published on the remote, regardless of what the (possibly stale) local
154 ``pyproject.toml`` says.
156 When the remote has no published version tags (first release) or cannot be
157 reached, the check is skipped so normal first releases and offline dry-runs
158 still work.
160 Args:
161 version_str: The version that is about to be released (with or without a
162 leading ``v``).
163 allow_older: If True, downgrade the hard error to a warning so that
164 intentional maintenance / back-branch releases can proceed.
166 Raises:
167 typer.Exit: If the version is not newer than the latest remote release
168 and ``allow_older`` is False.
169 """
170 latest_remote = get_latest_remote_version()
171 if latest_remote is None:
172 return
174 candidate = parse_semver_or_exit(version_str, strip_v_prefix=True)
176 if candidate > latest_remote:
177 console.success(f"Preflight: v{candidate} is newer than the latest remote release v{latest_remote}")
178 return
180 relation = "the same as" if candidate == latest_remote else "older than"
181 if allow_older:
182 console.warning(
183 f"Version v{candidate} is {relation} the latest remote release v{latest_remote}; "
184 "proceeding because --allow-older was set."
185 )
186 return
188 console.error(f"Refusing to release v{candidate}: it is {relation} the latest remote release v{latest_remote}.")
189 console.error("Your branch likely diverged before a newer release was merged (issue #1126).")
190 console.error("To resolve:")
191 console.error(" Sync with the latest release, e.g.: git pull --rebase origin <default-branch>")
192 console.error(f" Then bump again so the new version is higher than v{latest_remote}.")
193 console.error("For an intentional maintenance/back-branch release, re-run with --allow-older.")
194 raise typer.Exit(code=1)
197def release_command(
198 bump_type: str | None = None,
199 push: bool = False,
200 dry_run: bool = False,
201 non_interactive: bool = False,
202 language: Language | None = None,
203 config: Path | None = None,
204 allow_older: bool = False,
205) -> None:
206 """Bump the version and push a release tag to remote.
208 A release always bumps the version before tagging — there is no tag-only
209 path. This command performs the following steps:
210 1. Detects the project language (Python or Go) unless explicitly specified
211 2. Resolves the bump (explicit ``bump_type``, interactive prompt, or a patch
212 default in non-interactive mode) and bumps the version
213 3. Reads the current version from pyproject.toml (Python) or VERSION file (Go)
214 4. Validates the git repository state (clean working tree, up-to-date with remote)
215 5. Checks that a tag exists for the current version (created by bump-my-version)
216 6. Pushes the tag to remote, triggering the release workflow
218 Args:
219 bump_type: Optional bump type (MAJOR, MINOR, PATCH) to apply. When omitted,
220 the bump type is selected interactively (or defaults to patch in
221 non-interactive mode).
222 push: If True, push changes without prompting.
223 dry_run: If True, show what would be done without making any changes.
224 non_interactive: If True, skip all confirmation prompts and default the
225 bump to patch when no ``bump_type`` is given.
226 language: Programming language (python or go). Auto-detected if not specified.
227 config: Optional path to the .cfg.toml bumpversion config file.
228 allow_older: If True, permit releasing a version that is not strictly
229 greater than the latest version already published on the remote.
230 Required for intentional back-branch / maintenance releases.
232 Raises:
233 typer.Exit: If no supported project files are found, repository is not clean,
234 tag doesn't exist, or any git operations fail.
236 Example:
237 Bump (interactive) and release::
239 release_command()
241 Preview what would happen::
243 release_command(dry_run=True)
245 Non-interactive patch release::
247 release_command(non_interactive=True)
249 Explicit bump and release::
251 release_command(bump_type="MINOR", push=True)
253 Release a Go project::
255 release_command(language=Language.GO)
256 """
257 # Detect or validate project language
258 if language is None:
259 language = Language.detect()
260 if language is None:
261 console.error("No supported project files found in current directory.")
262 console.error("Python projects need pyproject.toml; Go projects need go.mod and VERSION.")
263 raise typer.Exit(code=1)
264 else:
265 _validate_project_exists(language)
267 # Get current branch early
268 current_branch = get_current_branch()
269 console.info(f"Current branch: {typer.style(current_branch, fg=typer.colors.CYAN, bold=True)}")
271 # A release always bumps: resolve the bump target (explicit, interactive, or
272 # patch default in non-interactive mode).
273 _, new_version = _resolve_required_bump(non_interactive, bump_type, language=language)
275 # ── Preflight validation: check everything BEFORE making any changes ──
276 default_branch = get_default_branch()
277 _check_repository_state(dry_run, current_branch, default_branch)
279 # Ensure the version we are about to release is strictly newer than the
280 # latest version already published on the remote (issue #1126). Runs in all
281 # modes (including dry-run) and before any mutation.
282 _check_release_version_monotonic(new_version, allow_older)
284 # Pre-validate that the new tag won't conflict with remote
285 if not dry_run:
286 new_tag = f"v{new_version}"
287 _, exists_remotely = check_tag_exists(new_tag)
288 if exists_remotely:
289 console.error(f"Tag '{new_tag}' already exists on remote")
290 console.error(f"The release for version {new_version} has already been published.")
291 console.error("No changes were made. To resolve:")
292 console.error(f" Delete the remote tag: git push origin :refs/tags/{new_tag}")
293 console.error(" Or choose a different version to bump to.")
294 raise typer.Exit(code=1)
295 console.success(f"Preflight: tag '{new_tag}' is available on remote")
297 # ── Execute: all preflight checks passed, safe to make changes ──
299 # Perform the bump (bump_command runs its own internal preflight)
300 bumped_new_version = _perform_version_bump(new_version, dry_run, language, config)
302 # Get current version and tag
303 current_version, tag = _get_release_version(dry_run, bumped_new_version, language)
305 # Validate tag state (for non-bump cases, ensures local tag exists)
306 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version)
308 # Push tag
309 console.info("Preparing to push tag to remote...")
310 console.info(f"Pushing tag '{tag}' to origin will trigger the release workflow.")
312 # Show commits since last tag (if any)
313 _show_commits_since_last_tag(tag)
315 # Confirm and push (bump commit + tag together)
316 _confirm_and_push_tag(
317 tag, push, dry_run, non_interactive, bump_branch=current_branch if bumped_new_version else None
318 )
320 if dry_run:
321 console.info("[DRY-RUN] Release process completed (no changes made)")
322 else:
323 console.success("Release process completed successfully!")