Skip to content

Commit 8b668fd

Browse files
authored
ci(version-check): require uv.lock sync alongside pyproject changes (#82)
* 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. * ci(version-check): also require PR version > latest PyPI stable 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.
1 parent 41039a8 commit 8b668fd

2 files changed

Lines changed: 135 additions & 19 deletions

File tree

.github/workflows/version-check.yml

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ on:
44
types: [opened, synchronize, ready_for_review]
55
paths:
66
- 'socketdev/**'
7-
- 'setup.py'
87
- 'pyproject.toml'
8+
- 'uv.lock'
99

1010
permissions:
1111
contents: read
@@ -33,16 +33,55 @@ jobs:
3333
MAIN_VERSION=$(grep -o "__version__.*" socketdev/version.py | awk '{print $3}' | tr -d '"' | tr -d "'")
3434
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
3535
36-
# Compare versions using Python
37-
python3 -c "
36+
export PR_VERSION
37+
export MAIN_VERSION
38+
39+
# Compare against both main and latest published PyPI release.
40+
python3 <<'PY'
41+
import json
42+
import os
43+
import urllib.request
3844
from packaging import version
39-
pr_ver = version.parse('${PR_VERSION}')
40-
main_ver = version.parse('${MAIN_VERSION}')
41-
if pr_ver <= main_ver:
42-
print(f'❌ Version must be incremented! Main: {main_ver}, PR: {pr_ver}')
43-
exit(1)
44-
print(f'✅ Version properly incremented from {main_ver} to {pr_ver}')
45-
"
45+
46+
pr_ver = version.parse(os.environ["PR_VERSION"])
47+
main_ver = version.parse(os.environ["MAIN_VERSION"])
48+
49+
with urllib.request.urlopen("https://pypi.org/pypi/socketdev/json") as response:
50+
pypi_data = json.load(response)
51+
52+
published_versions = []
53+
for raw in pypi_data.get("releases", {}).keys():
54+
parsed = version.parse(raw)
55+
if not parsed.is_prerelease and not parsed.is_devrelease:
56+
published_versions.append(parsed)
57+
58+
pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0")
59+
required_floor = max(main_ver, pypi_ver)
60+
61+
if pr_ver <= required_floor:
62+
print(
63+
f"❌ Version must be greater than main and PyPI! "
64+
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
65+
)
66+
raise SystemExit(1)
67+
68+
print(
69+
f"✅ Version properly incremented. "
70+
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
71+
)
72+
PY
73+
74+
- name: Require uv.lock update when pyproject changes
75+
run: |
76+
CHANGED_FILES="$(git diff --name-only origin/main...HEAD)"
77+
78+
if echo "$CHANGED_FILES" | grep -qx 'pyproject.toml'; then
79+
if ! echo "$CHANGED_FILES" | grep -qx 'uv.lock'; then
80+
echo "❌ pyproject.toml changed, but uv.lock was not updated."
81+
echo "Run 'uv lock' and commit uv.lock with the version bump."
82+
exit 1
83+
fi
84+
fi
4685
4786
- name: Manage PR Comment
4887
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea

.hooks/sync_version.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88

99
VERSION_FILE = pathlib.Path("socketdev/version.py")
1010
PYPROJECT_FILE = pathlib.Path("pyproject.toml")
11+
UV_LOCK_FILE = pathlib.Path("uv.lock")
1112

1213
VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]")
1314
PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE)
14-
PYPI_API = "https://test.pypi.org/pypi/socketdev/json"
15+
STABLE_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
16+
PYPI_PROD_API = "https://pypi.org/pypi/socketdev/json"
17+
PYPI_TEST_API = "https://test.pypi.org/pypi/socketdev/json"
1518

1619
def read_version_from_version_file(path: pathlib.Path) -> str:
1720
content = path.read_text()
@@ -38,24 +41,61 @@ def bump_patch_version(version: str) -> str:
3841
parts[-1] = str(int(parts[-1]) + 1)
3942
return ".".join(parts)
4043

41-
def fetch_existing_versions() -> set:
44+
def parse_stable_version(version: str):
45+
if not STABLE_VERSION_PATTERN.fullmatch(version):
46+
return None
47+
return tuple(int(part) for part in version.split("."))
48+
49+
50+
def format_stable_version(version_parts) -> str:
51+
return ".".join(str(part) for part in version_parts)
52+
53+
54+
def fetch_existing_versions(api_url: str) -> set:
4255
try:
43-
with urllib.request.urlopen(PYPI_API) as response:
56+
with urllib.request.urlopen(api_url) as response:
4457
data = json.load(response)
4558
return set(data.get("releases", {}).keys())
4659
except Exception as e:
47-
print(f"⚠️ Warning: Failed to fetch existing versions from Test PyPI: {e}")
60+
print(f"⚠️ Warning: Failed to fetch versions from {api_url}: {e}")
4861
return set()
4962

63+
64+
def fetch_latest_stable_pypi_version():
65+
versions = fetch_existing_versions(PYPI_PROD_API)
66+
stable_versions = []
67+
for ver in versions:
68+
parsed = parse_stable_version(ver)
69+
if parsed is not None:
70+
stable_versions.append(parsed)
71+
if not stable_versions:
72+
return None
73+
return max(stable_versions)
74+
75+
5076
def find_next_available_dev_version(base_version: str) -> str:
51-
existing_versions = fetch_existing_versions()
77+
existing_versions = fetch_existing_versions(PYPI_TEST_API)
5278
for i in range(1, 100):
5379
candidate = f"{base_version}.dev{i}"
5480
if candidate not in existing_versions:
5581
return candidate
5682
print("❌ Could not find available .devN slot after 100 attempts.")
5783
sys.exit(1)
5884

85+
86+
def find_next_stable_patch_version(current_version: str) -> str:
87+
current_stable = current_version.split(".dev")[0] if ".dev" in current_version else current_version
88+
current_parts = parse_stable_version(current_stable)
89+
if current_parts is None:
90+
print(f"❌ Unsupported version format for stable bump: {current_version}")
91+
sys.exit(1)
92+
93+
latest_pypi_parts = fetch_latest_stable_pypi_version()
94+
base_parts = max([current_parts, latest_pypi_parts] if latest_pypi_parts else [current_parts])
95+
next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1)
96+
return format_stable_version(next_parts)
97+
98+
5999
def inject_version(version: str):
60100
print(f"🔁 Updating version to: {version}")
61101

@@ -68,6 +108,22 @@ def inject_version(version: str):
68108
new_pyproject = PYPROJECT_PATTERN.sub(f'version = "{version}"', pyproject)
69109
PYPROJECT_FILE.write_text(new_pyproject)
70110

111+
112+
def run_uv_lock() -> bool:
113+
before = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b""
114+
try:
115+
subprocess.run(["uv", "lock"], check=True, text=True)
116+
except FileNotFoundError:
117+
print("❌ `uv` is required but was not found in PATH.")
118+
sys.exit(1)
119+
except subprocess.CalledProcessError:
120+
print("❌ `uv lock` failed. Please run it manually and fix any errors.")
121+
sys.exit(1)
122+
123+
after = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b""
124+
return before != after
125+
126+
71127
def main():
72128
dev_mode = "--dev" in sys.argv
73129
current_version = read_version_from_version_file(VERSION_FILE)
@@ -80,15 +136,36 @@ def main():
80136
base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version
81137
new_version = find_next_available_dev_version(base_version)
82138
inject_version(new_version)
83-
print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.")
139+
uv_lock_changed = run_uv_lock()
140+
lock_hint = " and uv.lock" if uv_lock_changed else ""
141+
print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.")
84142
sys.exit(0)
85143
else:
86-
new_version = bump_patch_version(current_version)
144+
new_version = find_next_stable_patch_version(current_version)
87145
inject_version(new_version)
88-
print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.")
146+
uv_lock_changed = run_uv_lock()
147+
lock_hint = " and uv.lock" if uv_lock_changed else ""
148+
print(f"⚠️ Version was unchanged — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
89149
sys.exit(1)
90150
else:
91-
print("✅ Version already bumped — proceeding.")
151+
if not dev_mode:
152+
current_parts = parse_stable_version(current_version)
153+
latest_pypi_parts = fetch_latest_stable_pypi_version()
154+
if current_parts is not None and latest_pypi_parts is not None and current_parts <= latest_pypi_parts:
155+
next_parts = (latest_pypi_parts[0], latest_pypi_parts[1], latest_pypi_parts[2] + 1)
156+
new_version = format_stable_version(next_parts)
157+
inject_version(new_version)
158+
uv_lock_changed = run_uv_lock()
159+
lock_hint = " and uv.lock" if uv_lock_changed else ""
160+
print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
161+
sys.exit(1)
162+
163+
uv_lock_changed = run_uv_lock()
164+
if uv_lock_changed:
165+
print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")
166+
sys.exit(1)
167+
168+
print("✅ Version already bumped and uv.lock is up to date — proceeding.")
92169
sys.exit(0)
93170

94171
if __name__ == "__main__":

0 commit comments

Comments
 (0)