Skip to content

Commit 2e9e7fa

Browse files
hweawerCopilot
andauthored
fix: fix trusted publishing (#85)
* fix: fix trusted publishing * fix: try different trusted publishing * fix: script * fix: export * fix: export * fix: move common code * fix: publish on each commi * fix: version * Update .github/workflows/test-publish.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: fail on bad response * fix: publish to test on push to main --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 575de79 commit 2e9e7fa

File tree

4 files changed

+246
-11
lines changed

4 files changed

+246
-11
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
"""
3+
PyPI OIDC Trusted Publishing Upload Script
4+
5+
Exchanges OIDC token for PyPI API token and uploads packages using twine.
6+
Replicates the functionality of pypa/gh-action-pypi-publish.
7+
"""
8+
import os
9+
import sys
10+
import subprocess
11+
import argparse
12+
import requests
13+
from pathlib import Path
14+
15+
16+
def exchange_oidc_token(oidc_token: str, token_exchange_url: str) -> str:
17+
"""Exchange OIDC token for PyPI API token."""
18+
print(f"📤 Exchanging OIDC token at {token_exchange_url}...")
19+
try:
20+
mint_token_resp = requests.post(
21+
token_exchange_url,
22+
json={'token': oidc_token},
23+
timeout=5
24+
)
25+
26+
if not mint_token_resp.ok:
27+
try:
28+
error_payload = mint_token_resp.json()
29+
errors = error_payload.get('errors', [])
30+
error_messages = '\n'.join(
31+
f" - {err.get('code', 'unknown')}: {err.get('description', 'no description')}"
32+
for err in errors
33+
)
34+
print(f"❌ Token exchange failed:")
35+
print(error_messages)
36+
print("\n💡 This usually means:")
37+
print(" - Trusted publisher configuration doesn't match")
38+
print(" - Repository, workflow, or environment name mismatch")
39+
print(" - Project name mismatch")
40+
except:
41+
print(f"❌ Token exchange failed: HTTP {mint_token_resp.status_code}")
42+
print(f"Response: {mint_token_resp.text[:500]}")
43+
sys.exit(1)
44+
45+
mint_token_payload = mint_token_resp.json()
46+
pypi_token = mint_token_payload.get('token')
47+
48+
if not pypi_token:
49+
print("❌ Token exchange response missing 'token' field")
50+
print(f"Response: {mint_token_payload}")
51+
sys.exit(1)
52+
53+
print("✅ Successfully exchanged OIDC token for PyPI API token")
54+
return pypi_token
55+
56+
except requests.exceptions.RequestException as e:
57+
print(f"❌ Token exchange request failed: {e}")
58+
sys.exit(1)
59+
except Exception as e:
60+
print(f"❌ Unexpected error during token exchange: {e}")
61+
sys.exit(1)
62+
63+
64+
def upload_packages(repository_url: str, skip_existing: bool = False) -> int:
65+
"""Upload packages to PyPI using twine."""
66+
print("📤 Uploading packages with twine...")
67+
dist_dir = Path("dist")
68+
dist_files = list(dist_dir.glob("*"))
69+
70+
if not dist_files:
71+
print("❌ No distribution files found in dist/")
72+
sys.exit(1)
73+
74+
oidc_token = os.environ.get('OIDC_TOKEN')
75+
token_exchange_url = os.environ.get('TOKEN_EXCHANGE_URL')
76+
77+
if not oidc_token:
78+
print("❌ OIDC token not found")
79+
sys.exit(1)
80+
81+
if not token_exchange_url:
82+
print("❌ Token exchange URL not found")
83+
sys.exit(1)
84+
85+
# Exchange OIDC token for PyPI API token
86+
pypi_token = exchange_oidc_token(oidc_token, token_exchange_url)
87+
88+
# Set up twine environment
89+
os.environ['TWINE_USERNAME'] = '__token__'
90+
os.environ['TWINE_PASSWORD'] = pypi_token
91+
92+
# Build twine command
93+
twine_cmd = [
94+
'twine', 'upload',
95+
'--repository-url', repository_url,
96+
'--verbose'
97+
]
98+
99+
if skip_existing:
100+
twine_cmd.append('--skip-existing')
101+
102+
twine_cmd.extend([str(f) for f in dist_files])
103+
104+
# Run twine upload
105+
result = subprocess.run(
106+
twine_cmd,
107+
capture_output=True,
108+
text=True
109+
)
110+
111+
print(result.stdout)
112+
if result.stderr:
113+
print(result.stderr, file=sys.stderr)
114+
115+
return result.returncode
116+
117+
118+
def main():
119+
parser = argparse.ArgumentParser(
120+
description='Upload Python packages to PyPI using OIDC trusted publishing'
121+
)
122+
parser.add_argument(
123+
'--repository-url',
124+
required=True,
125+
help='PyPI repository URL (e.g., https://upload.pypi.org/legacy/ or https://test.pypi.org/legacy/)'
126+
)
127+
parser.add_argument(
128+
'--skip-existing',
129+
action='store_true',
130+
help='Skip uploading files that already exist on PyPI'
131+
)
132+
133+
args = parser.parse_args()
134+
135+
exit_code = upload_packages(
136+
repository_url=args.repository_url,
137+
skip_existing=args.skip_existing
138+
)
139+
sys.exit(exit_code)
140+
141+
142+
if __name__ == '__main__':
143+
main()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/bin/bash
2+
# Setup OIDC token for PyPI trusted publishing
3+
# Usage: setup_oidc_token.sh <repository_domain>
4+
# Example: setup_oidc_token.sh pypi.org
5+
# setup_oidc_token.sh test.pypi.org
6+
7+
set -euo pipefail
8+
9+
REPOSITORY_DOMAIN="${1:-}"
10+
11+
if [ -z "$REPOSITORY_DOMAIN" ]; then
12+
echo "❌ Repository domain is required"
13+
echo "Usage: $0 <repository_domain>"
14+
exit 1
15+
fi
16+
17+
# Step 1: Get audience from PyPI
18+
echo "🔄 Getting OIDC audience from PyPI..."
19+
AUDIENCE_URL="https://${REPOSITORY_DOMAIN}/_/oidc/audience"
20+
AUDIENCE_RESPONSE=$(curl -sS -w "\n%{http_code}" "$AUDIENCE_URL")
21+
HTTP_CODE=$(echo "$AUDIENCE_RESPONSE" | tail -n1)
22+
AUDIENCE_RESPONSE=$(echo "$AUDIENCE_RESPONSE" | sed '$d')
23+
if [ "$HTTP_CODE" != "200" ]; then
24+
echo "❌ Failed to get audience from PyPI (HTTP $HTTP_CODE). Response: ${AUDIENCE_RESPONSE:-<empty>}"
25+
exit 1
26+
fi
27+
28+
OIDC_AUDIENCE=$(echo "$AUDIENCE_RESPONSE" | jq -r '.audience')
29+
if [ -z "$OIDC_AUDIENCE" ] || [ "$OIDC_AUDIENCE" = "null" ]; then
30+
echo "❌ Invalid audience response: $AUDIENCE_RESPONSE"
31+
exit 1
32+
fi
33+
34+
echo "✅ Got OIDC audience: $OIDC_AUDIENCE"
35+
36+
# Step 2: Get OIDC token from GitHub Actions with the correct audience
37+
echo "🔄 Getting OIDC token from GitHub Actions..."
38+
OIDC_RAW=$(curl -sS -w "\n%{http_code}" -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
39+
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$OIDC_AUDIENCE")
40+
HTTP_CODE=$(echo "$OIDC_RAW" | tail -n1)
41+
OIDC_JSON=$(echo "$OIDC_RAW" | sed '$d')
42+
if [ "$HTTP_CODE" != "200" ]; then
43+
echo "❌ Failed to get OIDC token (HTTP $HTTP_CODE). Response: ${OIDC_JSON:-<empty>}"
44+
exit 1
45+
fi
46+
OIDC_TOKEN=$(echo "$OIDC_JSON" | jq -r '.value')
47+
48+
if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then
49+
echo "❌ Invalid OIDC token response: $OIDC_JSON"
50+
exit 1
51+
fi
52+
53+
echo "✅ OIDC token obtained (length: ${#OIDC_TOKEN})"
54+
55+
# Step 3: Set up token exchange URL
56+
TOKEN_EXCHANGE_URL="https://${REPOSITORY_DOMAIN}/_/oidc/mint-token"
57+
58+
# Export for Python script
59+
export OIDC_TOKEN
60+
export TOKEN_EXCHANGE_URL
61+
62+
echo "✅ OIDC token setup complete"

.github/workflows/publish.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,17 @@ jobs:
4141
run: |
4242
poetry build --no-interaction
4343
44-
- name: Publish to PyPI
45-
uses: pypa/gh-action-pypi-publish@release/v1
44+
- name: Get OIDC token and publish to PyPI
45+
run: |
46+
# Setup OIDC token (sets OIDC_TOKEN and TOKEN_EXCHANGE_URL environment variables)
47+
source .github/scripts/setup_oidc_token.sh pypi.org
48+
49+
# Install dependencies
50+
pip install twine requests
51+
52+
# Exchange token and upload using shared script
53+
python3 .github/scripts/pypi_oidc_upload.py \
54+
--repository-url "https://upload.pypi.org/legacy/"
4655
4756
- name: Success message
4857
run: echo "ℹ️ Published version ${{ steps.extract-version.outputs.version }} 🎉"

.github/workflows/test-publish.yml

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
name: Test PyPI Publishing
22
on:
33
workflow_dispatch:
4-
pull_request:
54
push:
5+
branches: [main]
66

77
jobs:
88
test-publish:
@@ -34,7 +34,23 @@ jobs:
3434
- name: Extract version
3535
id: extract-version
3636
run: |
37-
echo "version=$(poetry version -s)" >> $GITHUB_OUTPUT
37+
BASE_VERSION=$(poetry version -s)
38+
# For test publishes, use dev version with a unique number per commit
39+
# PyPI doesn't allow local versions (with +), so we use .devN format
40+
# Use epoch seconds as dev number for uniqueness (modulo to keep it reasonable)
41+
TIMESTAMP=$(date +%s)
42+
DEV_NUMBER=$((TIMESTAMP % 10000000)) # Keep it under 10 million
43+
# Use dev version format: 2.2.2 -> 2.2.3.dev1234567 (increment patch, add dev number)
44+
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
45+
TEST_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1)).dev${DEV_NUMBER}"
46+
echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT
47+
echo "version=$TEST_VERSION" >> $GITHUB_OUTPUT
48+
echo "test_version=$TEST_VERSION" >> $GITHUB_OUTPUT
49+
50+
- name: Set test version
51+
run: |
52+
# Temporarily set version with dev version + commit SHA for test publish
53+
poetry version ${{ steps.extract-version.outputs.test_version }}
3854
3955
- name: Build distribution
4056
run: |
@@ -45,13 +61,18 @@ jobs:
4561
ls -la dist/
4662
echo "✅ Distributions built successfully"
4763
48-
- name: Test PyPI publish (dry-run check)
49-
uses: pypa/gh-action-pypi-publish@release/v1
50-
with:
51-
repository-url: https://test.pypi.org/legacy/
52-
skip-existing: true
53-
print-hash: true
54-
verbose: true
64+
- name: Get OIDC token and publish to TestPyPI
65+
run: |
66+
# Setup OIDC token (sets OIDC_TOKEN and TOKEN_EXCHANGE_URL environment variables)
67+
source .github/scripts/setup_oidc_token.sh test.pypi.org
68+
69+
# Install dependencies
70+
pip install twine requests
71+
72+
# Exchange token and upload using shared script
73+
# No --skip-existing needed since each commit has a unique version
74+
python3 .github/scripts/pypi_oidc_upload.py \
75+
--repository-url "https://test.pypi.org/legacy/"
5576
5677
- name: Success message
5778
run: echo "ℹ️ Test publish completed for version ${{ steps.extract-version.outputs.version }} 🎉"

0 commit comments

Comments
 (0)