-
Notifications
You must be signed in to change notification settings - Fork 0
270 lines (238 loc) · 12 KB
/
publish-pypi.yml
File metadata and controls
270 lines (238 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
name: Publish Package to PyPI
run-name: "[Publish PyPI] Ref:${{ github.ref_name }} Event:${{ github.event_name }}"
on:
workflow_call:
inputs:
python_version:
required: false
type: string
default: '3.11'
description: 'Python version to use'
registry_url:
required: false
type: string
default: 'https://pypi.org'
description: 'PyPI registry URL'
enable_provenance:
required: false
type: boolean
default: true
description: 'Whether to enable package provenance'
secrets:
PYPI_TOKEN:
required: true
description: 'PyPI token for publishing packages'
TEST_PYPI_TOKEN:
required: false
description: 'TestPyPI token (if different from PYPI_TOKEN)'
release:
types: [published]
jobs:
verify-release:
uses: ./.github/workflows/verify-release.yml
validate-package:
needs: [verify-release]
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
package_taken: ${{ steps.check_package.outputs.package_taken }}
package_name: ${{ steps.get_package_name.outputs.package_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get package name
id: get_package_name
run: |
# Extract package name from pyproject.toml or setup.py
if [ -f "pyproject.toml" ]; then
PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])")
elif [ -f "setup.py" ]; then
PACKAGE_NAME=$(python -c "import ast; tree = ast.parse(open('setup.py').read()); print([n.value.s for n in ast.walk(tree) if isinstance(n, ast.Constant) and hasattr(n, 'value') and isinstance(n.value, str) and 'name=' in str(tree)][0])" | grep -o 'name=[^,]*' | cut -d'=' -f2 | tr -d "'\"")
else
echo "::error::No pyproject.toml or setup.py found"
exit 1
fi
echo "package_name=$PACKAGE_NAME" >> $GITHUB_OUTPUT
- name: Validate package configuration
run: |
# Check for required files
if [ ! -f "pyproject.toml" ] && [ ! -f "setup.py" ]; then
echo "::error::Missing package configuration file (pyproject.toml or setup.py)"
exit 1
fi
# Validate pyproject.toml if it exists
if [ -f "pyproject.toml" ]; then
if ! python -c "import tomllib; tomllib.load(open('pyproject.toml', 'rb'))" 2>/dev/null; then
echo "::error::Invalid pyproject.toml file"
exit 1
fi
# Check for required fields in pyproject.toml
REQUIRED_FIELDS=("name" "version" "description" "authors" "license")
for field in "${REQUIRED_FIELDS[@]}"; do
if ! python -c "import tomllib; data = tomllib.load(open('pyproject.toml', 'rb')); print(data['project']['$field'])" 2>/dev/null; then
echo "::error::Missing required field '$field' in pyproject.toml"
exit 1
fi
done
fi
# Validate setup.py if it exists
if [ -f "setup.py" ]; then
if ! python -c "import ast; ast.parse(open('setup.py').read())" 2>/dev/null; then
echo "::error::Invalid setup.py file"
exit 1
fi
fi
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python_version || '3.11' }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine requests
- name: Install package dependencies
run: |
if [ -f "requirements.txt" ]; then
pip install -r requirements.txt
fi
if [ -f "pyproject.toml" ]; then
pip install -e .
fi
# Check for typosquatting
- name: Checking typosquatting patterns
run: |
PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])" 2>/dev/null || python -c "import ast; tree = ast.parse(open('setup.py').read()); print([n.value.s for n in ast.walk(tree) if isinstance(n, ast.Constant) and hasattr(n, 'value') and isinstance(n.value, str) and 'name=' in str(tree)][0])" | grep -o 'name=[^,]*' | cut -d'=' -f2 | tr -d "'\"")
echo "🔍 Checking typosquatting patterns of your package name: $PACKAGE_NAME"
echo "----------------------------------------"
# Common typosquatting patterns with descriptions
declare -A PATTERNS=()
# Only add hyphen/underscore conversions if the character exists
[[ $PACKAGE_NAME == *"-"* ]] && PATTERNS["${PACKAGE_NAME//-/_}"]="Hyphen to underscore"
[[ $PACKAGE_NAME == *"_"* ]] && PATTERNS["${PACKAGE_NAME//_/-}"]="Underscore to hyphen"
# Add Python-specific patterns
PATTERNS["${PACKAGE_NAME}-py"]="Added '-py' suffix"
PATTERNS["py-${PACKAGE_NAME}"]="Added 'py-' prefix"
PATTERNS["python-${PACKAGE_NAME}"]="Added 'python-' prefix"
# Only add character substitutions if the character exists in the package name
[[ $PACKAGE_NAME == *"a"* ]] && PATTERNS["${PACKAGE_NAME//a/4}"]="Replaced 'a' with '4'"
[[ $PACKAGE_NAME == *"e"* ]] && PATTERNS["${PACKAGE_NAME//e/3}"]="Replaced 'e' with '3'"
[[ $PACKAGE_NAME == *"i"* ]] && PATTERNS["${PACKAGE_NAME//i/1}"]="Replaced 'i' with '1'"
[[ $PACKAGE_NAME == *"o"* ]] && PATTERNS["${PACKAGE_NAME//o/0}"]="Replaced 'o' with '0'"
[[ $PACKAGE_NAME == *"s"* ]] && PATTERNS["${PACKAGE_NAME//s/5}"]="Replaced 's' with '5'"
[[ $PACKAGE_NAME == *"t"* ]] && PATTERNS["${PACKAGE_NAME//t/7}"]="Replaced 't' with '7'"
FOUND_SQUATTERS=false
echo "📦 Found packages names similar to yours $PACKAGE_NAME"
echo "----------------------------------------"
for pattern in "${!PATTERNS[@]}"; do
if python -c "import requests; response = requests.get('https://pypi.org/pypi/$pattern/json'); exit(0 if response.status_code == 200 else 1)" 2>/dev/null; then
FOUND_SQUATTERS=true
VERSION=$(python -c "import requests; response = requests.get('https://pypi.org/pypi/$pattern/json'); data = response.json(); print(data['info']['version'])" 2>/dev/null || echo "unknown")
AUTHOR=$(python -c "import requests; response = requests.get('https://pypi.org/pypi/$pattern/json'); data = response.json(); print(data['info']['author'])" 2>/dev/null || echo "unknown")
echo "⚠️ Package: $pattern"
echo "Pattern: ${PATTERNS[$pattern]}"
echo "Version: $VERSION"
echo "Author: $AUTHOR"
echo "URL: https://pypi.org/project/$pattern"
echo "----------------------------------------"
fi
done
if [ "$FOUND_SQUATTERS" = true ]; then
echo "::warning::Potential typosquatters detected (see the workflow logs). Consider registering these names to protect your package."
echo "TYPOSQUATTERS_FOUND=true" >> $GITHUB_ENV
else
echo "✅ No typosquatters found."
echo "TYPOSQUATTERS_FOUND=false" >> $GITHUB_ENV
fi
# Check if package name is available
- name: Checking package availability
id: check_package
run: |
PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])" 2>/dev/null || python -c "import ast; tree = ast.parse(open('setup.py').read()); print([n.value.s for n in ast.walk(tree) if isinstance(n, ast.Constant) and hasattr(n, 'value') and isinstance(n.value, str) and 'name=' in str(tree)][0])" | grep -o 'name=[^,]*' | cut -d'=' -f2 | tr -d "'\"")
# Build the package for testing
echo "🔨 Building package for ownership test..."
python -m build
# Check if package exists on PyPI
if python -c "import requests; response = requests.get('https://pypi.org/pypi/$PACKAGE_NAME/json'); exit(0 if response.status_code == 200 else 1)" 2>/dev/null; then
echo "Package name $PACKAGE_NAME exists on PyPI."
echo "package_taken=true" >> $GITHUB_OUTPUT
# Test ownership by attempting a dry-run upload to TestPyPI
echo "🔍 Testing package ownership with TestPyPI dry-run..."
UPLOAD_OUTPUT=$(twine upload --repository testpypi --skip-existing --non-interactive dist/* 2>&1 || true)
# Check if the upload was successful (indicating we own the package)
if echo "$UPLOAD_OUTPUT" | grep -q "File .* already exists"; then
echo "✅ Package $PACKAGE_NAME exists and we have ownership (upload would succeed)"
echo "Proceeding with update to existing package."
elif echo "$UPLOAD_OUTPUT" | grep -q "HTTPError: 403"; then
echo "::error::Package $PACKAGE_NAME exists on PyPI but we do not have ownership."
echo "Upload failed with 403 Forbidden - you do not have permission to update this package."
echo "Please choose a different package name or contact the package owner."
exit 1
elif echo "$UPLOAD_OUTPUT" | grep -q "HTTPError: 409"; then
echo "::error::Package $PACKAGE_NAME exists on PyPI but we do not have ownership."
echo "Upload failed with 409 Conflict - package name is taken by another user."
echo "Please choose a different package name."
exit 1
elif echo "$UPLOAD_OUTPUT" | grep -q "Uploading"; then
echo "✅ Package $PACKAGE_NAME exists and we have ownership (upload succeeded)"
echo "Proceeding with update to existing package."
else
echo "⚠️ Could not determine package ownership from upload test."
echo "Upload output: $UPLOAD_OUTPUT"
echo "Proceeding with caution - ensure you have proper permissions."
fi
else
echo "Package name $PACKAGE_NAME is available on PyPI."
echo "package_taken=false" >> $GITHUB_OUTPUT
fi
publish:
needs: [verify-release, validate-package]
if: always() && needs.verify-release.result == 'success' && needs.validate-package.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
environment:
name: pypi-publish
url: https://pypi.org/project/${{ needs.validate-package.outputs.package_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python_version || '3.11' }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Clean build artifacts
run: rm -rf dist/*
- name: Build package
run: python -m build
- name: Verify package integrity
run: |
twine check dist/*
- name: Publish to TestPyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN || secrets.PYPI_TOKEN }}
run: |
echo "📦 Publishing to TestPyPI..."
if [ "${{ inputs.enable_provenance }}" = "true" ]; then
twine upload --non-interactive --repository testpypi --verbose dist/* --provenance
else
twine upload --non-interactive --repository testpypi --verbose dist/*
fi
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
echo "📦 Publishing to PyPI..."
if [ "${{ inputs.enable_provenance }}" = "true" ]; then
twine upload --non-interactive --verbose dist/* --provenance
else
twine upload --non-interactive --verbose dist/*
fi