diff --git a/Makefile b/Makefile index cd903029..1bd6d280 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for mcpbridge-wrapper -.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all package-assets-check clean webui webui-health check +.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all package-assets-check bump-version clean webui webui-health check help: @echo "Available targets:" @@ -17,6 +17,7 @@ help: @echo " doccheck-branch - Check docs/ sync against git branch" @echo " doccheck-all - Check docs/ sync for unstaged, staged, and branch changes" @echo " package-assets-check - Build artifacts and verify required packaged assets" + @echo " bump-version - Update pyproject.toml and server.json versions (VERSION=x.y.z, add DRY_RUN=1 to preview)" @echo " webui - Start wrapper with Web UI dashboard (port 8080)" @echo " webui-health - Check Web UI health status" @echo " clean - Clean build artifacts" @@ -72,6 +73,17 @@ package-assets-check: python -m build --sdist --wheel python scripts/check_package_assets.py --dist-dir dist +bump-version: + @if [ -z "$(VERSION)" ]; then \ + echo "Usage: make bump-version VERSION=x.y.z [DRY_RUN=1]"; \ + exit 1; \ + fi + @if [ -n "$(DRY_RUN)" ]; then \ + python scripts/publish_helper.py $(VERSION) --dry-run; \ + else \ + python scripts/publish_helper.py $(VERSION); \ + fi + check: test lint format-check typecheck doccheck-all package-assets-check clean: diff --git a/PUBLISHING.md b/PUBLISHING.md index c879eab5..1527e299 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -68,24 +68,50 @@ No additional secrets needed! The workflow uses GitHub OIDC for authentication. ## How to Publish +### Version Helper (Recommended for Version Bumps) + +Use the helper to update all required version fields together: + +```bash +# Preview changes only +python scripts/publish_helper.py 0.4.0 --dry-run + +# Apply changes +python scripts/publish_helper.py 0.4.0 +``` + +Or via Makefile: + +```bash +make bump-version VERSION=0.4.0 DRY_RUN=1 +make bump-version VERSION=0.4.0 +``` + +The helper updates: +- `pyproject.toml` → `[project].version` +- `server.json` → top-level `version` +- `server.json` → `packages[*].version` + +It also prints the next release commands (`git add`, `git commit`, `git tag`, `git push`) aligned with this guide. + ### Automated Publishing (Recommended) -1. **Update version** in `pyproject.toml` and `server.json`: - ```toml - version = "0.2.0" +1. **Update version** (recommended: helper script): + ```bash + python scripts/publish_helper.py 0.4.0 ``` 2. **Commit and push**: ```bash git add pyproject.toml server.json - git commit -m "Bump version to 0.2.0" + git commit -m "Bump version to 0.4.0" git push ``` 3. **Create and push a tag**: ```bash - git tag v0.2.0 - git push origin v0.2.0 + git tag v0.4.0 + git push origin v0.4.0 ``` 4. **Watch the workflow run**: diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 501bafdb..d7e3204f 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -80,6 +80,7 @@ | FU-REBUILD-P10-T1-7 | [FU-REBUILD-P10-T1-7_WebUI_Static_Assets/](FU-REBUILD-P10-T1-7_WebUI_Static_Assets/) | 2026-02-13 | PASS | | FU-P9-T2-1 | [FU-P9-T2-1_Fix_uvx_Web_UI_examples_to_include_webui_extras/](FU-P9-T2-1_Fix_uvx_Web_UI_examples_to_include_webui_extras/) | 2026-02-13 | PASS | | BUG-T0 | [BUG-T0_Uptime_Widget_Fix/](BUG-T0_Uptime_Widget_Fix/) | 2026-02-13 | PASS | +| P9-T4 | [P9-T4_Create_the_publishing_helper/](P9-T4_Create_the_publishing_helper/) | 2026-02-13 | PASS | ## Historical Artifacts @@ -125,6 +126,7 @@ | [REVIEW_webui-static-assets.md](FU-REBUILD-P10-T1-7_WebUI_Static_Assets/REVIEW_webui-static-assets.md) | Review report for FU-REBUILD-P10-T1-7 | | [REVIEW_fu_p9_t2_1_uvx_webui_extras.md](FU-P9-T2-1_Fix_uvx_Web_UI_examples_to_include_webui_extras/REVIEW_fu_p9_t2_1_uvx_webui_extras.md) | Review report for FU-P9-T2-1 | | [REVIEW_BUG-T0_Uptime_Widget.md](BUG-T0_Uptime_Widget_Fix/REVIEW_BUG-T0_Uptime_Widget.md) | Review report for BUG-T0 | +| [REVIEW_p9_t4_publishing_helper.md](P9-T4_Create_the_publishing_helper/REVIEW_p9_t4_publishing_helper.md) | Review report for P9-T4 | ## Archive Log @@ -204,3 +206,5 @@ | 2026-02-13 | FU-P9-T2-1 | Archived REVIEW_fu_p9_t2_1_uvx_webui_extras report | | 2026-02-13 | BUG-T0 | Archived Uptime_Widget_Fix (PASS) | | 2026-02-13 | BUG-T0 | Archived REVIEW_BUG-T0_Uptime_Widget report | +| 2026-02-13 | P9-T4 | Archived Create_the_publishing_helper (PASS) | +| 2026-02-13 | P9-T4 | Archived REVIEW_p9_t4_publishing_helper report | diff --git a/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Create_the_publishing_helper.md b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Create_the_publishing_helper.md new file mode 100644 index 00000000..736334b1 --- /dev/null +++ b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Create_the_publishing_helper.md @@ -0,0 +1,58 @@ +# P9-T4 — Create the publishing helper + +**Task ID:** P9-T4 +**Phase:** Phase 9: Release Management +**Priority:** P1 +**Dependencies:** P9-T3 +**Status:** Planned + +## Objective + +Create a publishing helper script that reduces release errors by performing version bumps in all required files from a single command. The helper must validate a provided semantic version, update both `pyproject.toml` and `server.json` consistently, and support `--dry-run` to preview changes safely. It should also print the exact next commands required to complete release publication according to `PUBLISHING.md`. + +## Success Criteria + +- A helper exists at `scripts/publish_helper.py` and is executable via `python scripts/publish_helper.py `. +- Semantic versions in `MAJOR.MINOR.PATCH` format are accepted; invalid values are rejected with non-zero exit code and clear error. +- Both `pyproject.toml` and `server.json` are updated to the same target version in one run. +- Dry-run mode outputs planned file changes and does not modify files. +- Script output includes next-step release commands for commit/tag/push and notes that GitHub Actions handles publish. +- `PUBLISHING.md` documents helper usage. +- Quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` with coverage >= 90%. + +## Test-First Plan + +1. Add unit tests covering: + - valid version updates, + - invalid version rejection, + - dry-run no-write behavior, + - mismatch detection for expected keys and output summary behavior. +2. Implement helper internals to satisfy tests. +3. Update publishing docs and rerun targeted and full test suites. + +## Execution Plan + +### Phase A: Script Design and CLI Contract +- **Inputs:** `PUBLISHING.md`, `pyproject.toml`, `server.json` +- **Outputs:** CLI arguments and validation rules, parsing/update logic +- **Verification:** `--help` output and unit tests for parser/validator + +### Phase B: Implementation and File Updates +- **Inputs:** target version argument and repository files +- **Outputs:** updated `scripts/publish_helper.py`, modified version fields +- **Verification:** tests assert exact replacements and dry-run preservation + +### Phase C: Docs and Integration Validation +- **Inputs:** final script behavior +- **Outputs:** updated `PUBLISHING.md`, optional Makefile helper target +- **Verification:** full quality gates and manual dry-run/live-run checks + +## Notes + +- Keep logic deterministic and avoid external dependencies. +- Fail fast if expected version keys are missing or ambiguous. +- Preserve JSON formatting and TOML readability when writing updates. + +--- +**Archived:** 2026-02-13 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Validation_Report.md b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Validation_Report.md new file mode 100644 index 00000000..59ff479e --- /dev/null +++ b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/P9-T4_Validation_Report.md @@ -0,0 +1,44 @@ +# Validation Report — P9-T4: Create the publishing helper + +**Date:** 2026-02-13 +**Verdict:** PASS + +## Scope + +Implemented a new publishing helper to perform coordinated version updates for release publishing, documented usage in publishing docs, and added tests. + +## Deliverables + +- `scripts/publish_helper.py` created +- `tests/unit/test_publish_helper.py` created +- `PUBLISHING.md` updated with helper usage and Makefile examples +- `Makefile` updated with `bump-version` target + +## Acceptance Criteria Check + +- [x] Running helper updates `pyproject.toml` and `server.json` to same version +- [x] Invalid semantic versions are rejected with clear error +- [x] `--dry-run` prints planned changes and does not modify files +- [x] Helper prints release commands (`git add`, `commit`, `tag`, `push`) +- [x] Existing tests and quality gates pass + +## Commands Executed + +- `pytest tests/unit/test_publish_helper.py -v` +- `pytest` +- `ruff check src/` +- `mypy src/` +- `pytest --cov` +- `python scripts/publish_helper.py 0.3.3 --dry-run` + +## Results + +- `pytest`: PASS (`345 passed, 5 skipped`) +- `ruff check src/`: PASS +- `mypy src/`: PASS +- `pytest --cov`: PASS (`Total coverage: 96.62%`, threshold 90%) +- Manual dry-run of helper: PASS (correct planned updates and guidance output) + +## Notes + +Test warnings seen during full suite were pre-existing environment/runtime warnings (websocket deprecations and transient port 8080 binding from threaded Web UI test), not regressions from this task. diff --git a/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/REVIEW_p9_t4_publishing_helper.md b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/REVIEW_p9_t4_publishing_helper.md new file mode 100644 index 00000000..fe99f604 --- /dev/null +++ b/SPECS/ARCHIVE/P9-T4_Create_the_publishing_helper/REVIEW_p9_t4_publishing_helper.md @@ -0,0 +1,38 @@ +## REVIEW REPORT — P9-T4 Publishing Helper + +**Scope:** origin/main..HEAD +**Files:** 9 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues + +None. + +### Secondary Issues + +None. + +### Architectural Notes + +- `scripts/publish_helper.py` uses deterministic local file transformations with explicit validation and no network/runtime side effects. +- The helper keeps release version updates centralized and reduces drift risk between `pyproject.toml` and `server.json`. +- Dry-run behavior and explicit next-step command output align with documented publishing workflow. + +### Tests + +- Added: `tests/unit/test_publish_helper.py` (17 tests) +- Executed quality gates: + - `pytest` + - `ruff check src/` + - `mypy src/` + - `pytest --cov` +- Coverage remained above threshold: `96.62%` (required `>= 90%`). + +### Next Steps + +- FOLLOW-UP skipped: no actionable findings from review. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 9d4da1ec..f675381e 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -4,11 +4,11 @@ The previously selected task has been archived. ## Recently Archived +- 2026-02-13 — P9-T4: Create the publishing helper (PASS) - 2026-02-13 — BUG-T0: Uptime widget on Web UI always shows 1h 0m 0s (PASS) - 2026-02-13 — FU-P9-T2-1: Fix uvx Web UI examples to include `webui` extras (PASS) - 2026-02-13 — FU-REBUILD-P10-T1-7: Include Web UI static assets in published package artifacts (PASS) - 2026-02-13 — P9-T3: Release version 0.3.0 (Web UI Feature Release) (PASS) -- 2026-02-12 — FU-P8-T1-1: Reconcile P8-T1 URL criteria with current GitHub Pages path and resolve DocC reference warnings (PASS) ## Suggested Next Tasks diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index a68dc2f8..0e6ab714 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1194,6 +1194,24 @@ Main branch is currently unstable after an accidental merge of the Phase 10 Web --- +#### ✅ P9-T4: Create the publishing helper +- **Description:** Create a helper script to streamline release version updates for publishing. The script should update all required version fields in one run (at minimum `pyproject.toml` and `server.json`), validate the provided semantic version, and guide the user through the remaining publish steps documented in `PUBLISHING.md`. +- **Priority:** P1 +- **Dependencies:** P9-T3 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - New helper script at `scripts/publish_helper.py` (or equivalent script under `scripts/`) + - Documentation update in `PUBLISHING.md` showing how to run the helper + - Optional `Makefile` convenience target for version bumping +- **Acceptance Criteria:** + - Running the helper with a target version updates `pyproject.toml` and `server.json` to the exact same version value + - The helper rejects invalid version formats with a clear error message + - The helper supports a dry-run mode that prints planned changes without modifying files + - The helper prints next release commands (commit, tag, push) aligned with `PUBLISHING.md` + - Existing tests and quality gates continue to pass after integrating the helper + +--- + Phase 9 Follow-up Backlog - [x] FU-P9-T2-1: Fix uvx Web UI examples to include `webui` extras (P1) diff --git a/scripts/publish_helper.py b/scripts/publish_helper.py new file mode 100755 index 00000000..9941e862 --- /dev/null +++ b/scripts/publish_helper.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Helper script for release version updates used by publishing workflow.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) + + +@dataclass(frozen=True) +class FileChange: + """Describes a single version update for reporting.""" + + path: Path + field: str + old: str + new: str + + +def validate_semver(version: str) -> bool: + """Return True when the version is valid semantic versioning.""" + return bool(SEMVER_RE.match(version)) + + +def _replace_project_version(pyproject_text: str, target_version: str) -> tuple[str, str]: + """Replace [project].version in pyproject content. + + Raises: + ValueError: If [project] section or version key is missing. + """ + lines = pyproject_text.splitlines() + in_project = False + version_idx: int | None = None + old_version: str | None = None + + for idx, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_project = stripped == "[project]" + continue + + if not in_project: + continue + + match = re.match(r'^(\s*version\s*=\s*)(["\'])([^"\']+)(["\'])(\s*)$', line) + if match: + quote_start = match.group(2) + quote_end = match.group(4) + if quote_start != quote_end: + raise ValueError("Mismatched quotes in pyproject.toml version line") + version_idx = idx + old_version = match.group(3) + lines[idx] = f"{match.group(1)}{quote_start}{target_version}{quote_end}{match.group(5)}" + break + + if version_idx is None or old_version is None: + raise ValueError("Could not find [project].version in pyproject.toml") + + new_text = "\n".join(lines) + if pyproject_text.endswith("\n"): + new_text += "\n" + + return new_text, old_version + + +def _update_server_json(server_data: dict[str, Any], target_version: str) -> tuple[dict[str, Any], list[FileChange]]: + """Update known version fields in server.json data structure.""" + changes: list[FileChange] = [] + + if "version" not in server_data or not isinstance(server_data["version"], str): + raise ValueError("server.json missing top-level string field: version") + + top_old = server_data["version"] + server_data["version"] = target_version + changes.append(FileChange(path=Path("server.json"), field="version", old=top_old, new=target_version)) + + packages = server_data.get("packages") + if not isinstance(packages, list) or not packages: + raise ValueError("server.json missing non-empty packages list") + + package_updated = False + for idx, pkg in enumerate(packages): + if not isinstance(pkg, dict): + continue + + pkg_version = pkg.get("version") + if isinstance(pkg_version, str): + packages[idx]["version"] = target_version + changes.append( + FileChange( + path=Path("server.json"), + field=f"packages[{idx}].version", + old=pkg_version, + new=target_version, + ) + ) + package_updated = True + + if not package_updated: + raise ValueError("server.json packages list has no string version fields") + + return server_data, changes + + +def update_files(pyproject_path: Path, server_json_path: Path, target_version: str, dry_run: bool) -> list[FileChange]: + """Apply version updates to pyproject.toml and server.json.""" + pyproject_text = pyproject_path.read_text(encoding="utf-8") + pyproject_new, pyproject_old = _replace_project_version(pyproject_text, target_version) + + server_data = json.loads(server_json_path.read_text(encoding="utf-8")) + server_updated, server_changes = _update_server_json(server_data, target_version) + + changes = [ + FileChange(path=pyproject_path, field="[project].version", old=pyproject_old, new=target_version), + *[ + FileChange( + path=server_json_path, + field=change.field, + old=change.old, + new=change.new, + ) + for change in server_changes + ], + ] + + if not dry_run: + pyproject_path.write_text(pyproject_new, encoding="utf-8") + server_json_path.write_text(json.dumps(server_updated, indent=2) + "\n", encoding="utf-8") + + return changes + + +def print_summary(changes: list[FileChange], dry_run: bool, target_version: str) -> None: + """Print deterministic operation summary and next release commands.""" + action = "Dry-run planned" if dry_run else "Applied" + print(f"{action} version changes:") + for change in changes: + print(f"- {change.path}: {change.field} {change.old} -> {change.new}") + + print() + print("Next release commands:") + print("```bash") + print("git add pyproject.toml server.json") + print(f'git commit -m "Bump version to {target_version}"') + print(f"git tag v{target_version}") + print(f"git push origin v{target_version}") + print("```") + print("Then verify the GitHub Actions publish workflow in the Actions tab.") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse CLI arguments.""" + repo_root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="Update publishing versions for release.") + parser.add_argument("version", help="Target semantic version, e.g. 0.4.0") + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without writing files.", + ) + parser.add_argument( + "--pyproject", + type=Path, + default=repo_root / "pyproject.toml", + help="Path to pyproject.toml", + ) + parser.add_argument( + "--server-json", + type=Path, + default=repo_root / "server.json", + help="Path to server.json", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Entrypoint for CLI execution.""" + args = parse_args(argv if argv is not None else sys.argv[1:]) + + if not validate_semver(args.version): + print( + "Error: invalid semantic version. Expected MAJOR.MINOR.PATCH " + "(optional -prerelease and +build metadata supported).", + file=sys.stderr, + ) + return 1 + + if not args.pyproject.exists(): + print(f"Error: file not found: {args.pyproject}", file=sys.stderr) + return 1 + if not args.server_json.exists(): + print(f"Error: file not found: {args.server_json}", file=sys.stderr) + return 1 + + try: + changes = update_files(args.pyproject, args.server_json, args.version, args.dry_run) + except (ValueError, json.JSONDecodeError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print_summary(changes, args.dry_run, args.version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_publish_helper.py b/tests/unit/test_publish_helper.py new file mode 100644 index 00000000..edb5d71b --- /dev/null +++ b/tests/unit/test_publish_helper.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Tests for scripts/publish_helper.py.""" + +import json +import sys +from pathlib import Path + +import pytest + +# Add scripts directory to path for direct module import. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts")) + +from publish_helper import ( # noqa: E402 + _replace_project_version, + main, + update_files, + validate_semver, +) + + +@pytest.fixture +def sample_files(tmp_path: Path) -> tuple[Path, Path]: + """Create sample pyproject.toml and server.json files.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + ( + "[build-system]\n" + 'requires = ["setuptools"]\n' + "\n" + "[project]\n" + 'name = "mcpbridge-wrapper"\n' + 'version = "0.3.2"\n' + ), + encoding="utf-8", + ) + + server_json = tmp_path / "server.json" + server_json.write_text( + json.dumps( + { + "name": "io.github.SoundBlaster/xcode-mcpbridge-wrapper", + "version": "0.3.2", + "packages": [ + { + "registryType": "pypi", + "identifier": "mcpbridge-wrapper", + "version": "0.3.2", + } + ], + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + return pyproject, server_json + + +class TestValidateSemver: + """Semver validation tests.""" + + @pytest.mark.parametrize( + "version", + ["0.1.0", "1.2.3", "2.0.1-rc.1", "3.4.5+build.7", "1.2.3-rc.1+build.5"], + ) + def test_accepts_valid_semver(self, version: str) -> None: + """Valid semantic versions pass validation.""" + assert validate_semver(version) is True + + @pytest.mark.parametrize("version", ["1", "1.2", "v1.2.3", "01.2.3", "1.2.3.4", ""]) + def test_rejects_invalid_semver(self, version: str) -> None: + """Invalid semantic versions fail validation.""" + assert validate_semver(version) is False + + +class TestPyprojectVersionUpdate: + """Tests for pyproject version replacement.""" + + def test_replace_project_version(self) -> None: + """The [project].version field is replaced and previous version returned.""" + content = """[project]\nname = \"pkg\"\nversion = \"0.3.2\"\n""" + updated, old = _replace_project_version(content, "0.4.0") + assert old == "0.3.2" + assert 'version = "0.4.0"' in updated + + def test_replace_project_version_missing_field_raises(self) -> None: + """Missing [project].version should raise a clear error.""" + content = """[project]\nname = \"pkg\"\n""" + with pytest.raises(ValueError, match=r"Could not find \[project\]\.version"): + _replace_project_version(content, "0.4.0") + + +class TestUpdateFiles: + """End-to-end update behavior for both files.""" + + def test_updates_pyproject_and_server_json(self, sample_files: tuple[Path, Path]) -> None: + """Live mode updates version in all required fields.""" + pyproject, server_json = sample_files + changes = update_files(pyproject, server_json, "0.4.0", dry_run=False) + + assert any(change.path == pyproject for change in changes) + assert sum(1 for change in changes if change.path == server_json) >= 2 + assert 'version = "0.4.0"' in pyproject.read_text(encoding="utf-8") + + server_data = json.loads(server_json.read_text(encoding="utf-8")) + assert server_data["version"] == "0.4.0" + assert server_data["packages"][0]["version"] == "0.4.0" + + def test_dry_run_does_not_modify_files(self, sample_files: tuple[Path, Path]) -> None: + """Dry-run mode should report changes without writing files.""" + pyproject, server_json = sample_files + original_pyproject = pyproject.read_text(encoding="utf-8") + original_server = server_json.read_text(encoding="utf-8") + + changes = update_files(pyproject, server_json, "0.4.0", dry_run=True) + + assert changes + assert pyproject.read_text(encoding="utf-8") == original_pyproject + assert server_json.read_text(encoding="utf-8") == original_server + + +def test_main_rejects_invalid_version(capsys: pytest.CaptureFixture[str]) -> None: + """CLI exits non-zero for invalid semantic versions.""" + code = main(["v0.4.0"]) + captured = capsys.readouterr() + assert code == 1 + assert "invalid semantic version" in captured.err + + +def test_main_dry_run_prints_next_commands( + sample_files: tuple[Path, Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI prints publish guidance commands after successful dry-run.""" + pyproject, server_json = sample_files + code = main( + [ + "0.4.0", + "--dry-run", + "--pyproject", + str(pyproject), + "--server-json", + str(server_json), + ] + ) + + captured = capsys.readouterr() + assert code == 0 + assert "Dry-run planned version changes" in captured.out + assert "git tag v0.4.0" in captured.out + assert pyproject.read_text(encoding="utf-8").find('version = "0.3.2"') != -1