diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 40b9536..6db1426 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 @@ -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 diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 59b0427..7a8ab24 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -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() @@ -38,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: @@ -56,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}") @@ -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) @@ -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__": diff --git a/pyproject.toml b/pyproject.toml index 5bbc081..0fc8167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socketdev" -version = "3.1.0" +version = "3.1.1" requires-python = ">= 3.9" dependencies = [ 'requests', diff --git a/socketdev/core/issues.py b/socketdev/core/issues.py index d712056..027ad98 100644 --- a/socketdev/core/issues.py +++ b/socketdev/core/issues.py @@ -463,7 +463,7 @@ class didYouMean: def __init__(self): self.description = "Package name is similar to other popular packages and may not be the package you want." - self.props = {"alternatePackage": "Alternate package", "downloads": "Downloads", "downloadsRatio": "Download ratio", "editDistance": "Edit distance"} + self.props = {"alternatePackage": "Alternate package", "detectedAt": "Detected at"} self.suggestion = "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages." self.title = "Possible typosquat attack" self.emoji = "\ud83e\uddd0" diff --git a/socketdev/version.py b/socketdev/version.py index f5f41e5..d539d50 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.1.0" +__version__ = "3.1.1" diff --git a/tests/unit/test_issues_did_you_mean_props.py b/tests/unit/test_issues_did_you_mean_props.py new file mode 100644 index 0000000..8657872 --- /dev/null +++ b/tests/unit/test_issues_did_you_mean_props.py @@ -0,0 +1,28 @@ +"""Contract test for the didYouMean alert-type class's props. + +The OpenAPI schema (`socket-sdk-js/openapi.json` around line 9298) declares +that the API emits `didYouMean` alerts with ``props: { alternatePackage, +detectedAt }``. The Python SDK previously declared four props +(``alternatePackage``, ``downloads``, ``downloadsRatio``, ``editDistance``); +the latter three are no longer in the API schema and were dead keys at +runtime — and ``detectedAt`` was missing. + +Tracks CUS2-5. Sibling of CUS2-4. +""" + +import unittest + +from socketdev.core.issues import didYouMean + + +class TestDidYouMeanProps(unittest.TestCase): + def test_props_match_openapi_schema(self): + """API emits props { alternatePackage, detectedAt } (openapi.json:9298).""" + issue = didYouMean() + self.assertEqual(set(issue.props.keys()), {"alternatePackage", "detectedAt"}) + + def test_props_label_strings_are_non_empty(self): + """Every props key must have a non-empty human-readable label.""" + issue = didYouMean() + for key, label in issue.props.items(): + self.assertTrue(label, f"props[{key!r}] label should not be empty") diff --git a/uv.lock b/uv.lock index a863a5c..9c96b4d 100644 --- a/uv.lock +++ b/uv.lock @@ -1343,7 +1343,7 @@ wheels = [ [[package]] name = "socketdev" -version = "3.1.0" +version = "3.1.1" source = { editable = "." } dependencies = [ { name = "requests" },