Skip to content

Commit 2cf2cb6

Browse files
committed
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 250267e commit 2cf2cb6

2 files changed

Lines changed: 94 additions & 16 deletions

File tree

.github/workflows/version-check.yml

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,43 @@ 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
4673
4774
- name: Require uv.lock update when pyproject changes
4875
run: |

.hooks/sync_version.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]")
1414
PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE)
15-
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"
1618

1719
def read_version_from_version_file(path: pathlib.Path) -> str:
1820
content = path.read_text()
@@ -39,24 +41,61 @@ def bump_patch_version(version: str) -> str:
3941
parts[-1] = str(int(parts[-1]) + 1)
4042
return ".".join(parts)
4143

42-
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:
4355
try:
44-
with urllib.request.urlopen(PYPI_API) as response:
56+
with urllib.request.urlopen(api_url) as response:
4557
data = json.load(response)
4658
return set(data.get("releases", {}).keys())
4759
except Exception as e:
48-
print(f"⚠️ Warning: Failed to fetch existing versions from Test PyPI: {e}")
60+
print(f"⚠️ Warning: Failed to fetch versions from {api_url}: {e}")
4961
return set()
5062

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+
5176
def find_next_available_dev_version(base_version: str) -> str:
52-
existing_versions = fetch_existing_versions()
77+
existing_versions = fetch_existing_versions(PYPI_TEST_API)
5378
for i in range(1, 100):
5479
candidate = f"{base_version}.dev{i}"
5580
if candidate not in existing_versions:
5681
return candidate
5782
print("❌ Could not find available .devN slot after 100 attempts.")
5883
sys.exit(1)
5984

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+
6099
def inject_version(version: str):
61100
print(f"🔁 Updating version to: {version}")
62101

@@ -102,13 +141,25 @@ def main():
102141
print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.")
103142
sys.exit(0)
104143
else:
105-
new_version = bump_patch_version(current_version)
144+
new_version = find_next_stable_patch_version(current_version)
106145
inject_version(new_version)
107146
uv_lock_changed = run_uv_lock()
108147
lock_hint = " and uv.lock" if uv_lock_changed else ""
109-
print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.")
148+
print(f"⚠️ Version was unchanged — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
110149
sys.exit(1)
111150
else:
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+
112163
uv_lock_changed = run_uv_lock()
113164
if uv_lock_changed:
114165
print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")

0 commit comments

Comments
 (0)