@@ -3,7 +3,13 @@ name: Publish to PyPI
33on :
44 push :
55 tags :
6- - " v*" # Triggers when a new GitHub Tag is published eg: v1.2.3
6+ - " v*" # Triggers on tag push (e.g. git push origin --tags)
7+ release :
8+ types : [ published ] # Triggers on GitHub UI Release creation
9+
10+ concurrency :
11+ group : publish-${{ github.ref }}
12+ cancel-in-progress : false
713
814jobs :
915 publish :
@@ -27,37 +33,111 @@ jobs:
2733 - name : Install dependencies
2834 run : make setup-dev
2935
30- - name : Install versioning deps
36+ # ---- VERSION EXTRACTION (handles push-tag + release events, with/without v prefix) ----
37+ - name : Extract and validate version from tag
3138 run : |
32- python -m pip install --upgrade pip
33- pip install tomlkit
39+ # Get tag name — works for both push (refs/tags/v1.2.3) and release events
40+ if [ "${{ github.event_name }}" = "release" ]; then
41+ TAG="${{ github.event.release.tag_name }}"
42+ else
43+ TAG="${GITHUB_REF#refs/tags/}"
44+ fi
45+
46+ # Strip optional 'v' prefix: v2.1.0 → 2.1.0, 2.1.0 → 2.1.0
47+ VERSION="${TAG#v}"
48+
49+ # Validate PEP 440 format
50+ python3 -c "
51+ import re, sys
52+ v = '$VERSION'
53+ if not re.fullmatch(r'\d+\.\d+\.\d+([.](post|dev)\d+|(a|b|rc)\d+)?', v):
54+ print(f'❌ Tag \'{TAG}\' does not contain a valid PEP 440 version (extracted: \'{v}\')')
55+ print(' Expected formats: X.Y.Z, X.Y.Z.postN, X.Y.Z.devN, X.Y.ZaN, X.Y.ZbN, X.Y.ZrcN')
56+ sys.exit(1)
57+ "
58+
59+ # Export for all subsequent steps
60+ echo "VERSION=$VERSION" >> $GITHUB_ENV
61+ echo "✅ Extracted version: $VERSION (from tag: $TAG, event: ${{ github.event_name }})"
3462
35- - name : Set version from GitHub tag
63+ # ---- PRE-FLIGHT: Check version is not already burnt on PyPI ----
64+ - name : Check version is available on PyPI
3665 run : |
37- # Extract tag like "v1.2.3" → "1.2.3"
38- export VERSION="${GITHUB_REF#refs/tags/v}"
39- echo "Setting [project].version to $VERSION"
40- python - << 'PY'
41- from pathlib import Path
42- from tomlkit import parse, dumps
43- import os
44- version = os.environ["VERSION"]
45- p = Path('pyproject.toml')
46- doc = parse(p.read_text(encoding='utf-8'))
47- # Update PEP 621 version
48- if 'project' in doc:
49- doc['project']['version'] = version
50- p.write_text(dumps(doc), encoding='utf-8')
51- PY
66+ python3 -c "
67+ import urllib.request, urllib.error, sys
68+ try:
69+ urllib.request.urlopen('https://pypi.org/pypi/sygra/${{ env.VERSION }}/json')
70+ print('❌ Version ${{ env.VERSION }} already exists on PyPI — this version is burnt.')
71+ print(' Options:')
72+ print(' • Use a .postN suffix: v${{ env.VERSION }}.post1')
73+ print(' • Bump to the next version: make bump-version V=X.Y.Z')
74+ sys.exit(1)
75+ except urllib.error.HTTPError as e:
76+ if e.code == 404:
77+ print('✅ Version ${{ env.VERSION }} is available on PyPI')
78+ sys.exit(0)
79+ print(f'⚠️ PyPI check returned HTTP {e.code} — proceeding anyway')
80+ except Exception as e:
81+ print(f'⚠️ PyPI check failed ({e}) — proceeding anyway')
82+ "
83+
84+ # ---- PATCH VERSION ----
85+ - name : Set version in source files
86+ run : |
87+ echo "Setting __version__ to $VERSION"
88+
89+ # Patch sygra/__init__.py (hatchling reads version from here)
90+ python3 -c "
91+ import re, pathlib
92+ p = pathlib.Path('sygra/__init__.py')
93+ p.write_text(re.sub(r'^__version__ = \".*\"', '__version__ = \"$VERSION\"', p.read_text(), count=1, flags=re.MULTILINE))
94+ "
5295
96+ # Patch [tool.poetry] version so Poetry stays consistent
97+ python3 -c "
98+ import re, pathlib
99+ p = pathlib.Path('pyproject.toml')
100+ p.write_text(re.sub(r'(\[tool\.poetry\]\nversion\s*=\s*)\"[^\"]*\"', r'\g<1>\"$VERSION\"', p.read_text(), count=1))
101+ "
102+
103+ # Verify
104+ grep '__version__' sygra/__init__.py
105+ echo "✅ Version set to $VERSION"
106+
107+ - name : Validate version consistency
108+ run : |
109+ BUILT_VERSION=$(python3 -c "import re; m=re.search(r'__version__\s*=\s*\"(.+?)\"', open('sygra/__init__.py').read()); print(m.group(1))")
110+ if [ "$VERSION" != "$BUILT_VERSION" ]; then
111+ echo "❌ Version mismatch: tag=$VERSION, __init__.py=$BUILT_VERSION"
112+ exit 1
113+ fi
114+ echo "✅ Version validated: $VERSION"
115+
116+ # ---- BUILD ----
53117 - name : Build package
54118 run : make build
55119
120+ - name : Validate built artifacts
121+ run : |
122+ ls dist/
123+ if ! ls dist/sygra-${VERSION}-*.whl 1>/dev/null 2>&1; then
124+ echo "❌ No wheel found for version $VERSION in dist/"
125+ ls dist/
126+ exit 1
127+ fi
128+ if ! ls dist/sygra-${VERSION}.tar.gz 1>/dev/null 2>&1; then
129+ echo "❌ No sdist found for version $VERSION in dist/"
130+ ls dist/
131+ exit 1
132+ fi
133+ echo "✅ Built artifacts verified for version $VERSION"
134+
135+ # ---- PUBLISH ----
56136 - name : Publish to PyPI
57137 env :
58138 TWINE_USERNAME : __token__
59139 TWINE_PASSWORD : ${{ secrets.PYPI_API_TOKEN }}
60140 run : |
61- python -m pip install --upgrade pip
62- pip install twine
63- python -m twine upload --repository pypi dist/* --verbose
141+ python3 -m pip install --upgrade pip
142+ python3 -m pip install twine
143+ python3 -m twine upload --repository pypi dist/* --verbose
0 commit comments