From 04e97b4e2a152ab00b45a0d1eb88b5a6a4734f54 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:17:23 +0530 Subject: [PATCH] fix: auto-sync .bumpversion.cfg on version bump Root-cause fix for the recurring version drift. The publish pipeline bumps `_version.py` via scripts/bump_version.py, which only ever wrote that one file, while the "Commit version bump" step staged only `_version.py` too. `.bumpversion.cfg` was therefore never advanced by CI, so it fell behind on every release and check_version_consistency.py (and the pre-commit hook) failed on a clean tree. Two-part fix so the two versions stay consistent going forward: - bump_version.py: add sync_bumpversion_cfg(), called after write_version(), which rewrites the cfg's `current_version` line in place (tolerates the file being absent; honours --dry-run). _version.py stays the source of truth. - python-publish.yml: the commit step now also `git add .bumpversion.cfg`, so the sync is committed to git, not just left on the runner's disk. Also corrects the current drift (cfg 1.0.19 -> 1.1.10) and updates CLAUDE.md, which previously documented the drift as an accepted quirk. Note: .github/workflows/ is CODEOWNERS-protected, so this PR needs @amitray007 review on the workflow change. New tests in tests/test_bump_version.py cover the bump arithmetic and the sync (updates only current_version, tolerates a missing file, raises on a malformed cfg, idempotent, and leaves both files on the same version). Co-Authored-By: Claude Opus 5 --- .bumpversion.cfg | 2 +- .github/workflows/python-publish.yml | 3 + CLAUDE.md | 2 +- scripts/bump_version.py | 38 +++++++++- tests/test_bump_version.py | 104 +++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 tests/test_bump_version.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6bcbf1c..25d305f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.19 +current_version = 1.1.10 commit = True tag = True tag_name = v{new_version} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 54f35dd..d272081 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -151,6 +151,9 @@ jobs: - name: Commit version bump run: | git add etsy_python/_version.py + # bump_version.py also syncs .bumpversion.cfg; stage it so the two + # versions stay consistent in git (not just on the runner's disk). + git add .bumpversion.cfg # Always skip CI for version bump commits to prevent infinite loops git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]" diff --git a/CLAUDE.md b/CLAUDE.md index 2ab6483..3826bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ cp specs/latest.json specs/baseline.json **Single source of truth**: `etsy_python/_version.py` -`setup.py` reads version dynamically from `_version.py`. The `.bumpversion.cfg` file tracks version for the bump tool but can drift out of sync -- always trust `_version.py`. +`setup.py` reads version dynamically from `_version.py`, which remains the single source of truth -- always trust `_version.py`. The `.bumpversion.cfg` file tracks version for the `bump2version` tool (which CI does not run); `scripts/bump_version.py` now keeps its `current_version` synced automatically on every bump, and the publish workflow commits both files together, so the two should no longer drift. A pre-commit hook (`.pre-commit-config.yaml`) runs `scripts/check_version_consistency.py` to validate `_version.py` and `.bumpversion.cfg` agree. diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 4162228..fb9ab11 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -62,11 +62,36 @@ def write_version(version_file, new_version): content = f'''"""Version information for etsy-python package.""" __version__ = "{new_version}"''' - + with open(version_file, 'w') as f: f.write(content) +def sync_bumpversion_cfg(cfg_file, new_version): + """Update ``current_version`` in .bumpversion.cfg to match _version.py. + + _version.py is the source of truth (see CLAUDE.md); .bumpversion.cfg is only + read by the bump2version tool, which CI does not run. Keeping the two in sync + here stops check_version_consistency.py from flapping after every auto-bump. + + Returns True if the file was updated, False if it is absent (nothing to do). + """ + if not cfg_file.exists(): + return False + + content = cfg_file.read_text() + new_content, count = re.subn( + r'(?m)^(current_version\s*=\s*).*$', + rf'\g<1>{new_version}', + content, + ) + if count == 0: + raise ValueError(f"Could not find current_version in {cfg_file}") + + cfg_file.write_text(new_content) + return True + + def get_latest_commit_message(): """Get the latest commit message to determine bump type.""" import subprocess @@ -126,7 +151,8 @@ def main(): script_dir = Path(__file__).parent project_root = script_dir.parent version_file = project_root / 'etsy_python' / '_version.py' - + cfg_file = project_root / '.bumpversion.cfg' + if not version_file.exists(): print(f"Error: Version file not found at {version_file}") sys.exit(1) @@ -156,11 +182,17 @@ def main(): # Write new version write_version(version_file, new_version) print(f"Version updated to {new_version}") - + + # Keep .bumpversion.cfg in sync so the consistency check doesn't drift + if sync_bumpversion_cfg(cfg_file, new_version): + print(f".bumpversion.cfg synced to {new_version}") + # Output new version for GitHub Actions print(f"::set-output name=version::{new_version}") else: print("Dry run - no changes made") + if cfg_file.exists(): + print(f"Dry run - would sync .bumpversion.cfg to {new_version}") return 0 diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 0000000..eee3eca --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,104 @@ +"""Tests for scripts/bump_version.py — version arithmetic and the +.bumpversion.cfg sync that keeps it consistent with _version.py. +""" + +import sys +from pathlib import Path + +import pytest + +# scripts/ is not a package; put it on the path so we can import the tool. +SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import bump_version # noqa: E402 + + +# --------------------------------------------------------------------------- # +# bump_version — arithmetic +# --------------------------------------------------------------------------- # +class TestBumpVersion: + @pytest.mark.parametrize( + "current,bump_type,expected", + [ + ("1.1.10", "patch", "1.1.11"), + ("1.1.10", "minor", "1.2.0"), + ("1.1.10", "major", "2.0.0"), + ("0.0.0", "patch", "0.0.1"), + ("9.9.9", "major", "10.0.0"), + ], + ) + def test_bump(self, current, bump_type, expected): + assert bump_version.bump_version(current, bump_type) == expected + + def test_invalid_bump_type_raises(self): + with pytest.raises(ValueError): + bump_version.bump_version("1.0.0", "sideways") + + def test_malformed_version_raises(self): + with pytest.raises(ValueError): + bump_version.parse_version("1.0") + + +# --------------------------------------------------------------------------- # +# sync_bumpversion_cfg +# --------------------------------------------------------------------------- # +class TestSyncBumpversionCfg: + def _cfg(self, tmp_path, current="1.0.19"): + p = tmp_path / ".bumpversion.cfg" + p.write_text( + f"[bumpversion]\n" + f"current_version = {current}\n" + f"commit = True\n\n" + f"[bumpversion:file:etsy_python/_version.py]\n" + f"search = __version__ = \"{{current_version}}\"\n" + ) + return p + + def test_updates_current_version(self, tmp_path): + cfg = self._cfg(tmp_path, "1.0.19") + assert bump_version.sync_bumpversion_cfg(cfg, "1.1.10") is True + assert "current_version = 1.1.10" in cfg.read_text() + assert "1.0.19" not in cfg.read_text() + + def test_only_touches_current_version_line(self, tmp_path): + # The bumpversion:file section also references the version via a + # template; that line must be left alone. + cfg = self._cfg(tmp_path, "1.0.19") + bump_version.sync_bumpversion_cfg(cfg, "1.1.10") + content = cfg.read_text() + assert 'search = __version__ = "{current_version}"' in content + assert content.count("1.1.10") == 1 # only the current_version line + + def test_missing_file_returns_false(self, tmp_path): + assert bump_version.sync_bumpversion_cfg(tmp_path / "nope.cfg", "1.1.10") is False + + def test_no_current_version_line_raises(self, tmp_path): + p = tmp_path / ".bumpversion.cfg" + p.write_text("[bumpversion]\ncommit = True\n") + with pytest.raises(ValueError): + bump_version.sync_bumpversion_cfg(p, "1.1.10") + + def test_idempotent(self, tmp_path): + cfg = self._cfg(tmp_path, "1.1.10") + bump_version.sync_bumpversion_cfg(cfg, "1.1.10") + assert "current_version = 1.1.10" in cfg.read_text() + + +# --------------------------------------------------------------------------- # +# Integration: write_version + sync leave the repo consistent +# --------------------------------------------------------------------------- # +class TestWriteAndSyncConsistency: + def test_both_files_end_on_same_version(self, tmp_path): + vfile = tmp_path / "_version.py" + vfile.write_text('__version__ = "1.1.10"') + cfg = tmp_path / ".bumpversion.cfg" + cfg.write_text("[bumpversion]\ncurrent_version = 1.1.10\n") + + new = bump_version.bump_version(bump_version.read_version(vfile), "patch") + bump_version.write_version(vfile, new) + bump_version.sync_bumpversion_cfg(cfg, new) + + assert bump_version.read_version(vfile) == "1.1.11" + assert f"current_version = {new}" in cfg.read_text()