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
« 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.
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.
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"""
23from __future__ import annotations
25import re
26import sys
27from pathlib import Path
28from typing import Any
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
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)
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
64[[tool.bumpversion.files]]
65filename = "pyproject.toml"
66regex = true
67search = '(?ms)^\[project\]((?:(?!^\[)[\s\S])*?)^version = "{{current_version}}"'
68replace = '[project]\1version = "{{new_version}}"'
69"""
71_RUST_BUMPVERSION = r"""[tool.bumpversion]
72current_version = "{version}"
73tag = false
74commit = false
75allow_dirty = false
77[[tool.bumpversion.files]]
78filename = "Cargo.toml"
79regex = true
80search = '(?ms)^\[package\]((?:(?!^\[)[\s\S])*?)^version = "{{current_version}}"'
81replace = '[package]\1version = "{{new_version}}"'
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"""
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)
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)
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
116def _go_declared_version(target: Path) -> str | None:
117 """Return the version the `go-core` constant declares, or None before the first sync.
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
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)
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
146def seed_bumpversion_config(target: Path, language: str) -> str | None:
147 """Declare where the version lives, for `/rhiza:release`; return the file written.
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.
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
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"
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"
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.
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 )