Coverage for plugin/scripts/_skeleton_version.py: 100%

73 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Declare where the version lives, so `/rhiza:release` has something to read. 

3 

4**Why this is a script and not prose.** It was prose — `prompts/skeleton.md` steps 5 and 

5R5 spell the exact block out — and prose is a step a model can skip. What it costs when 

6skipped is not a failed gate but a wrong release: `bump-my-version` silently falls back to 

7``git describe``, so a version that already exists can be cut again. The template's own 

8`test_a_discoverable_config_exists` (new in rhiza v1.3.0) fails on its absence, which is 

9how this surfaced. The block is fixed text with one substituted number, so by this 

10plugin's own division of labour — deterministic work in tested Python, judgement in 

11markdown — it belongs here. 

12 

13**Go is the exception: nothing is written.** A Go module's version *is* its git tag, so 

14there is nothing in a fresh module to anchor to, and the `go-core` bundle owns the 

15declaration — a root `.bumpversion.toml` (template-owned, listed in `template.lock`) with 

16no `current_version` key, because the current version is read from the newest tag, plus 

17the `internal/version/version.go` constant that lets a built binary report itself. Writing 

18our own would be clobbered by the first sync *and* would inject a `current_version` 

19upstream deliberately omits. So on Go the version location arrives with `/rhiza:update`, 

20and the skeleton says so rather than pre-empting it. 

21""" 

22 

23from __future__ import annotations 

24 

25import re 

26import sys 

27from pathlib import Path 

28from typing import Any 

29 

30sys.path.insert(0, str(Path(__file__).resolve().parent)) 

31from _rhiza_toml import table_span # noqa: E402 

32from _skeleton_rust import cargo_package_name # noqa: E402 

33 

34# The files `bump-my-version` searches for its config, in its own order. A 

35# `[tool.bumpversion]` table anywhere else — `.rhiza/.cfg.toml`, say — is never found, 

36# and the tool then falls back to `git describe` without saying so. 

37_BUMPVERSION_CONFIGS = (".bumpversion.toml", ".bumpversion.cfg", "setup.cfg", "pyproject.toml") 

38# `[tool.bumpversion]` in TOML; `[bumpversion]` is the legacy INI spelling in setup.cfg. 

39_BUMPVERSION_SECTION = re.compile(r"^\s*\[(tool\.)?bumpversion\]", re.MULTILINE) 

40 

41# The version-location declarations, one per language. Fixed text apart from the current 

42# version (and, for Cargo.lock, the crate name), which is why they are written by the 

43# script rather than left to the procedure's prose. 

44# 

45# **The search patterns are anchored to their table on purpose.** `search`/`replace` apply 

46# to every occurrence in a file, so a bare `version = "{current_version}"` would also 

47# rewrite a `[tool.something].version`, or — worse, in `Cargo.lock` — every dependency 

48# that happens to share the number. 

49# 

50# **And the `Cargo.lock` entry needs `regex = true`.** Without it the `\n` is matched 

51# literally, so the entry silently does nothing: `Cargo.toml` moves, the lockfile records 

52# the old version, and the next `cargo build` dirties the tree — the exact failure the 

53# entry exists to prevent, reported as a successful release. The version this repo's own 

54# procedure documented had that bug until a real bump was run against it. 

55# Raw strings: every backslash here belongs to the regex that lands in the file, and 

56# `{{...}}` survives `.format()` as bump-my-version's own `{current_version}` placeholder. 

57_PYTHON_BUMPVERSION = r""" 

58[tool.bumpversion] 

59current_version = "{version}" 

60tag = false 

61commit = false 

62allow_dirty = false 

63 

64[[tool.bumpversion.files]] 

65filename = "pyproject.toml" 

66regex = true 

67search = '(?ms)^\[project\]((?:(?!^\[)[\s\S])*?)^version = "{{current_version}}"' 

68replace = '[project]\1version = "{{new_version}}"' 

69""" 

70 

71_RUST_BUMPVERSION = r"""[tool.bumpversion] 

72current_version = "{version}" 

73tag = false 

74commit = false 

75allow_dirty = false 

76 

77[[tool.bumpversion.files]] 

78filename = "Cargo.toml" 

79regex = true 

80search = '(?ms)^\[package\]((?:(?!^\[)[\s\S])*?)^version = "{{current_version}}"' 

81replace = '[package]\1version = "{{new_version}}"' 

82 

83[[tool.bumpversion.files]] 

84filename = "Cargo.lock" 

85regex = true 

86search = '(?m)^name = "{name}"\nversion = "{{current_version}}"$' 

87replace = 'name = "{name}"\nversion = "{{new_version}}"' 

88ignore_missing_file = true 

89""" 

90 

91# Where a Go module's version lives *in the source tree* — the constant `go-core` ships 

92# so a built binary can report itself. It is the template's file, not ours: absent until 

93# the first sync, and declared to bump-my-version by the template's own 

94# root-level `.bumpversion.toml`. 

95_GO_VERSION_FILE = Path("internal") / "version" / "version.go" 

96_GO_VERSION_CONST = re.compile(r'^\s*const\s+Version\s*=\s*"([^"]+)"', re.MULTILINE) 

97 

98_GO_NOTE = ( 

99 "no version location written: a Go module's version is its git tag, and the " 

100 "template's own .bumpversion.toml (plus internal/version/version.go) arrives " 

101 "with the first /rhiza:update" 

102) 

103 

104 

105def bumpversion_config(target: Path) -> str | None: 

106 """Return the discoverable file declaring a bumpversion config, or None.""" 

107 for name in _BUMPVERSION_CONFIGS: 

108 path = target / name 

109 if path.is_file() and _BUMPVERSION_SECTION.search( 

110 path.read_text(encoding="utf-8", errors="ignore") 

111 ): 

112 return name 

113 return None 

114 

115 

116def _go_declared_version(target: Path) -> str | None: 

117 """Return the version the `go-core` constant declares, or None before the first sync. 

118 

119 Not `go.mod`: a Go module's version is its git tag, and the only copy in the source 

120 tree is the constant the `go-core` bundle ships — which is absent until the sync. 

121 """ 

122 version_file = target / _GO_VERSION_FILE 

123 if not version_file.is_file(): 

124 return None 

125 match = _GO_VERSION_CONST.search(version_file.read_text(encoding="utf-8", errors="ignore")) 

126 return match.group(1) if match else None 

127 

128 

129def declared_version(target: Path, language: str) -> str | None: 

130 """Return the version the manifest declares, or None when it declares none.""" 

131 if language == "go": 

132 return _go_declared_version(target) 

133 

134 manifest = target / ("Cargo.toml" if language == "rust" else "pyproject.toml") 

135 if not manifest.is_file(): 

136 return None 

137 lines = manifest.read_text(encoding="utf-8", errors="ignore").splitlines() 

138 span = table_span(lines, "package" if language == "rust" else "project") 

139 for line in lines[span[0] + 1 : span[1]] if span else []: 

140 match = re.match(r"""^\s*version\s*=\s*["']([^"']+)["']""", line) 

141 if match: 

142 return match.group(1) 

143 return None 

144 

145 

146def seed_bumpversion_config(target: Path, language: str) -> str | None: 

147 """Declare where the version lives, for `/rhiza:release`; return the file written. 

148 

149 Returns None when a discoverable config already exists (the user's wins, and this is 

150 idempotent) or when there is no version to anchor to. 

151 

152 Python appends to `pyproject.toml`, since that is both discoverable and where the 

153 version is. Rust gets `.bumpversion.toml`: Cargo has no `[tool]` table convention, 

154 and `bump-my-version` does not read `Cargo.toml`. Go writes nothing — see this 

155 module's docstring. 

156 """ 

157 if language == "go" or bumpversion_config(target) is not None: 

158 return None 

159 version = declared_version(target, language) 

160 if version is None: 

161 return None 

162 

163 if language == "rust": 

164 # The *package* name, not the crate identifier: `Cargo.lock` records the name as 

165 # written in the manifest, hyphens and all. 

166 name = cargo_package_name(target) or target.name 

167 path = target / ".bumpversion.toml" 

168 path.write_text(_RUST_BUMPVERSION.format(version=version, name=name), encoding="utf-8") 

169 return ".bumpversion.toml" 

170 

171 manifest = target / "pyproject.toml" 

172 text = manifest.read_text(encoding="utf-8") 

173 if not text.endswith("\n"): 

174 text += "\n" 

175 manifest.write_text(text + _PYTHON_BUMPVERSION.format(version=version), encoding="utf-8") 

176 return "pyproject.toml" 

177 

178 

179def note_bumpversion(target: Path, language: str, result: dict[str, Any]) -> None: 

180 """Declare the version location and record what happened in *result*, in place. 

181 

182 Runs last, and only when the manifest work succeeded: the table anchors to the version 

183 the manifest declares, so there is nothing to write until that manifest is sound. 

184 """ 

185 if not result["ok"]: 

186 return 

187 if language == "go": 

188 result["notes"].append(_GO_NOTE) 

189 return 

190 existing = bumpversion_config(target) 

191 written = seed_bumpversion_config(target, language) 

192 if written is not None: 

193 if written not in result["modified"]: 

194 result["modified"].append(written) 

195 result["changes"].append("tool.bumpversion") 

196 result["notes"].append( 

197 f"declared the version location in {written} — /rhiza:release reads " 

198 "[tool.bumpversion] and refuses to guess" 

199 ) 

200 elif existing is not None: 

201 result["notes"].append(f"version location already declared in {existing}") 

202 else: 

203 result["notes"].append( 

204 "no version declared in the manifest, so no [tool.bumpversion] was written — " 

205 "/rhiza:release will have nothing to read" 

206 )