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

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 typer 

28 

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) 

42 

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) 

72 

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) 

82 

83 

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. 

86 

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. 

91 

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) 

96 

97 tag = f"v{current_version}" 

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

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

100 

101 return current_version, tag 

102 

103 

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

105 """Check repository state before release. 

106 

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

115 

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) 

120 

121 

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

123 """Validate tag state before release. 

124 

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. 

130 

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) 

146 

147 

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. 

150 

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. 

155 

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. 

159 

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. 

165 

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 

173 

174 candidate = parse_semver_or_exit(version_str, strip_v_prefix=True) 

175 

176 if candidate > latest_remote: 

177 console.success(f"Preflight: v{candidate} is newer than the latest remote release v{latest_remote}") 

178 return 

179 

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 

187 

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) 

195 

196 

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. 

207 

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 

217 

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. 

231 

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. 

235 

236 Example: 

237 Bump (interactive) and release:: 

238 

239 release_command() 

240 

241 Preview what would happen:: 

242 

243 release_command(dry_run=True) 

244 

245 Non-interactive patch release:: 

246 

247 release_command(non_interactive=True) 

248 

249 Explicit bump and release:: 

250 

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

252 

253 Release a Go project:: 

254 

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) 

266 

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

270 

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) 

274 

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) 

278 

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) 

283 

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

296 

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

298 

299 # Perform the bump (bump_command runs its own internal preflight) 

300 bumped_new_version = _perform_version_bump(new_version, dry_run, language, config) 

301 

302 # Get current version and tag 

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

304 

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

306 _handle_tag_validation(dry_run, bumped_new_version, tag, current_version) 

307 

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

311 

312 # Show commits since last tag (if any) 

313 _show_commits_since_last_tag(tag) 

314 

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 ) 

319 

320 if dry_run: 

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

322 else: 

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