Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ on:
types: [opened, synchronize, ready_for_review]
paths:
- 'socketdev/**'
- 'setup.py'
- 'pyproject.toml'
- 'uv.lock'

permissions:
contents: read
Expand Down Expand Up @@ -33,16 +33,55 @@ 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: |
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
Expand Down
95 changes: 86 additions & 9 deletions .hooks/sync_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@

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)
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()
Expand All @@ -38,24 +41,61 @@ 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:
return candidate
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}")

Expand All @@ -68,6 +108,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)
Expand All @@ -80,15 +136,36 @@ 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)
new_version = find_next_stable_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 to {new_version}. Please git add{lock_hint} + commit again.")
sys.exit(1)
else:
print("✅ Version already bumped — proceeding.")
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.")
sys.exit(1)

print("✅ Version already bumped and uv.lock is up to date — proceeding.")
sys.exit(0)

if __name__ == "__main__":
Expand Down
Loading