Skip to content

Commit c08dc5b

Browse files
author
FirstUnicorn
committed
feat: add version change detection before PyPI publishing
Safeguard against publishing unchanged code with same version: Production (publish-pypi.yml): - Checks if built version already exists on PyPI - If yes, verifies if code has changed via git history - Fails with clear error if code changed but version not bumped - Prevents false-positive 'success' from skip-existing TestPyPI (publish-testpypi.yml): - Same check but only warns (allows re-publishing for testing) - Maintains flexibility for development workflow Benefits: - Catches forgotten version bumps before publishing - Clear error messages with actionable steps - Prevents confusion when skip-existing silently skips - Enforces semantic versioning discipline
1 parent 89cb11b commit c08dc5b

2 files changed

Lines changed: 156 additions & 0 deletions

File tree

.github/workflows/publish-pypi.yml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,104 @@ jobs:
6666
name: distributions
6767
path: dist/
6868

69+
- name: Check for version changes vs PyPI
70+
run: |
71+
pip install requests packaging
72+
73+
python << 'EOF'
74+
import os
75+
import json
76+
import requests
77+
from pathlib import Path
78+
from packaging import version
79+
80+
dist_dir = Path('dist')
81+
errors = []
82+
83+
print("=" * 70)
84+
print("CHECKING: Built versions vs PyPI published versions")
85+
print("=" * 70)
86+
87+
for wheel in dist_dir.glob('*.whl'):
88+
# Extract package name and version from wheel filename
89+
# Format: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
90+
parts = wheel.name.split('-')
91+
pkg_name = parts[0].replace('_', '-').lower()
92+
pkg_version = parts[1]
93+
94+
print(f"\nChecking {pkg_name} (built: {pkg_version})...")
95+
96+
# Check PyPI API
97+
try:
98+
response = requests.get(
99+
f'https://pypi.org/pypi/{pkg_name}/json',
100+
timeout=10
101+
)
102+
103+
if response.status_code == 404:
104+
print(f" ✓ NEW PACKAGE - Not on PyPI yet (will publish)")
105+
continue
106+
elif response.status_code != 200:
107+
print(f" ⚠ WARNING - PyPI API error: {response.status_code}")
108+
continue
109+
110+
pypi_data = response.json()
111+
pypi_versions = list(pypi_data['releases'].keys())
112+
latest_pypi = max(pypi_versions, key=version.parse) if pypi_versions else None
113+
114+
if pkg_version in pypi_versions:
115+
# Version exists on PyPI - check if code changed
116+
print(f" ⚠ VERSION {pkg_version} ALREADY EXISTS on PyPI")
117+
118+
# Get git commit hash for this package
119+
import subprocess
120+
try:
121+
result = subprocess.run(
122+
['git', 'log', '-1', '--format=%h', '--', f'packages/{pkg_name.replace("-", "_")}'],
123+
capture_output=True, text=True, timeout=10
124+
)
125+
current_commit = result.stdout.strip()
126+
127+
if current_commit:
128+
print(f" Current commit: {current_commit}")
129+
print(f" ❌ ERROR: Code changed but version not bumped!")
130+
errors.append(
131+
f"{pkg_name}: Built version {pkg_version} already on PyPI "
132+
f"but code has changed (commit {current_commit}). "
133+
f"Bump version in pyproject.toml"
134+
)
135+
else:
136+
print(f" ⚠ WARNING: Cannot determine git changes")
137+
except Exception as e:
138+
print(f" ⚠ WARNING: Could not check git history: {e}")
139+
else:
140+
print(f" ✓ NEW VERSION {pkg_version} (will publish)")
141+
if latest_pypi:
142+
print(f" Latest on PyPI: {latest_pypi}")
143+
144+
except requests.RequestException as e:
145+
print(f" ⚠ WARNING - Could not check PyPI: {e}")
146+
147+
print("\n" + "=" * 70)
148+
149+
if errors:
150+
print("❌ VERSION CHECK FAILED")
151+
print("\nErrors:")
152+
for error in errors:
153+
print(f" - {error}")
154+
print("\nAction required:")
155+
print(" 1. Bump version in package's pyproject.toml")
156+
print(" 2. Commit the version change")
157+
print(" 3. Re-run this workflow")
158+
exit(1)
159+
else:
160+
print("✅ VERSION CHECK PASSED")
161+
print("\nAll packages either:")
162+
print(" - Are new (not on PyPI yet)")
163+
print(" - Have new versions not yet published")
164+
print("\nProceeding with publication...")
165+
EOF
166+
69167
- name: Publish to PyPI
70168
uses: pypa/gh-action-pypi-publish@release/v1
71169
with:

.github/workflows/publish-testpypi.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,64 @@ jobs:
5353
name: distributions
5454
path: dist/
5555

56+
- name: Check for version changes vs TestPyPI
57+
run: |
58+
pip install requests packaging
59+
60+
python << 'EOF'
61+
import os
62+
import json
63+
import requests
64+
from pathlib import Path
65+
from packaging import version
66+
67+
dist_dir = Path('dist')
68+
errors = []
69+
70+
print("=" * 70)
71+
print("CHECKING: Built versions vs TestPyPI published versions")
72+
print("=" * 70)
73+
74+
for wheel in dist_dir.glob('*.whl'):
75+
parts = wheel.name.split('-')
76+
pkg_name = parts[0].replace('_', '-').lower()
77+
pkg_version = parts[1]
78+
79+
print(f"\nChecking {pkg_name} (built: {pkg_version})...")
80+
81+
try:
82+
response = requests.get(
83+
f'https://test.pypi.org/pypi/{pkg_name}/json',
84+
timeout=10
85+
)
86+
87+
if response.status_code == 404:
88+
print(f" ✓ NEW PACKAGE - Not on TestPyPI yet (will publish)")
89+
continue
90+
elif response.status_code != 200:
91+
print(f" ⚠ WARNING - TestPyPI API error: {response.status_code}")
92+
continue
93+
94+
pypi_data = response.json()
95+
pypi_versions = list(pypi_data['releases'].keys())
96+
latest_pypi = max(pypi_versions, key=version.parse) if pypi_versions else None
97+
98+
if pkg_version in pypi_versions:
99+
print(f" ⚠ VERSION {pkg_version} ALREADY EXISTS on TestPyPI")
100+
print(f" ℹ️ TestPyPI: Allowing re-publish for testing")
101+
else:
102+
print(f" ✓ NEW VERSION {pkg_version} (will publish)")
103+
if latest_pypi:
104+
print(f" Latest on TestPyPI: {latest_pypi}")
105+
106+
except requests.RequestException as e:
107+
print(f" ⚠ WARNING - Could not check TestPyPI: {e}")
108+
109+
print("\n" + "=" * 70)
110+
print("✅ TESTPYPI CHECK PASSED (proceeding with publication)")
111+
print("Note: TestPyPI allows re-publishing same versions for testing")
112+
EOF
113+
56114
- name: Publish to TestPyPI
57115
uses: pypa/gh-action-pypi-publish@release/v1
58116
with:

0 commit comments

Comments
 (0)