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

1#!/usr/bin/env python3 

2""" 

3Version bump script for GCO. 

4 

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: 

8 

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) 

16 

17``gco._version.__version__`` reads its value at import time and should always 

18match ``VERSION`` on a clean checkout. 

19 

20Other components that follow the same version automatically (no script 

21changes needed): 

22 

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. 

30 

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

38 

39import base64 

40import json 

41import re 

42import sys 

43import urllib.parse 

44from pathlib import Path 

45 

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" 

53 

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

60 

61REPO_GIT_URL = ( 

62 "https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git" 

63) 

64 

65 

66def _mcp_server_config(version: str) -> dict[str, str | list[str]]: 

67 """Canonical one-click install config for the GCO MCP server. 

68 

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 } 

82 

83 

84def render_mcp_install_table(version: str) -> str: 

85 """Render the one-click MCP install table for the top-level README. 

86 

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

95 

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

99 

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 ) 

118 

119 

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 

134 

135 

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

143 

144 

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

158 

159 

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

174 

175 

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

190 

191 

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. 

194 

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

219 

220 

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

225 

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

237 

238 return f"{major}.{minor}.{patch}" 

239 

240 

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) 

250 

251 

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

256 

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 

266 

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) 

271 

272 old_version = get_version() 

273 new_version = bump_version(bump_type) 

274 set_version(new_version, dry_run) 

275 

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

289 

290 

291if __name__ == "__main__": 

292 main()