From 250267e48c9c0677fb88932ae2d28e7fe87ded47 Mon Sep 17 00:00:00 2001 From: Eric Hibbs Date: Thu, 21 May 2026 12:58:39 -0700 Subject: [PATCH 1/2] ci(version-check): require uv.lock sync alongside pyproject changes Resolves CE-202. Mirrors the workflow + script changes from socket-python-cli#204 so the SDK catches lockfile drift the same way the CLI now does: - workflow: trigger paths drop unused setup.py, add uv.lock; new step fails CI if pyproject.toml is modified without uv.lock. - sync_version.py: new run_uv_lock() helper runs 'uv lock' and signals whether the lockfile changed. Wired into all three exit paths (--dev auto-bump, normal auto-bump, already-bumped) so the hook either updates uv.lock for you or tells you to commit it. --- .github/workflows/version-check.yml | 14 ++++++++++++- .hooks/sync_version.py | 32 ++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 40b9536..a972601 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -4,8 +4,8 @@ on: types: [opened, synchronize, ready_for_review] paths: - 'socketdev/**' - - 'setup.py' - 'pyproject.toml' + - 'uv.lock' permissions: contents: read @@ -44,6 +44,18 @@ jobs: print(f'✅ Version properly incremented from {main_ver} to {pr_ver}') " + - name: Require uv.lock update when pyproject changes + run: | + CHANGED_FILES="$(git diff --name-only origin/main...HEAD)" + + if echo "$CHANGED_FILES" | grep -qx 'pyproject.toml'; then + if ! echo "$CHANGED_FILES" | grep -qx 'uv.lock'; then + echo "❌ pyproject.toml changed, but uv.lock was not updated." + echo "Run 'uv lock' and commit uv.lock with the version bump." + exit 1 + fi + fi + - name: Manage PR Comment uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea if: always() diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 59b0427..ab4ffa3 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -8,6 +8,7 @@ VERSION_FILE = pathlib.Path("socketdev/version.py") PYPROJECT_FILE = pathlib.Path("pyproject.toml") +UV_LOCK_FILE = pathlib.Path("uv.lock") VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]") PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE) @@ -68,6 +69,22 @@ def inject_version(version: str): new_pyproject = PYPROJECT_PATTERN.sub(f'version = "{version}"', pyproject) PYPROJECT_FILE.write_text(new_pyproject) + +def run_uv_lock() -> bool: + before = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b"" + try: + subprocess.run(["uv", "lock"], check=True, text=True) + except FileNotFoundError: + print("❌ `uv` is required but was not found in PATH.") + sys.exit(1) + except subprocess.CalledProcessError: + print("❌ `uv lock` failed. Please run it manually and fix any errors.") + sys.exit(1) + + after = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b"" + return before != after + + def main(): dev_mode = "--dev" in sys.argv current_version = read_version_from_version_file(VERSION_FILE) @@ -80,15 +97,24 @@ def main(): base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version new_version = find_next_available_dev_version(base_version) inject_version(new_version) - print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.") sys.exit(0) else: new_version = bump_patch_version(current_version) inject_version(new_version) - print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.") sys.exit(1) else: - print("✅ Version already bumped — proceeding.") + uv_lock_changed = run_uv_lock() + if uv_lock_changed: + print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.") + sys.exit(1) + + print("✅ Version already bumped and uv.lock is up to date — proceeding.") sys.exit(0) if __name__ == "__main__": From 2cf2cb67768cecf4047284cd0e8e1cfafe43cc15 Mon Sep 17 00:00:00 2001 From: Eric Hibbs Date: Fri, 22 May 2026 14:31:54 -0700 Subject: [PATCH 2/2] ci(version-check): also require PR version > latest PyPI stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors socket-python-cli's fix at 0462b77 (in PR #199). The workflow previously only compared the PR version against main, which missed the case where the same or newer version had already been published to PyPI — that would slip through CI and either collide on publish or leave PyPI ahead of the repo. - workflow: hits pypi.org/pypi/socketdev/json, filters to stable (non-prerelease, non-devrelease), requires PR > max(main, PyPI). - sync_version.py: splits PYPI_PROD_API vs PYPI_TEST_API. Stable auto-bumps now use prod PyPI as the floor via find_next_stable_patch_version(). The .devN flow keeps using TestPyPI. New 'already bumped but ≤ PyPI' path auto-corrects the version when somebody bumps to a stale number. --- .github/workflows/version-check.yml | 45 ++++++++++++++++---- .hooks/sync_version.py | 65 +++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index a972601..6db1426 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -33,16 +33,43 @@ jobs: MAIN_VERSION=$(grep -o "__version__.*" socketdev/version.py | awk '{print $3}' | tr -d '"' | tr -d "'") echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV - # Compare versions using Python - python3 -c " + export PR_VERSION + export MAIN_VERSION + + # Compare against both main and latest published PyPI release. + python3 <<'PY' + import json + import os + import urllib.request from packaging import version - pr_ver = version.parse('${PR_VERSION}') - main_ver = version.parse('${MAIN_VERSION}') - if pr_ver <= main_ver: - print(f'❌ Version must be incremented! Main: {main_ver}, PR: {pr_ver}') - exit(1) - print(f'✅ Version properly incremented from {main_ver} to {pr_ver}') - " + + pr_ver = version.parse(os.environ["PR_VERSION"]) + main_ver = version.parse(os.environ["MAIN_VERSION"]) + + with urllib.request.urlopen("https://pypi.org/pypi/socketdev/json") as response: + pypi_data = json.load(response) + + published_versions = [] + for raw in pypi_data.get("releases", {}).keys(): + parsed = version.parse(raw) + if not parsed.is_prerelease and not parsed.is_devrelease: + published_versions.append(parsed) + + pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") + required_floor = max(main_ver, pypi_ver) + + if pr_ver <= required_floor: + print( + f"❌ Version must be greater than main and PyPI! " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) + raise SystemExit(1) + + print( + f"✅ Version properly incremented. " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) + PY - name: Require uv.lock update when pyproject changes run: | diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index ab4ffa3..7a8ab24 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -12,7 +12,9 @@ VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]") PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE) -PYPI_API = "https://test.pypi.org/pypi/socketdev/json" +STABLE_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +PYPI_PROD_API = "https://pypi.org/pypi/socketdev/json" +PYPI_TEST_API = "https://test.pypi.org/pypi/socketdev/json" def read_version_from_version_file(path: pathlib.Path) -> str: content = path.read_text() @@ -39,17 +41,40 @@ def bump_patch_version(version: str) -> str: parts[-1] = str(int(parts[-1]) + 1) return ".".join(parts) -def fetch_existing_versions() -> set: +def parse_stable_version(version: str): + if not STABLE_VERSION_PATTERN.fullmatch(version): + return None + return tuple(int(part) for part in version.split(".")) + + +def format_stable_version(version_parts) -> str: + return ".".join(str(part) for part in version_parts) + + +def fetch_existing_versions(api_url: str) -> set: try: - with urllib.request.urlopen(PYPI_API) as response: + with urllib.request.urlopen(api_url) as response: data = json.load(response) return set(data.get("releases", {}).keys()) except Exception as e: - print(f"⚠️ Warning: Failed to fetch existing versions from Test PyPI: {e}") + print(f"⚠️ Warning: Failed to fetch versions from {api_url}: {e}") return set() + +def fetch_latest_stable_pypi_version(): + versions = fetch_existing_versions(PYPI_PROD_API) + stable_versions = [] + for ver in versions: + parsed = parse_stable_version(ver) + if parsed is not None: + stable_versions.append(parsed) + if not stable_versions: + return None + return max(stable_versions) + + def find_next_available_dev_version(base_version: str) -> str: - existing_versions = fetch_existing_versions() + existing_versions = fetch_existing_versions(PYPI_TEST_API) for i in range(1, 100): candidate = f"{base_version}.dev{i}" if candidate not in existing_versions: @@ -57,6 +82,20 @@ def find_next_available_dev_version(base_version: str) -> str: print("❌ Could not find available .devN slot after 100 attempts.") sys.exit(1) + +def find_next_stable_patch_version(current_version: str) -> str: + current_stable = current_version.split(".dev")[0] if ".dev" in current_version else current_version + current_parts = parse_stable_version(current_stable) + if current_parts is None: + print(f"❌ Unsupported version format for stable bump: {current_version}") + sys.exit(1) + + latest_pypi_parts = fetch_latest_stable_pypi_version() + base_parts = max([current_parts, latest_pypi_parts] if latest_pypi_parts else [current_parts]) + next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1) + return format_stable_version(next_parts) + + def inject_version(version: str): print(f"🔁 Updating version to: {version}") @@ -102,13 +141,25 @@ def main(): print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.") sys.exit(0) else: - new_version = bump_patch_version(current_version) + new_version = find_next_stable_patch_version(current_version) inject_version(new_version) uv_lock_changed = run_uv_lock() lock_hint = " and uv.lock" if uv_lock_changed else "" - print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.") + print(f"⚠️ Version was unchanged — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.") sys.exit(1) else: + if not dev_mode: + current_parts = parse_stable_version(current_version) + latest_pypi_parts = fetch_latest_stable_pypi_version() + if current_parts is not None and latest_pypi_parts is not None and current_parts <= latest_pypi_parts: + next_parts = (latest_pypi_parts[0], latest_pypi_parts[1], latest_pypi_parts[2] + 1) + new_version = format_stable_version(next_parts) + inject_version(new_version) + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.") + sys.exit(1) + uv_lock_changed = run_uv_lock() if uv_lock_changed: print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")