Coverage for scripts / bump_version.py: 100.00%
127 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""
3Version bump script for GCO.
5The authoritative version lives in the top-level ``VERSION`` file so that
6shell scripts, Dockerfiles, and CI workflows can read it without importing
7Python. This script keeps the release version and maintained exact-ref examples in sync:
9- ``VERSION`` (source of truth, plain text: ``MAJOR.MINOR.PATCH``)
10- ``gco/_version.py`` (mirrors VERSION as ``__version__``)
11- ``cli/__init__.py`` (fallback ``__version__`` when ``gco`` is not importable)
12- ``gco_mcp/README.md`` (exact release refs in copy-paste MCP launch examples)
13- ``README.md`` (one-click MCP install buttons whose deep links embed
14 the release ref URL-encoded and base64-encoded; the
15 whole marked block is regenerated, not regex-patched)
17``gco._version.__version__`` reads its value at import time and should always
18match ``VERSION`` on a clean checkout.
20Other components that follow the same version automatically (no script
21changes needed):
23- ``gco_mcp/run_mcp.py`` imports ``gco._version.__version__`` for its
24 ``_MCP_SERVER_VERSION`` and reports it in the
25 startup audit log.
26- ``pyproject.toml`` uses ``dynamic = ["version"]`` with
27 ``setuptools.dynamic.version`` set to
28 ``{attr = "gco._version.__version__"}``, so the
29 built wheel tracks the same value.
31Usage:
32 python scripts/bump_version.py major # 0.0.9 -> 1.0.0
33 python scripts/bump_version.py minor # 0.0.9 -> 0.1.0
34 python scripts/bump_version.py patch # 0.0.9 -> 0.0.10
35 python scripts/bump_version.py patch --dry-run # Show what would change
36 python scripts/bump_version.py # Show current version
37"""
39import base64
40import json
41import re
42import sys
43import urllib.parse
44from pathlib import Path
46# File paths relative to project root
47PROJECT_ROOT = Path(__file__).parent.parent
48VERSION_FILE = PROJECT_ROOT / "VERSION"
49VERSION_PY = PROJECT_ROOT / "gco" / "_version.py"
50CLI_INIT_FILE = PROJECT_ROOT / "cli" / "__init__.py"
51MCP_README_FILE = PROJECT_ROOT / "gco_mcp" / "README.md"
52ROOT_README_FILE = PROJECT_ROOT / "README.md"
54# Markers delimiting the auto-generated one-click MCP install table in the
55# top-level README. Everything between them is owned by this script.
56MCP_INSTALL_TABLE_BEGIN = (
57 "<!-- BEGIN MCP INSTALL TABLE (generated by scripts/bump_version.py; edit there) -->"
58)
59MCP_INSTALL_TABLE_END = "<!-- END MCP INSTALL TABLE -->"
61REPO_GIT_URL = (
62 "https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git"
63)
66def _mcp_server_config(version: str) -> dict[str, str | list[str]]:
67 """Canonical one-click install config for the GCO MCP server.
69 Mirrors the recommended clone-free ``uvx`` form documented in
70 gco_mcp/README.md, pinned to the exact release ref.
71 """
72 return {
73 "command": "uvx",
74 "args": [
75 "--python",
76 "3.14",
77 "--from",
78 f"git+{REPO_GIT_URL}@v{version}",
79 "gco-mcp",
80 ],
81 }
84def render_mcp_install_table(version: str) -> str:
85 """Render the one-click MCP install table for the top-level README.
87 Deep-link formats follow https://github.com/awslabs/mcp: Kiro and
88 VS Code take the URL-encoded server-config JSON, Cursor takes it
89 base64-encoded.
90 """
91 config = _mcp_server_config(version)
92 compact = json.dumps(config, separators=(",", ":"))
93 url_encoded = urllib.parse.quote(compact, safe="")
94 b64_encoded = base64.b64encode(compact.encode("utf-8")).decode("ascii")
96 kiro_href = f"https://kiro.dev/launch/mcp/add?name=gco&config={url_encoded}"
97 cursor_href = f"https://cursor.com/en/install-mcp?name=gco&config={urllib.parse.quote(b64_encoded, safe='')}"
98 vscode_href = f"https://insiders.vscode.dev/redirect/mcp/install?name=gco&config={url_encoded}"
100 return "\n".join(
101 [
102 MCP_INSTALL_TABLE_BEGIN,
103 "<table>",
104 " <tr>",
105 ' <th><a href="https://kiro.dev/docs/mcp/">Kiro</a></th>',
106 ' <th><a href="https://cursor.com/docs/context/mcp">Cursor</a></th>',
107 ' <th><a href="https://code.visualstudio.com/docs/copilot/chat/mcp-servers">VS Code</a></th>',
108 " </tr>",
109 " <tr>",
110 f' <td><a href="{kiro_href}"><img src="https://kiro.dev/images/add-to-kiro.svg" alt="Add to Kiro"></a></td>',
111 f' <td><a href="{cursor_href}"><img src="https://cursor.com/deeplink/mcp-install-light.svg" alt="Add to Cursor"></a></td>',
112 f' <td><a href="{vscode_href}"><img src="https://img.shields.io/badge/Install_on-VS_Code-FF9900?style=flat-square" alt="Install on VS Code" height="28"></a></td>',
113 " </tr>",
114 "</table>",
115 MCP_INSTALL_TABLE_END,
116 ]
117 )
120def get_version() -> str:
121 """Read current version from the top-level VERSION file."""
122 if not VERSION_FILE.exists():
123 raise FileNotFoundError(
124 f"VERSION file not found at {VERSION_FILE.relative_to(PROJECT_ROOT)}. "
125 "Create it with a single line of the form MAJOR.MINOR.PATCH."
126 )
127 version = VERSION_FILE.read_text().strip()
128 if not re.fullmatch(r"\d+\.\d+\.\d+", version):
129 raise ValueError(
130 f"Invalid version in {VERSION_FILE.relative_to(PROJECT_ROOT)}: {version!r}. "
131 "Expected MAJOR.MINOR.PATCH."
132 )
133 return version
136def update_version_file(version: str, dry_run: bool = False) -> None:
137 """Update the top-level VERSION file."""
138 if dry_run:
139 print(f" [dry-run] Would update {VERSION_FILE.relative_to(PROJECT_ROOT)}")
140 return
141 VERSION_FILE.write_text(f"{version}\n")
142 print(f" ✓ Updated {VERSION_FILE.relative_to(PROJECT_ROOT)}")
145def update_version_py(version: str, dry_run: bool = False) -> None:
146 """Update ``__version__`` in gco/_version.py."""
147 if dry_run:
148 print(f" [dry-run] Would update {VERSION_PY.relative_to(PROJECT_ROOT)}")
149 return
150 content = VERSION_PY.read_text()
151 new_content = re.sub(
152 r'__version__\s*=\s*["\'][^"\']+["\']',
153 f'__version__ = "{version}"',
154 content,
155 )
156 VERSION_PY.write_text(new_content)
157 print(f" ✓ Updated {VERSION_PY.relative_to(PROJECT_ROOT)}")
160def update_cli_init(version: str, dry_run: bool = False) -> None:
161 """Update fallback ``__version__`` in cli/__init__.py."""
162 if dry_run:
163 print(f" [dry-run] Would update {CLI_INIT_FILE.relative_to(PROJECT_ROOT)}")
164 return
165 content = CLI_INIT_FILE.read_text()
166 new_content = re.sub(
167 r'__version__\s*=\s*["\'][^"\']+["\']',
168 f'__version__ = "{version}"',
169 content,
170 flags=re.MULTILINE,
171 )
172 CLI_INIT_FILE.write_text(new_content)
173 print(f" ✓ Updated {CLI_INIT_FILE.relative_to(PROJECT_ROOT)}")
176def update_mcp_readme_release_refs(version: str, dry_run: bool = False) -> None:
177 """Update exact release refs in copy-paste MCP launch examples."""
178 if dry_run:
179 print(f" [dry-run] Would update {MCP_README_FILE.relative_to(PROJECT_ROOT)}")
180 return
181 content = MCP_README_FILE.read_text(encoding="utf-8")
182 new_content = re.sub(r"@v\d+\.\d+\.\d+", f"@v{version}", content)
183 new_content = re.sub(
184 r"(?m)^GCO_REF=v\d+\.\d+\.\d+",
185 f"GCO_REF=v{version}",
186 new_content,
187 )
188 MCP_README_FILE.write_text(new_content, encoding="utf-8")
189 print(f" ✓ Updated {MCP_README_FILE.relative_to(PROJECT_ROOT)}")
192def update_root_readme_install_table(version: str, dry_run: bool = False) -> None:
193 """Regenerate the one-click MCP install table in the top-level README.
195 The Kiro/VS Code deep links embed the release ref URL-encoded and the
196 Cursor deep link embeds it base64-encoded, so a plain ``@vX.Y.Z`` regex
197 can't reach them. Instead this script owns the whole marked block and
198 rewrites it wholesale from :func:`render_mcp_install_table`.
199 """
200 if dry_run:
201 print(f" [dry-run] Would update {ROOT_README_FILE.relative_to(PROJECT_ROOT)}")
202 return
203 content = ROOT_README_FILE.read_text(encoding="utf-8")
204 begin = content.find(MCP_INSTALL_TABLE_BEGIN)
205 end = content.find(MCP_INSTALL_TABLE_END)
206 if begin == -1 or end == -1 or end < begin:
207 raise ValueError(
208 f"MCP install table markers not found in "
209 f"{ROOT_README_FILE.relative_to(PROJECT_ROOT)}; expected "
210 f"{MCP_INSTALL_TABLE_BEGIN!r} … {MCP_INSTALL_TABLE_END!r}."
211 )
212 new_content = (
213 content[:begin]
214 + render_mcp_install_table(version)
215 + content[end + len(MCP_INSTALL_TABLE_END) :]
216 )
217 ROOT_README_FILE.write_text(new_content, encoding="utf-8")
218 print(f" ✓ Updated {ROOT_README_FILE.relative_to(PROJECT_ROOT)}")
221def bump_version(bump_type: str) -> str:
222 """Bump version based on type (major, minor, patch)."""
223 current = get_version()
224 major, minor, patch = (int(p) for p in current.split("."))
226 if bump_type == "major":
227 major += 1
228 minor = 0
229 patch = 0
230 elif bump_type == "minor":
231 minor += 1
232 patch = 0
233 elif bump_type == "patch":
234 patch += 1
235 else:
236 raise ValueError(f"Invalid bump type: {bump_type}")
238 return f"{major}.{minor}.{patch}"
241def set_version(version: str, dry_run: bool = False) -> None:
242 """Update the source version, Python mirrors, and documented launch refs."""
243 action = "Would update" if dry_run else "Updating"
244 print(f"\n{action} version to {version}:")
245 update_version_file(version, dry_run)
246 update_version_py(version, dry_run)
247 update_cli_init(version, dry_run)
248 update_mcp_readme_release_refs(version, dry_run)
249 update_root_readme_install_table(version, dry_run)
252def main() -> None:
253 args = [a.lower() for a in sys.argv[1:]]
254 dry_run = "--dry-run" in args or "-n" in args
255 args = [a for a in args if a not in ("--dry-run", "-n")]
257 if len(args) < 1:
258 print(f"Current version: {get_version()}")
259 print("\nVersion locations:")
260 print(f" - {VERSION_FILE.relative_to(PROJECT_ROOT)} (source of truth)")
261 print(f" - {VERSION_PY.relative_to(PROJECT_ROOT)}")
262 print(f" - {CLI_INIT_FILE.relative_to(PROJECT_ROOT)}")
263 print(f" - {MCP_README_FILE.relative_to(PROJECT_ROOT)} (MCP launch refs)")
264 print(f" - {ROOT_README_FILE.relative_to(PROJECT_ROOT)} (MCP install buttons)")
265 return
267 bump_type = args[0]
268 if bump_type not in ("major", "minor", "patch"):
269 print(f"Usage: {sys.argv[0]} [major|minor|patch] [--dry-run]")
270 sys.exit(1)
272 old_version = get_version()
273 new_version = bump_version(bump_type)
274 set_version(new_version, dry_run)
276 if dry_run:
277 print(f"\n[dry-run] Would bump version: {old_version} -> {new_version}")
278 else:
279 print(f"\n✓ Bumped version: {old_version} -> {new_version}")
280 print("\nTo complete the release PR:")
281 print(" git add VERSION gco/_version.py cli/__init__.py gco_mcp/README.md README.md")
282 print(f" git commit -m 'Release v{new_version}'")
283 print(" git push -u origin HEAD")
284 print(
285 f" gh pr create --base main --title 'Release v{new_version}' "
286 "--body 'Version bump; release-publish.yml tags the reviewed merge.'"
287 )
288 print("\nAfter review, squash-merge the PR; release-publish.yml creates the tag.")
291if __name__ == "__main__":
292 main()